|
| 1 | +#include "ClaudeUsage.h" |
| 2 | + |
| 3 | +#include <QDateTime> |
| 4 | +#include <QDir> |
| 5 | +#include <QJsonArray> |
| 6 | +#include <QJsonDocument> |
| 7 | +#include <QRegularExpression> |
| 8 | + |
| 9 | +#include <algorithm> |
| 10 | +#include <cmath> |
| 11 | + |
| 12 | +namespace ai::claudeusage { |
| 13 | + |
| 14 | +namespace { |
| 15 | + |
| 16 | +std::optional<qint64> fromEpochNumber(double n) |
| 17 | +{ |
| 18 | + if (!std::isfinite(n) || n <= 0.0) |
| 19 | + return std::nullopt; |
| 20 | + return n > 1e10 ? static_cast<qint64>(n) : static_cast<qint64>(n * 1000.0); |
| 21 | +} |
| 22 | + |
| 23 | +// Qt's ISO parser rejects fractional seconds longer than 3 digits (the API |
| 24 | +// sends microseconds), so clamp the fraction to milliseconds first. |
| 25 | +QString clampFraction(const QString &iso) |
| 26 | +{ |
| 27 | + static const QRegularExpression re(QStringLiteral("^(.*T\\d{2}:\\d{2}:\\d{2})\\.(\\d+)(.*)$")); |
| 28 | + const QRegularExpressionMatch m = re.match(iso); |
| 29 | + if (!m.hasMatch()) |
| 30 | + return iso; |
| 31 | + const QString ms = (m.captured(2) + QStringLiteral("000")).left(3); |
| 32 | + return m.captured(1) + QLatin1Char('.') + ms + m.captured(3); |
| 33 | +} |
| 34 | + |
| 35 | +std::optional<RateWindow> mapWindow(const QJsonValue &raw, int windowMinutes) |
| 36 | +{ |
| 37 | + if (!raw.isObject()) |
| 38 | + return std::nullopt; |
| 39 | + const QJsonObject o = raw.toObject(); |
| 40 | + QJsonValue used = o.value(QLatin1String("utilization")); |
| 41 | + if (!used.isDouble()) |
| 42 | + used = o.value(QLatin1String("used_percentage")); |
| 43 | + if (!used.isDouble()) |
| 44 | + return std::nullopt; |
| 45 | + |
| 46 | + RateWindow w; |
| 47 | + w.usedPercent = std::clamp(used.toDouble(), 0.0, 100.0); |
| 48 | + w.windowMinutes = windowMinutes; |
| 49 | + w.resetsAtMs = parseTimestampMs(o.value(QLatin1String("resets_at"))); |
| 50 | + return w; |
| 51 | +} |
| 52 | + |
| 53 | +// Weekly window scoped to the Fable model: a `limits[]` entry or a top-level |
| 54 | +// key, depending on the account rollout. |
| 55 | +std::optional<RateWindow> mapFableWeekly(const QJsonObject &body) |
| 56 | +{ |
| 57 | + const QJsonArray limits = body.value(QLatin1String("limits")).toArray(); |
| 58 | + for (const QJsonValue &v : limits) { |
| 59 | + const QJsonObject limit = v.toObject(); |
| 60 | + if (limit.value(QLatin1String("kind")).toString() != QLatin1String("weekly_scoped")) |
| 61 | + continue; |
| 62 | + if (!limit.value(QLatin1String("percent")).isDouble()) |
| 63 | + continue; |
| 64 | + const QString name = limit.value(QLatin1String("scope")).toObject() |
| 65 | + .value(QLatin1String("model")).toObject() |
| 66 | + .value(QLatin1String("display_name")).toString().trimmed(); |
| 67 | + if (name.compare(QLatin1String("fable"), Qt::CaseInsensitive) != 0) |
| 68 | + continue; |
| 69 | + const QJsonObject synthetic{ |
| 70 | + {QLatin1String("used_percentage"), limit.value(QLatin1String("percent"))}, |
| 71 | + {QLatin1String("resets_at"), limit.value(QLatin1String("resets_at"))}, |
| 72 | + }; |
| 73 | + if (auto w = mapWindow(synthetic, kWeeklyMinutes)) |
| 74 | + return w; |
| 75 | + } |
| 76 | + for (const char *key : {"fable_weekly", "fable_seven_day", "seven_day_fable"}) { |
| 77 | + if (auto w = mapWindow(body.value(QLatin1String(key)), kWeeklyMinutes)) |
| 78 | + return w; |
| 79 | + } |
| 80 | + return std::nullopt; |
| 81 | +} |
| 82 | + |
| 83 | +QString normalizeSpaces(QString s) |
| 84 | +{ |
| 85 | + // CLDR 42+ puts a narrow no-break space before AM/PM. |
| 86 | + s.replace(QChar(0x202F), QLatin1Char(' ')); |
| 87 | + s.replace(QChar(0x00A0), QLatin1Char(' ')); |
| 88 | + return s; |
| 89 | +} |
| 90 | + |
| 91 | +} // namespace |
| 92 | + |
| 93 | +std::optional<qint64> parseTimestampMs(const QJsonValue &value) |
| 94 | +{ |
| 95 | + if (value.isDouble()) |
| 96 | + return fromEpochNumber(value.toDouble()); |
| 97 | + if (!value.isString()) |
| 98 | + return std::nullopt; |
| 99 | + |
| 100 | + const QString s = value.toString().trimmed(); |
| 101 | + if (s.isEmpty()) |
| 102 | + return std::nullopt; |
| 103 | + |
| 104 | + bool numeric = false; |
| 105 | + const double n = s.toDouble(&numeric); |
| 106 | + if (numeric) |
| 107 | + return fromEpochNumber(n); |
| 108 | + |
| 109 | + const QDateTime dt = QDateTime::fromString(clampFraction(s), Qt::ISODateWithMs); |
| 110 | + if (!dt.isValid()) |
| 111 | + return std::nullopt; |
| 112 | + return dt.toMSecsSinceEpoch(); |
| 113 | +} |
| 114 | + |
| 115 | +QString parseCredentialsJson(const QByteArray &raw) |
| 116 | +{ |
| 117 | + const QJsonValue token = QJsonDocument::fromJson(raw).object() |
| 118 | + .value(QLatin1String("claudeAiOauth")).toObject() |
| 119 | + .value(QLatin1String("accessToken")); |
| 120 | + return token.isString() ? token.toString().trimmed() : QString(); |
| 121 | +} |
| 122 | + |
| 123 | +QString credentialsPath(const QString &configDirEnv, const QString &homeDir) |
| 124 | +{ |
| 125 | + const QString base = configDirEnv.isEmpty() ? homeDir + QStringLiteral("/.claude") : configDirEnv; |
| 126 | + return QDir::cleanPath(base + QStringLiteral("/.credentials.json")); |
| 127 | +} |
| 128 | + |
| 129 | +Usage parseUsage(const QJsonObject &body) |
| 130 | +{ |
| 131 | + Usage u; |
| 132 | + u.status = Status::Ok; |
| 133 | + u.session = mapWindow(body.value(QLatin1String("five_hour")), kSessionMinutes); |
| 134 | + u.weekly = mapWindow(body.value(QLatin1String("seven_day")), kWeeklyMinutes); |
| 135 | + u.fableWeekly = mapFableWeekly(body); |
| 136 | + return u; |
| 137 | +} |
| 138 | + |
| 139 | +QString httpErrorMessage(int httpStatus) |
| 140 | +{ |
| 141 | + switch (httpStatus) { |
| 142 | + case 401: |
| 143 | + case 403: |
| 144 | + return QStringLiteral("Sign-in expired - run claude once to refresh it"); |
| 145 | + case 429: |
| 146 | + return QStringLiteral("Rate limited by provider - try again shortly"); |
| 147 | + default: |
| 148 | + return QStringLiteral("Provider returned HTTP %1").arg(httpStatus); |
| 149 | + } |
| 150 | +} |
| 151 | + |
| 152 | +Tone toneFor(double usedPercent) |
| 153 | +{ |
| 154 | + if (usedPercent >= kDangerPercent) |
| 155 | + return Tone::Danger; |
| 156 | + if (usedPercent >= kWarningPercent) |
| 157 | + return Tone::Warning; |
| 158 | + return Tone::Ok; |
| 159 | +} |
| 160 | + |
| 161 | +int displayPercent(double usedPercent, PercentMode mode) |
| 162 | +{ |
| 163 | + const double used = std::clamp(usedPercent, 0.0, 100.0); |
| 164 | + return static_cast<int>(std::lround(mode == PercentMode::Used ? used : 100.0 - used)); |
| 165 | +} |
| 166 | + |
| 167 | +std::optional<double> elapsedPercent(const RateWindow &window, qint64 nowMs) |
| 168 | +{ |
| 169 | + if (!window.resetsAtMs || window.windowMinutes <= 0) |
| 170 | + return std::nullopt; |
| 171 | + const double total = static_cast<double>(window.windowMinutes) * 60'000.0; |
| 172 | + const double elapsed = total - static_cast<double>(*window.resetsAtMs - nowMs); |
| 173 | + return std::clamp(elapsed / total * 100.0, 0.0, 100.0); |
| 174 | +} |
| 175 | + |
| 176 | +QString formatResetIn(std::optional<qint64> resetsAtMs, qint64 nowMs) |
| 177 | +{ |
| 178 | + if (!resetsAtMs) |
| 179 | + return QStringLiteral("reset time unknown"); |
| 180 | + const qint64 minutes = (*resetsAtMs - nowMs) / 60'000; |
| 181 | + if (minutes <= 0) |
| 182 | + return QStringLiteral("resetting now"); |
| 183 | + if (minutes < 60) |
| 184 | + return QStringLiteral("in %1m").arg(minutes); |
| 185 | + const qint64 hours = minutes / 60; |
| 186 | + if (hours < 24) { |
| 187 | + const qint64 rest = minutes % 60; |
| 188 | + return rest ? QStringLiteral("in %1h %2m").arg(hours).arg(rest) |
| 189 | + : QStringLiteral("in %1h").arg(hours); |
| 190 | + } |
| 191 | + const qint64 days = hours / 24; |
| 192 | + const qint64 restHours = hours % 24; |
| 193 | + return restHours ? QStringLiteral("in %1d %2h").arg(days).arg(restHours) |
| 194 | + : QStringLiteral("in %1d").arg(days); |
| 195 | +} |
| 196 | + |
| 197 | +QString formatResetAt(std::optional<qint64> resetsAtMs, qint64 nowMs, const QLocale &locale) |
| 198 | +{ |
| 199 | + if (!resetsAtMs) |
| 200 | + return {}; |
| 201 | + const QDateTime at = QDateTime::fromMSecsSinceEpoch(*resetsAtMs); |
| 202 | + if (!at.isValid()) |
| 203 | + return {}; |
| 204 | + const QString clock = normalizeSpaces(locale.toString(at.time(), QLocale::ShortFormat)); |
| 205 | + if (at.date() == QDateTime::fromMSecsSinceEpoch(nowMs).date()) |
| 206 | + return clock; |
| 207 | + return locale.dayName(at.date().dayOfWeek(), QLocale::ShortFormat) + QLatin1Char(' ') + clock; |
| 208 | +} |
| 209 | + |
| 210 | +QString percentText(double usedPercent, PercentMode mode) |
| 211 | +{ |
| 212 | + return QStringLiteral("%1% %2") |
| 213 | + .arg(displayPercent(usedPercent, mode)) |
| 214 | + .arg(mode == PercentMode::Used ? QStringLiteral("used") : QStringLiteral("left")); |
| 215 | +} |
| 216 | + |
| 217 | +QVector<NamedWindow> windowsOf(const Usage &usage) |
| 218 | +{ |
| 219 | + QVector<NamedWindow> out; |
| 220 | + out.reserve(3); |
| 221 | + if (usage.session) |
| 222 | + out.push_back({WindowKey::Session, QStringLiteral("5-hour"), *usage.session}); |
| 223 | + if (usage.weekly) |
| 224 | + out.push_back({WindowKey::Weekly, QStringLiteral("Weekly"), *usage.weekly}); |
| 225 | + if (usage.fableWeekly) |
| 226 | + out.push_back({WindowKey::FableWeekly, QStringLiteral("Fable weekly"), *usage.fableWeekly}); |
| 227 | + return out; |
| 228 | +} |
| 229 | + |
| 230 | +std::optional<NamedWindow> tightestWindow(const Usage &usage) |
| 231 | +{ |
| 232 | + std::optional<NamedWindow> best; |
| 233 | + for (const NamedWindow &w : windowsOf(usage)) { |
| 234 | + if (!best || w.window.usedPercent > best->window.usedPercent) |
| 235 | + best = w; |
| 236 | + } |
| 237 | + return best; |
| 238 | +} |
| 239 | + |
| 240 | +} // namespace ai::claudeusage |
0 commit comments