contents/ui/CostPresentation.js
.pragma library
.import "Guards.js" as Guards
.import "ProviderNormalizer.js" as Normalizer
// Cost and spend presentation: number formatting, chart geometry, and the row
// and summary builders the Usage & Spend tab and the provider cost sections
// bind to.
//
// Like ProviderNormalizer.js this module must not reach for `Qt`, `Kirigami`,
// or `i18n`. Two consequences shape the interface:
//
// * The caller supplies the words. Every function that would otherwise call
// `i18n` returns figures rather than sentences, so the plural rules and the
// placeholder order stay in QML where `i18np` lives.
// * The caller supplies the locale separators through `numberFormat`, because
// `Qt.locale()` is not reachable from a `.pragma library` module.
//
// Everything here is side-effect free apart from `paintRoundedTopBar`, which
// draws into a caller-supplied canvas context.
var maximumCostHistoryPoints = 365
var maximumProjectRows = 128
var maximumCostTrustNoticeScopes = 128
var maximumIncompleteRequests = 1000000000
// Locale separators, resolved once by the caller from `Qt.locale()`.
function numberFormat(groupSeparator, decimalPoint) {
return {
group: typeof groupSeparator === "string" ? groupSeparator : ",",
decimal: typeof decimalPoint === "string" ? decimalPoint : "."
}
}
function hasOwnKey(item, key) {
return Guards.hasOwnKey(item, key)
}
function isUnsafeObjectKey(key) {
return Guards.isUnsafeObjectKey(key)
}
function boundedText(value, limit) {
if (value === null || value === undefined) {
return ""
}
var text = String(value)
return text.length > limit ? text.slice(0, limit) : text
}
// The metric the charts currently plot. Cost and tokens share one payload, so
// every scale, peak, and summary has to read the same switch.
function hasMetricValue(point, showsTokens) {
if (!point) {
return false
}
var value = showsTokens ? point.tokens : point.cost
return typeof value === "number" && isFinite(value)
}
function metricValue(point, showsTokens) {
if (!hasMetricValue(point, showsTokens)) {
return 0
}
return showsTokens ? point.tokens : point.cost
}
function groupedDecimalString(fmt, value, digits) {
var numeric = Number(value)
if (!isFinite(numeric)) {
return "-"
}
// Number.toFixed returns exponential notation past 1e21, which has no
// fixed-point digits to group ("1e+21" would gain a bogus separator).
if (Math.abs(numeric) >= 1e21) {
return String(Math.abs(numeric))
}
var f = fmt && typeof fmt === "object" ? fmt : numberFormat()
var groupSep = typeof f.group === "string" ? f.group : ","
var decPoint = typeof f.decimal === "string" ? f.decimal : "."
var parts = Math.abs(numeric).toFixed(digits).split(".")
var whole = parts[0]
var grouped = ""
for (var i = 0; i < whole.length; i++) {
if (i > 0 && (whole.length - i) % 3 === 0) {
grouped += groupSep
}
grouped += whole.charAt(i)
}
return parts.length > 1 ? grouped + decPoint + parts[1] : grouped
}
// "Quota" is a credit balance, not money, so it prints as a bare rounded count.
// Like the money path it degrades to "-" for non-numeric input instead of
// printing "NaN".
function amountString(fmt, value, currency) {
var code = typeof currency === "string" ? currency.trim() : ""
if (typeof value !== "number" || !isFinite(value)) {
return "-"
}
var numeric = value
if (code === "Quota") {
return String(Math.round(numeric))
}
var negative = numeric < 0
var amount = groupedDecimalString(fmt, Math.abs(numeric), 2)
if (code === "USD") {
return negative ? "-$" + amount : "$" + amount
}
if (code.length === 0) {
return (negative ? "-" : "") + amount
}
return (negative ? "-" : "") + code + " " + amount
}
// Compact counts use the caller's decimal mark, like costs on the same line:
// "1,5K" beside "1.234,50" instead of the C-locale "1.5K".
function scaledTokenCount(value, fmt) {
if (value >= 10) {
return Number(value).toFixed(0)
}
var f = fmt && typeof fmt === "object" ? fmt : numberFormat()
var decPoint = typeof f.decimal === "string" ? f.decimal : "."
return Number(value).toFixed(1).replace(/\.0$/, "").replace(".", decPoint)
}
// Credit balances are plain counts: a whole balance keeps no fractional part,
// while a fractional one keeps a single digit. Rounding can carry a fractional
// value across the whole boundary ("99.95" -> "100.0"), so the fractional digit
// drops when the rounded figure is whole instead of choosing the precision
// from the unrounded value.
function formatCount(fmt, value) {
var numeric = Number(value)
if (!isFinite(numeric)) {
return "-"
}
var magnitude = Math.abs(numeric)
var digits = magnitude >= 100 || magnitude === Math.round(magnitude) ? 0 : 1
var text = groupedDecimalString(fmt, magnitude, digits)
if (digits === 1) {
var f = fmt && typeof fmt === "object" ? fmt : numberFormat()
var decPoint = typeof f.decimal === "string" ? f.decimal : "."
if (text.slice(-(decPoint.length + 1)) === decPoint + "0") {
text = text.slice(0, -(decPoint.length + 1))
}
}
// A tiny negative magnitude rounds to a displayed zero; its sign must not
// survive as the negative-zero string "-0".
return (numeric < 0 && text !== "0" ? "-" : "") + text
}
function tokenCountString(tokens, fmt) {
var value = Number(tokens)
if (!isFinite(value)) {
return "-"
}
var absValue = Math.abs(value)
var sign = value < 0 ? "-" : ""
// Round inside each unit before choosing it, so a value that rounds up
// across a threshold promotes ("1M") instead of printing an overflowing
// unit ("1000K").
if (Math.round(absValue / 1000000) >= 1000) {
return sign + scaledTokenCount(absValue / 1000000000, fmt) + "B"
}
if (Math.round(absValue / 1000) >= 1000) {
return sign + scaledTokenCount(absValue / 1000000, fmt) + "M"
}
if (Math.round(absValue) >= 1000) {
return sign + scaledTokenCount(absValue / 1000, fmt) + "K"
}
return Math.round(value).toString()
}
// Dense charts keep a one-pixel bar, so their drawing step must account for the
// bar width instead of the nominal point slot. Otherwise the newest bar extends
// beyond the right edge of the canvas.
function chartBarGeometry(width, count) {
var numericWidth = Number(width)
var safeWidth = isFinite(numericWidth) ? Math.max(0, numericWidth) : 0
var numericCount = Math.floor(Number(count))
var points = isFinite(numericCount) ? Math.max(1, numericCount) : 1
var slotStep = safeWidth / points
var gap = Math.max(0, Math.min(4, slotStep / 4))
var barWidth = Math.max(1, slotStep - gap)
if (barWidth > slotStep) {
return {
step: points > 1 ? Math.max(0, safeWidth - barWidth) / (points - 1) : 0,
gap: gap,
barWidth: barWidth,
offset: 0
}
}
return {
step: slotStep,
gap: gap,
barWidth: barWidth,
offset: Math.max(0, slotStep - barWidth) / 2
}
}
// Keep the largest interactive marker fully inside the canvas at both ends.
function chartLineX(width, count, index, inset) {
var numericWidth = Number(width)
var safeWidth = isFinite(numericWidth) ? Math.max(0, numericWidth) : 0
var numericCount = Math.floor(Number(count))
var points = isFinite(numericCount) ? Math.max(1, numericCount) : 1
var numericInset = Number(inset)
var padding = Math.min(safeWidth / 2,
isFinite(numericInset) ? Math.max(0, numericInset) : 0)
if (points === 1) {
return safeWidth / 2
}
var numericIndex = Math.floor(Number(index))
var boundedIndex = isFinite(numericIndex)
? Math.max(0, Math.min(points - 1, numericIndex))
: 0
return padding + (safeWidth - padding * 2) * boundedIndex / (points - 1)
}
function chartLineIndexAt(width, count, positionX, inset) {
var numericWidth = Number(width)
var safeWidth = isFinite(numericWidth) ? Math.max(0, numericWidth) : 0
var numericCount = Math.floor(Number(count))
var points = isFinite(numericCount) ? Math.max(0, numericCount) : 0
if (points === 0 || safeWidth === 0) {
return -1
}
if (points === 1) {
return 0
}
var numericInset = Number(inset)
var padding = Math.min(safeWidth / 2,
isFinite(numericInset) ? Math.max(0, numericInset) : 0)
var numericPosition = Number(positionX)
var boundedPosition = isFinite(numericPosition)
? Math.max(0, Math.min(safeWidth, numericPosition))
: 0
var drawableWidth = safeWidth - padding * 2
var normalizedPosition = drawableWidth > 0
? (boundedPosition - padding) / drawableWidth
: boundedPosition / safeWidth
return Math.max(0, Math.min(points - 1,
Math.round(normalizedPosition * (points - 1))))
}
function chartLineY(height, fraction, inset) {
var numericHeight = Number(height)
var safeHeight = isFinite(numericHeight) ? Math.max(0, numericHeight) : 0
var numericInset = Number(inset)
var padding = Math.min(safeHeight / 2,
isFinite(numericInset) ? Math.max(0, numericInset) : 0)
var numericFraction = Number(fraction)
var boundedFraction = isFinite(numericFraction)
? Math.max(0, Math.min(1, numericFraction))
: 0
return safeHeight - padding - (safeHeight - padding * 2) * boundedFraction
}
function paintRoundedTopBar(context, x, baseline, width, height, radius) {
var numericWidth = Number(width)
var safeWidth = isFinite(numericWidth) ? Math.max(0, numericWidth) : 0
var numericHeight = Number(height)
var safeHeight = isFinite(numericHeight) ? Math.max(0, numericHeight) : 0
var top = baseline - safeHeight
var corner = Math.max(0, Math.min(radius, safeWidth / 2, safeHeight))
context.beginPath()
context.moveTo(x, baseline)
context.lineTo(x, top + corner)
context.quadraticCurveTo(x, top, x + corner, top)
context.lineTo(x + safeWidth - corner, top)
context.quadraticCurveTo(x + safeWidth, top, x + safeWidth, top + corner)
context.lineTo(x + safeWidth, baseline)
context.closePath()
context.fill()
}
// Returns the peak of the currently plotted metric, so the bars keep their
// scale when the Usage & Spend tab switches between cost and tokens.
function sparklineMax(points, showsTokens) {
var maximum = 0
if (!Array.isArray(points)) {
return maximum
}
for (var i = 0; i < points.length; i++) {
maximum = Math.max(maximum, metricValue(points[i], showsTokens))
}
return maximum
}
function metricText(fmt, magnitude, currency, showsTokens) {
return showsTokens
? tokenCountString(magnitude, fmt)
: amountString(fmt, magnitude, currency || "USD")
}
function chartPoints(fmt, points, showsTokens) {
var result = []
if (!Array.isArray(points)) {
return result
}
for (var i = 0; i < points.length; i++) {
var point = points[i]
if (!hasMetricValue(point, showsTokens)) {
continue
}
var value = Math.max(0, metricValue(point, showsTokens))
result.push({
label: boundedText(point.label, 120),
sourceIndex: i,
value: value,
displayValue: metricText(fmt, value, point.currency, showsTokens)
})
}
return result
}
// Returns the previous points while the daily rows, format, and metric are the
// same objects. Qt 6.8 re-notifies a var binding that re-evaluates to an equal
// object, so a shallow provider copy would otherwise hand the chart a new array
// for an unchanged snapshot. Writes only into the caller-owned memo.
function memoizedChartPoints(memo, fmt, points, showsTokens) {
if (!memo || typeof memo !== "object") {
return chartPoints(fmt, points, showsTokens)
}
if (!memo.result || memo.points !== points || memo.fmt !== fmt || memo.showsTokens !== showsTokens) {
memo.points = points
memo.fmt = fmt
memo.showsTokens = showsTokens
memo.result = chartPoints(fmt, points, showsTokens)
}
return memo.result
}
// Chart points omit unavailable amounts. Their index is not a daily-row index.
function selectedCostDay(daily, points, activeIndex) {
if (!Array.isArray(daily) || !Array.isArray(points)
|| typeof activeIndex !== "number" || activeIndex !== Math.floor(activeIndex)
|| activeIndex < 0 || activeIndex >= points.length) {
return null
}
var point = points[activeIndex]
var sourceIndex = point && point.sourceIndex
if (typeof sourceIndex !== "number" || sourceIndex !== Math.floor(sourceIndex)
|| sourceIndex < 0 || sourceIndex >= daily.length) {
return null
}
return daily[sourceIndex] || null
}
function uniqueCostDayIndex(points, label) {
var found = -1
for (var i = 0; i < points.length; i++) {
if (points[i] && points[i].label === label) {
if (found !== -1) {
return -1
}
found = i
}
}
return found
}
function costDayIndexAfterRefresh(previousPoints, selectedIndex, points) {
if (!Array.isArray(previousPoints) || !Array.isArray(points)
|| typeof selectedIndex !== "number" || selectedIndex !== Math.floor(selectedIndex)
|| selectedIndex < 0 || selectedIndex >= previousPoints.length) {
return -1
}
var previous = previousPoints[selectedIndex]
if (!previous || typeof previous.label !== "string" || previous.label.length === 0
|| uniqueCostDayIndex(previousPoints, previous.label) !== selectedIndex) {
return -1
}
return uniqueCostDayIndex(points, previous.label)
}
// The newest plotted point, as a label and an already-formatted value. Returns
// null when there is nothing to summarise; the caller joins the two words.
function sparklineSummary(fmt, points, showsTokens) {
if (!Array.isArray(points) || points.length === 0) {
return null
}
var last = null
for (var i = points.length - 1; i >= 0; i--) {
if (hasMetricValue(points[i], showsTokens)) {
last = points[i]
break
}
}
if (!last) {
return null
}
return {
label: last.label && last.label.length > 0 ? last.label : "",
value: metricText(fmt, metricValue(last, showsTokens), last.currency, showsTokens)
}
}
// `entries` is an ordered list of `{ label, tokens }`. Rows with no positive
// token count are dropped rather than printed as zero.
function breakdownRows(entries, fmt) {
var rows = []
if (!Array.isArray(entries)) {
return rows
}
for (var i = 0; i < entries.length; i++) {
var entry = entries[i]
if (!entry || typeof entry !== "object" || Array.isArray(entry)) {
continue
}
var value = Number(entry.tokens)
if (!isFinite(value) || value <= 0) {
continue
}
rows.push({
label: entry.label,
value: tokenCountString(value, fmt)
})
}
return rows
}
// Missing amounts stay absent; measured zeroes remain visible.
function amountSummary(fmt, amounts, tokensTextFor) {
var values = []
if (hasMetricValue(amounts, false)) {
values.push(amountString(fmt, amounts.cost, amounts.currency))
}
if (hasMetricValue(amounts, true)) {
values.push(tokensTextFor ? tokensTextFor(amounts.tokens) : tokenCountString(amounts.tokens, fmt))
}
return values.length > 0 ? values.join(" · ") : "-"
}
// `tokensTextFor(tokens)` lets the caller word the token half of each summary.
function modelRows(fmt, tokenCost, tokensTextFor) {
var rows = []
if (!tokenCost || !Array.isArray(tokenCost.models)) {
return rows
}
for (var i = 0; i < tokenCost.models.length; i++) {
var item = tokenCost.models[i]
if (!item || typeof item !== "object" || Array.isArray(item)) {
continue
}
rows.push({
label: item.label,
value: amountSummary(fmt, item, tokensTextFor),
incompleteRequests: acceptedIncompleteRequests(item)
})
}
return rows
}
// The history rows cover only the newest days of the range. The peak line must
// read the same span, or it names a day the rows neither show nor highlight.
function recentHistoryPoints(daily) {
return Array.isArray(daily) ? daily.slice(Math.max(0, daily.length - 7)) : []
}
// The last seven days, newest first, scaled against the peak of the selected
// metric so the bar lengths match the figures beside them. A point whose label
// the payload omitted gets `fallbackLabel`.
function historyRows(fmt, tokenCost, showsTokens, fallbackLabel) {
if (!tokenCost || !Array.isArray(tokenCost.daily) || tokenCost.daily.length === 0) {
return []
}
var recentDaily = recentHistoryPoints(tokenCost.daily)
var visibleDaily = []
for (var day = 0; day < recentDaily.length; day++) {
if (hasMetricValue(recentDaily[day], showsTokens)) {
visibleDaily.push(recentDaily[day])
}
}
var rows = []
var maximum = sparklineMax(visibleDaily, showsTokens)
for (var i = visibleDaily.length - 1; i >= 0; i--) {
var item = visibleDaily[i]
var magnitude = Math.max(0, metricValue(item, showsTokens))
rows.push({
label: item.label && item.label.length > 0 ? item.label : fallbackLabel,
value: amountSummary(fmt, item),
incompleteRequests: acceptedIncompleteRequests(item),
percent: maximum > 0 && magnitude > 0 ? Math.max(3, magnitude * 100 / maximum) : 0,
isPeak: maximum > 0 && magnitude === maximum
})
}
return rows
}
// The bars highlight the peak of the selected metric, so this has to name the
// same day, not the most expensive one. Returns null when nothing was spent.
function peakPoint(points, showsTokens) {
if (!Array.isArray(points) || points.length === 0) {
return null
}
var peak = null
for (var i = 0; i < points.length; i++) {
var point = points[i]
if (!point || typeof point !== "object" || Array.isArray(point)) {
continue
}
var magnitude = metricValue(point, showsTokens)
if (!peak || magnitude > peak.magnitude) {
peak = {
label: point.label && point.label.length > 0 ? point.label : "",
magnitude: magnitude,
currency: point.currency || "USD"
}
}
}
return peak && peak.magnitude > 0 ? peak : null
}
function averageDailyValue(points, showsTokens) {
if (!Array.isArray(points) || points.length === 0) {
return null
}
var total = 0
var count = 0
var currency = "USD"
for (var i = 0; i < points.length; i++) {
if (!hasMetricValue(points[i], showsTokens)) {
continue
}
total += Math.max(0, metricValue(points[i], showsTokens))
count += 1
if (points[i].currency) {
currency = points[i].currency
}
}
// Only an empty denominator has no average. A range measured as zero keeps
// its zero, like the history rows built from the same points.
if (count === 0) {
return null
}
return { value: total / count, currency: currency }
}
function perMillionAmount(tokenCost) {
if (!tokenCost || !tokenCost.totals) {
return null
}
var cost = Number(tokenCost.totals.cost)
var tokens = Number(tokenCost.totals.tokens)
if (!isFinite(cost) || !isFinite(tokens) || cost <= 0 || tokens <= 0) {
return null
}
return {
value: cost * 1000000 / tokens,
currency: tokenCost.totals.currency || "USD"
}
}
// The CLI's "today" is the total of the day it scanned. A snapshot scanned on
// an earlier local date, such as one taken before midnight or retained after a
// failed refresh, holds that day's spend, so today reads as unknown until a
// scan from today arrives. A snapshot without a scan date keeps its amounts.
function todayAmounts(tokenCost, nowMs) {
var today = tokenCost && tokenCost.today ? tokenCost.today : null
var scanDay = today && typeof tokenCost.scanDay === "string" ? tokenCost.scanDay : ""
var now = new Date(typeof nowMs === "number" ? nowMs : NaN)
if (scanDay.length === 0 || !isFinite(now.getTime())
|| scanDay === Normalizer.calendarDateKey(now.getFullYear(), now.getMonth() + 1, now.getDate())) {
return today
}
return {
cost: null,
tokens: null,
inputTokens: null,
outputTokens: null,
cacheReadTokens: null,
cacheCreationTokens: null,
currency: today.currency
}
}
// The calendar periods CLI 0.67.0 computes itself with `cost --period`. Any
// other value means the rolling --days range.
function costPeriod(value) {
return value === "month-to-date" || value === "all" ? value : ""
}
// A cached cost snapshot belongs to the selected range only when the CLI
// answered for the same window; a stale snapshot must not be summed into a
// range the user has since changed. A calendar period matches by name, since
// its day count changes as the month goes on.
function snapshotMatchesRange(tokenCost, historyDays, period) {
if (!tokenCost) {
return false
}
var requestedPeriod = costPeriod(period)
if (costPeriod(tokenCost.period) !== requestedPeriod) {
return false
}
if (requestedPeriod.length > 0) {
return true
}
var snapshotDays = Number(tokenCost.historyDays)
return isFinite(snapshotDays)
&& Math.floor(snapshotDays) === Math.floor(Number(historyDays))
}
// `titleFor` maps a provider ID to its display title, so the sort order matches
// what the Usage & Spend tab prints.
function spendSnapshots(tokenCosts, historyDays, titleFor, period) {
var result = []
for (var providerID in tokenCosts) {
if (!hasOwnKey(tokenCosts, providerID)) {
continue
}
var tokenCost = tokenCosts[providerID]
if (!snapshotMatchesRange(tokenCost, historyDays, period)) {
continue
}
result.push(tokenCost)
}
result.sort(function(a, b) {
var left = titleFor ? titleFor(a.provider) : String(a.provider)
var right = titleFor ? titleFor(b.provider) : String(b.provider)
return left.localeCompare(right)
})
return result
}
// Callers supply range-matched snapshots in their displayed provider order.
// Rank within each provider, so different currencies are never compared.
function projectRows(costs, showsTokens) {
var result = { rows: [], truncated: false }
var items = Array.isArray(costs) ? costs : []
for (var i = 0; i < items.length; i++) {
var snapshot = items[i]
var projects = snapshot && typeof snapshot === "object" ? snapshot.projects : null
if (!projects || !Array.isArray(projects.rows)) {
continue
}
result.truncated = result.truncated || projects.truncated === true
if (result.rows.length === maximumProjectRows) {
result.truncated = result.truncated || projects.rows.length > 0
continue
}
// The slice bounds the inspected payload before filtering, so the
// truncation check below can tell limit-driven omissions apart from
// malformed rows that were never displayable.
var inspectedRows = projects.rows.slice(0, maximumProjectRows)
var rows = inspectedRows
.filter(function(row) { return row && typeof row === "object" && !Array.isArray(row) })
rows.sort(function(a, b) {
var aHasValue = hasMetricValue(a, showsTokens)
var bHasValue = hasMetricValue(b, showsTokens)
if (aHasValue !== bHasValue) {
return aHasValue ? -1 : 1
}
var difference = metricValue(b, showsTokens) - metricValue(a, showsTokens)
var left = boundedText(a.label, 120)
var right = boundedText(b.label, 120)
return difference || left.localeCompare(right)
})
var trust = costTrustSummary([snapshot])
for (var j = 0; j < rows.length && result.rows.length < maximumProjectRows; j++) {
result.rows.push({
provider: snapshot.provider,
label: boundedText(rows[j].label, 120),
cost: rows[j].cost,
tokens: rows[j].tokens,
currency: rows[j].currency,
valueMode: trust ? trust.valueMode : "plain"
})
}
result.truncated = result.truncated
|| projects.rows.length > inspectedRows.length
|| j < rows.length
}
return result
}
function spendCurrency(costs) {
var items = Array.isArray(costs) ? costs : []
// A token-only snapshot still carries a fallback currency, but it must not
// choose which priced providers participate in the money aggregate.
for (var i = 0; i < items.length; i++) {
var snapshot = items[i]
var totals = snapshot && typeof snapshot === "object" && snapshot.totals
? snapshot.totals : ({})
if (hasMetricValue(totals, false)) {
var totalsCurrency = boundedText(totals.currency || "", 12)
if (totalsCurrency.length > 0) {
return totalsCurrency
}
}
var daily = snapshot && Array.isArray(snapshot.daily) ? snapshot.daily : []
for (var j = 0; j < daily.length; j++) {
if (!hasMetricValue(daily[j], false)) {
continue
}
var dailyCurrency = boundedText(daily[j].currency || "", 12)
if (dailyCurrency.length > 0) {
return dailyCurrency
}
}
}
for (var fallbackIndex = 0; fallbackIndex < items.length; fallbackIndex++) {
var fallbackSnapshot = items[fallbackIndex]
var fallbackTotals = fallbackSnapshot && typeof fallbackSnapshot === "object"
&& fallbackSnapshot.totals ? fallbackSnapshot.totals : ({})
var fallbackCurrency = boundedText(fallbackTotals.currency || "", 12)
if (fallbackCurrency.length > 0) {
return fallbackCurrency
}
var fallbackDaily = fallbackSnapshot && Array.isArray(fallbackSnapshot.daily) ? fallbackSnapshot.daily : []
if (fallbackDaily.length > 0) {
var firstDaily = fallbackDaily[0]
var firstCurrency = firstDaily && typeof firstDaily === "object"
? firstDaily.currency : null
return boundedText(firstCurrency || "USD", 12)
}
}
return "USD"
}
function costMatchesSpendCurrency(cost, currency) {
var totals = cost && cost.totals ? cost.totals : ({})
return boundedText(totals.currency || currency, 12) === currency
}
// Cost figures from different currencies cannot share one aggregate. The
// caller uses this to label the selected-currency amount as a subtotal and to
// explain why token totals can cover more providers than money totals.
function spendHasMixedCostCurrencies(costs) {
var items = Array.isArray(costs) ? costs : []
var currency = spendCurrency(items)
for (var i = 0; i < items.length; i++) {
var snapshot = items[i]
var totals = snapshot && typeof snapshot === "object" && snapshot.totals
? snapshot.totals : ({})
if (hasMetricValue(totals, false)
&& boundedText(totals.currency || currency, 12) !== currency) {
return true
}
var daily = snapshot && Array.isArray(snapshot.daily) ? snapshot.daily : []
for (var j = 0; j < daily.length; j++) {
if (hasMetricValue(daily[j], false)
&& boundedText(daily[j].currency || "USD", 12) !== currency) {
return true
}
}
}
return false
}
function acceptedCostCoverage(value) {
if (value === null || typeof value !== "object" || Array.isArray(value)) {
return null
}
var fields = ["priced", "unpriced", "unmetered", "estimated"]
for (var i = 0; i < fields.length; i++) {
var field = fields[i]
var count = value[field]
if (!hasOwnKey(value, field)
|| typeof count !== "number"
|| !isFinite(count)
|| Math.floor(count) !== count
|| count < 0) {
return null
}
}
return value
}
// Excluded requests are counted, never summed into an amount. Keep the total
// bounded so a saturated upstream count cannot overflow the joined notice.
// Accepts a trust record, a daily point, or a model aggregate alike.
function acceptedIncompleteRequests(trust) {
if (trust === null
|| typeof trust !== "object"
|| Array.isArray(trust)
|| !hasOwnKey(trust, "incompleteRequests")) {
return 0
}
var count = trust.incompleteRequests
return typeof count === "number"
&& isFinite(count)
&& Math.floor(count) === count
&& count > 0
? Math.min(count, maximumIncompleteRequests)
: 0
}
function boundedIncompleteRequests(count) {
return Math.min(Math.max(0, count), maximumIncompleteRequests)
}
function acceptedCostSourceKind(value) {
return typeof value === "string"
&& ["listPrice", "vendor", "mixed", "unknown"]
.indexOf(value) !== -1
? value
: ""
}
// Provenance without observed cost, token, daily, or coverage data cannot
// qualify a displayed total. Missing token totals normalize to zero, so only a
// positive token count is evidence; explicit finite cost values matter at zero.
function costTrustSnapshotHasEvidence(item) {
if (!item) {
return false
}
var totals = item.totals || ({})
if (hasMetricValue(totals, false)
|| (hasMetricValue(totals, true) && totals.tokens > 0)) {
return true
}
var trust = item.trust
var coverage = trust !== null
&& typeof trust === "object"
&& !Array.isArray(trust)
&& hasOwnKey(trust, "coverage")
? acceptedCostCoverage(trust.coverage)
: null
if (coverage && (coverage.priced > 0
|| coverage.unpriced > 0
|| coverage.unmetered > 0
|| coverage.estimated > 0)) {
return true
}
var daily = Array.isArray(item.daily) ? item.daily : []
for (var i = 0; i < daily.length; i++) {
if (hasMetricValue(daily[i], false)
|| (hasMetricValue(daily[i], true) && daily[i].tokens > 0)) {
return true
}
}
return false
}
// Fold normalized pricing coverage and provenance into one presentation
// decision. The caller localizes it; wire enum values never cross this seam.
// For global spend, currency eligibility deliberately matches `spendTotals()`.
// Token-only snapshots still qualify a mixed total, but cannot create a notice
// when there is no finite cost to qualify.
function costTrustSummary(costs) {
var items = Array.isArray(costs) ? costs : []
if (items.length === 0) {
return null
}
var currency = spendCurrency(items)
var hasEstimated = false
var hasUnpriced = false
var hasUnmetered = false
var hasListPrice = false
var hasVendor = false
var hasMixed = false
var hasUnknown = false
var hasUnclassifiedSource = false
var hasDisplayedCost = false
var hasDisplayedTokens = false
var incompleteRequests = 0
for (var i = 0; i < items.length; i++) {
var item = items[i]
if (!costTrustSnapshotHasEvidence(item)) {
continue
}
var itemHasCost = item && hasMetricValue(item.totals, false)
if (!item || (itemHasCost && !costMatchesSpendCurrency(item, currency))) {
continue
}
hasDisplayedCost = hasDisplayedCost
|| itemHasCost
// Excluded requests are missing from the token totals too, so a
// token-only snapshot still has an amount they can qualify.
hasDisplayedTokens = hasDisplayedTokens
|| (hasMetricValue(item.totals, true) && item.totals.tokens > 0)
if (!hasOwnKey(item, "trust")
|| item.trust === null
|| typeof item.trust !== "object"
|| Array.isArray(item.trust)) {
hasUnclassifiedSource = true
continue
}
var coverage = hasOwnKey(item.trust, "coverage")
? acceptedCostCoverage(item.trust.coverage)
: null
if (coverage) {
hasEstimated = hasEstimated || coverage.estimated > 0
hasUnpriced = hasUnpriced || coverage.unpriced > 0
hasUnmetered = hasUnmetered || coverage.unmetered > 0
}
incompleteRequests = boundedIncompleteRequests(
incompleteRequests + acceptedIncompleteRequests(item.trust))
var sourceKind = hasOwnKey(item.trust, "sourceKind")
? acceptedCostSourceKind(item.trust.sourceKind)
: ""
switch (sourceKind) {
case "listPrice":
hasListPrice = true
break
case "vendor":
hasVendor = true
break
case "mixed":
hasMixed = true
break
case "unknown":
hasUnknown = true
break
default:
hasUnclassifiedSource = true
break
}
}
var sourceKind = ""
if (hasUnknown) {
sourceKind = "unknown"
} else if (hasMixed || (hasListPrice && hasVendor)) {
sourceKind = "mixed"
} else if (hasListPrice) {
sourceKind = "listPrice"
} else if (hasVendor) {
sourceKind = "vendor"
}
if (hasListPrice || hasMixed) {
hasEstimated = true
}
// A source reported by only part of the displayed total cannot describe
// the whole amount. Keep all-legacy totals quiet, but make a mixed
// classified/unclassified aggregate explicitly conservative.
if (hasUnclassifiedSource && sourceKind.length > 0) {
sourceKind = "unknown"
}
var hasExcludedRequests = incompleteRequests > 0
&& (hasDisplayedCost || hasDisplayedTokens)
var isPartial = hasUnpriced || hasUnmetered || hasExcludedRequests
if ((!hasDisplayedCost && !hasExcludedRequests)
|| (!hasEstimated && !isPartial && sourceKind.length === 0)) {
return null
}
return {
level: isPartial || sourceKind === "unknown" ? "warning" : "information",
valueMode: isPartial ? "partial"
: (sourceKind === "unknown" ? "approximate"
: (hasEstimated ? "estimated" : "plain")),
sourceKind: sourceKind,
hasEstimated: hasEstimated,
hasUnpriced: hasUnpriced,
hasUnmetered: hasUnmetered,
incompleteRequests: hasExcludedRequests ? incompleteRequests : 0
}
}
function costTrustNoticeKey(summary) {
if (summary === null
|| typeof summary !== "object"
|| Array.isArray(summary)) {
return ""
}
var sourceKind = hasOwnKey(summary, "sourceKind")
? acceptedCostSourceKind(summary.sourceKind)
: ""
var hasEstimated = hasOwnKey(summary, "hasEstimated")
&& summary.hasEstimated === true
var hasUnpriced = hasOwnKey(summary, "hasUnpriced")
&& summary.hasUnpriced === true
var hasUnmetered = hasOwnKey(summary, "hasUnmetered")
&& summary.hasUnmetered === true
// Dismissal is semantic: a changed count describes the same warning, so it
// must not revive a notice the user already closed.
var hasIncomplete = acceptedIncompleteRequests(summary) > 0
if (sourceKind.length === 0
&& !hasEstimated
&& !hasUnpriced
&& !hasUnmetered
&& !hasIncomplete) {
return ""
}
return [
hasOwnKey(summary, "level") && summary.level === "warning"
? "warning" : "information",
sourceKind,
hasEstimated ? "estimated" : "exact",
hasUnpriced ? "unpriced" : "priced",
hasUnmetered ? "unmetered" : "metered",
hasIncomplete ? "incomplete" : "complete"
].join("|")
}
// Keep dismissal state semantic rather than tying it to localized text or a
// freshly allocated summary object. Refreshes preserve a dismissal; a changed
// warning meaning becomes visible again, including a return to an older state.
function costTrustNoticeTransition(summary, previousState, shouldDismiss) {
var key = costTrustNoticeKey(summary)
var previousKey = ""
var wasDismissed = false
if (previousState !== null
&& typeof previousState === "object"
&& !Array.isArray(previousState)) {
previousKey = hasOwnKey(previousState, "key")
&& typeof previousState.key === "string"
? previousState.key
: ""
wasDismissed = hasOwnKey(previousState, "dismissed")
&& previousState.dismissed === true
}
var dismissed = key.length > 0
&& key === previousKey
&& wasDismissed
if (shouldDismiss === true && key.length > 0) {
dismissed = true
}
return {
key: key,
dismissed: dismissed,
shouldShow: key.length > 0 && !dismissed
}
}
// The popup representation may be destroyed while the compact applet remains
// alive. Keep each surface's semantic notice state in the applet owner so a
// recreated notice does not forget that the user closed it.
function costTrustNoticeStoreTransition(summary, states, scope, shouldDismiss) {
var safeScope = boundedText(scope, 120)
if (safeScope.length === 0 || isUnsafeObjectKey(safeScope)) {
safeScope = ""
}
var source = states !== null
&& typeof states === "object"
&& !Array.isArray(states)
? states
: ({})
var previousState = safeScope.length > 0 && hasOwnKey(source, safeScope)
? source[safeScope]
: null
var state = costTrustNoticeTransition(summary, previousState, shouldDismiss)
var nextStates = ({})
var copiedScopes = 0
for (var existingScope in source) {
if (!hasOwnKey(source, existingScope)
|| isUnsafeObjectKey(existingScope)
|| existingScope === safeScope
|| copiedScopes >= maximumCostTrustNoticeScopes - 1) {
continue
}
nextStates[existingScope] = source[existingScope]
copiedScopes += 1
}
var stateToStore = state
if (state.key.length === 0
&& previousState !== null
&& typeof previousState === "object"
&& !Array.isArray(previousState)
&& hasOwnKey(previousState, "key")
&& typeof previousState.key === "string"
&& previousState.key.length > 0
&& hasOwnKey(previousState, "dismissed")
&& typeof previousState.dismissed === "boolean") {
stateToStore = previousState
}
if (safeScope.length > 0 && stateToStore.key.length > 0) {
nextStates[safeScope] = stateToStore
}
return { states: nextStates, state: state }
}
function spendDailyPoints(fmt, costs, showsTokens) {
var items = Array.isArray(costs) ? costs : []
var byDate = ({})
var currency = spendCurrency(items)
for (var i = 0; i < items.length; i++) {
var snapshot = items[i]
var daily = snapshot && Array.isArray(snapshot.daily) ? snapshot.daily : []
for (var j = 0; j < daily.length; j++) {
var point = daily[j]
if (!point || typeof point !== "object" || Array.isArray(point)) {
continue
}
var label = boundedText(point.label, 120)
var pointCurrency = boundedText(point.currency || "USD", 12)
// Mixed currencies cannot be summed as money, but token counts are
// currency-free: filtering them would silently drop whole providers
// from the chart.
if (label.length === 0
|| isUnsafeObjectKey(label)
|| !hasMetricValue(point, showsTokens)
|| (!showsTokens && pointCurrency !== currency)) {
continue
}
if (!hasOwnKey(byDate, label)) {
byDate[label] = { label: label, value: 0, currency: currency }
}
byDate[label].value += Math.max(0, metricValue(point, showsTokens))
}
}
var result = []
for (var day in byDate) {
if (!hasOwnKey(byDate, day)) {
continue
}
var item = byDate[day]
item.displayValue = metricText(fmt, item.value, item.currency, showsTokens)
result.push(item)
}
result.sort(function(a, b) { return a.label.localeCompare(b.label) })
return result.slice(Math.max(0, result.length - maximumCostHistoryPoints))
}
// Money is summed only in the range currency. Tokens have no currency, so the
// total keeps every provider just like the token chart does.
function spendTotals(costs) {
var items = Array.isArray(costs) ? costs : []
if (items.length === 0) {
return null
}
var currency = spendCurrency(items)
var totalCost = 0
var totalTokens = 0
var hasCost = false
var hasTokens = false
for (var i = 0; i < items.length; i++) {
var snapshot = items[i]
var totals = snapshot && typeof snapshot === "object" && snapshot.totals
? snapshot.totals : ({})
if (hasMetricValue(totals, true)) {
totalTokens += Math.max(0, totals.tokens)
hasTokens = true
}
if (!costMatchesSpendCurrency(items[i], currency)
|| !hasMetricValue(totals, false)) {
continue
}
totalCost += Math.max(0, totals.cost)
hasCost = true
}
return {
cost: hasCost ? totalCost : null,
tokens: hasTokens && isFinite(totalTokens) ? totalTokens : null,
currency: currency,
hasMixedCostCurrencies: spendHasMixedCostCurrencies(items)
}
}
// The CLI reports whether its local log scan already covers the requested
// window; until it does, the earliest bars are short for a scan reason rather
// than a spend reason. A missing flag counts as established.
function historyStillBuilding(costs) {
var items = Array.isArray(costs) ? costs : []
for (var i = 0; i < items.length; i++) {
var snapshot = items[i]
if (snapshot && typeof snapshot === "object"
&& snapshot.historyCoverageEstablished === false) {
return true
}
}
return false
}
// The selected metric can omit unavailable days. Use the unfiltered snapshots'
// calendar bounds so missing values at either edge keep their slots too.
// Legacy labels with no unambiguous calendar order keep their bounded sequence.
function spendHeatmapDays(points, costs) {
var items = Array.isArray(points) ? points.slice(-maximumCostHistoryPoints) : []
var byDate = ({})
var firstDayMs = 0
var lastDayMs = 0
for (var i = 0; i < items.length; i++) {
var point = items[i]
var date = point && typeof point.label === "string"
? Normalizer.parsedCalendarDateKey(point.label) : null
if (!date || (i > 0 && date.timestampMs <= lastDayMs)) {
return items
}
if (i === 0) {
firstDayMs = date.timestampMs
}
lastDayMs = date.timestampMs
byDate[lastDayMs] = point
}
if (items.length === 0) {
return []
}
var snapshots = Array.isArray(costs) ? costs : []
for (var providerIndex = 0; providerIndex < Math.min(snapshots.length, Normalizer.maximumCostSnapshots); providerIndex++) {
var snapshot = snapshots[providerIndex]
var daily = snapshot && Array.isArray(snapshot.daily) ? snapshot.daily : []
for (var dayIndex = Math.max(0, daily.length - maximumCostHistoryPoints); dayIndex < daily.length; dayIndex++) {
var calendarPoint = daily[dayIndex]
var calendarDate = calendarPoint && typeof calendarPoint.label === "string"
? Normalizer.parsedCalendarDateKey(calendarPoint.label) : null
if (calendarDate) {
firstDayMs = Math.min(firstDayMs, calendarDate.timestampMs)
lastDayMs = Math.max(lastDayMs, calendarDate.timestampMs)
}
}
}
// UTC calendar keys keep DST changes from adding or losing a day.
var dayMs = 24 * 60 * 60 * 1000
firstDayMs = Math.max(firstDayMs, lastDayMs - (maximumCostHistoryPoints - 1) * dayMs)
var days = []
for (var currentDayMs = firstDayMs; currentDayMs <= lastDayMs; currentDayMs += dayMs) {
days.push(hasOwnKey(byDate, currentDayMs) ? byDate[currentDayMs] : null)
}
return days
}
// Lays the daily points out for the activity heatmap's fixed seven rows,
// filled top to bottom and column by column. The newest day is followed by
// `trailingSlots` empty slots that close its week, so every row stays one
// weekday; the oldest end is padded with `null` placeholders, which keeps a
// ragged first column from cutting a notch out of the block. Points beyond the
// capacity are dropped, oldest first.
function spendHeatmapCells(points, capacity, trailingSlots) {
var items = Array.isArray(points) ? points : []
var slots = Math.max(0, Math.floor(Number(capacity) || 0))
var trailing = Math.max(0, Math.min(slots, Math.floor(Number(trailingSlots) || 0)))
var dayCapacity = slots - trailing
var visible = dayCapacity > 0 ? items.slice(Math.max(0, items.length - dayCapacity)) : []
var cells = []
for (var i = visible.length; i < dayCapacity; i++) {
cells.push(null)
}
cells = cells.concat(visible)
for (var j = 0; j < trailing; j++) {
cells.push(null)
}
return cells
}
// QML's Locale.firstDayOfWeek counts 0 (Sunday) through 6; anything else falls
// back to the ISO week's Monday start.
function heatmapFirstWeekday(firstWeekday) {
return typeof firstWeekday === "number" && Math.floor(firstWeekday) === firstWeekday
&& firstWeekday >= 0 && firstWeekday <= 6 ? firstWeekday : 1
}
// Weekday of the newest slot, 0 (Sunday) through 6, or -1 when the slots do not
// carry one weekday per row. spendHeatmapDays keeps unavailable days as `null`
// slots; its fallback for undated or unordered points returns -1 here so the
// caller leaves the rows unlabelled rather than naming the wrong days.
function spendHeatmapNewestWeekday(days) {
var items = Array.isArray(days) ? days : []
if (items.length === 0 || items.length > maximumCostHistoryPoints) {
return -1
}
var dayMs = 24 * 60 * 60 * 1000
var newestSlotMs = null
for (var i = 0; i < items.length; i++) {
if (items[i] === null) {
continue
}
var date = typeof items[i] === "object" && typeof items[i].label === "string"
? Normalizer.parsedCalendarDateKey(items[i].label) : null
if (!date) {
return -1
}
var slotMs = date.timestampMs + (items.length - 1 - i) * dayMs
if (newestSlotMs !== null && slotMs !== newestSlotMs) {
return -1
}
newestSlotMs = slotMs
}
if (newestSlotMs === null) {
return -1
}
return new Date(newestSlotMs).getUTCDay()
}
// Weekday of each heatmap row, top to bottom, as 0 (Sunday) through 6. Rows
// start on the locale's first day of the week, so each weekday keeps its row
// as the range moves; [] leaves undated rows unlabelled.
function spendHeatmapRowWeekdays(days, firstWeekday) {
if (spendHeatmapNewestWeekday(days) < 0) {
return []
}
var first = heatmapFirstWeekday(firstWeekday)
var weekdays = []
for (var row = 0; row < 7; row++) {
weekdays.push((first + row) % 7)
}
return weekdays
}
// Empty slots after the newest day that close its week in the locale's row
// order; 0 when the rows carry no weekday and the newest day simply ends the grid.
function spendHeatmapTrailingSlots(days, firstWeekday) {
var newestWeekday = spendHeatmapNewestWeekday(days)
if (newestWeekday < 0) {
return 0
}
return 6 - (newestWeekday - heatmapFirstWeekday(firstWeekday) + 7) % 7
}