The shell starts before DHCP finishes, so Claude's first probe of the OAuth usage endpoint came back as a transport failure and pinned "Claude limits unavailable" for a full refresh interval. Opening the panel could not clear it either: the open path refreshed unforced and hit the same throttle it was trying to escape. A transport failure now arms a short retry that goes straight to the probe, since a retry answering to the throttle never gets off the ground. Any real answer disarms it — a server that replied, 429 included, is one to stop pestering. Probes are single-flight, a fresh token skips the throttle its predecessor set, and opening the panel asks for the wire without re-walking every transcript on disk. The module also earns its place in the bar now instead of sitting there dimmed with nothing to say: it collapses out entirely until a provider has actually recorded usage, here or on a synced machine. That made both presence probes redundant, so they are gone. Selection follows the provider rather than its slot, so one appearing while the panel is open no longer swaps out what you were reading. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
570 lines
21 KiB
QML
570 lines
21 KiB
QML
import QtQuick
|
|
import Quickshell
|
|
import Quickshell.Io
|
|
|
|
Item {
|
|
id: root
|
|
visible: false
|
|
|
|
property string providerId: "claude"
|
|
property string providerName: "Claude Code"
|
|
property string providerIcon: "ai"
|
|
property bool enabled: false
|
|
property bool ready: false
|
|
property bool refreshing: false
|
|
property double lastRefreshedAtMs: 0
|
|
property string usageStatusText: ""
|
|
|
|
property real rateLimitPercent: -1
|
|
property string rateLimitLabel: "Session (5-hour)"
|
|
property string rateLimitResetAt: ""
|
|
property real secondaryRateLimitPercent: -1
|
|
property string secondaryRateLimitLabel: "Weekly (7-day)"
|
|
property string secondaryRateLimitResetAt: ""
|
|
|
|
property int todayPrompts: 0
|
|
property int todaySessions: 0
|
|
property int todayTotalTokens: 0
|
|
property var todayTokensByModel: ({})
|
|
|
|
property var recentDays: []
|
|
property int totalPrompts: 0
|
|
property int totalSessions: 0
|
|
property int activeDays: 0
|
|
property var activeDates: []
|
|
property var modelUsage: ({})
|
|
property var dailyActivity: []
|
|
|
|
property string tierLabel: ""
|
|
property string authHelpText: "Run `claude auth login` to restore authoritative usage."
|
|
property bool hasLocalStats: true
|
|
property bool hasProjectStats: false
|
|
|
|
property string oauthAccessToken: ""
|
|
property double oauthExpiresAtMs: 0
|
|
property string authMode: "none"
|
|
property string subscriptionType: ""
|
|
property string rateLimitTier: ""
|
|
property bool hasAuthoritativeRateLimit: false
|
|
|
|
property bool probeInFlight: false
|
|
property double lastProbeAtMs: 0
|
|
property int probeMinIntervalMs: 15 * 60 * 1000
|
|
// Opening the panel asks for fresh limits, so a forced probe skips the
|
|
// background interval — but not so freely that flicking the panel open and
|
|
// shut turns into a request per flick.
|
|
property int probeForcedMinIntervalMs: 15 * 1000
|
|
property int probeRetryMs: 30 * 1000
|
|
property bool projectScanRerunForce: false
|
|
|
|
property var providerSettings: ({})
|
|
|
|
function resolvePath(p) {
|
|
if (p && p.startsWith("~"))
|
|
return (Quickshell.env("HOME") ?? "/home") + p.substring(1);
|
|
return p;
|
|
}
|
|
|
|
function pathFromUrl(url) {
|
|
const value = String(url || "");
|
|
if (value.indexOf("file://") === 0)
|
|
return decodeURIComponent(value.substring(7));
|
|
return value;
|
|
}
|
|
|
|
readonly property string projectScannerScriptPath: pathFromUrl(Qt.resolvedUrl("../scripts/claude_usage_scanner.py"))
|
|
|
|
FileView {
|
|
id: statsFile
|
|
path: root.resolvePath(root.providerSettings?.statsPath ?? "~/.claude/stats-cache.json")
|
|
watchChanges: true
|
|
printErrors: false
|
|
onFileChanged: reload()
|
|
onLoaded: root.parseStats(text())
|
|
}
|
|
|
|
FileView {
|
|
id: historyFile
|
|
path: root.resolvePath("~/.claude/history.jsonl")
|
|
watchChanges: true
|
|
onFileChanged: reload()
|
|
onLoaded: root.parseHistory(text())
|
|
onLoadFailed: error => {
|
|
if (error === FileViewError.FileNotFound)
|
|
console.error("model-usage/claude", "history.jsonl not found");
|
|
}
|
|
}
|
|
|
|
FileView {
|
|
id: credentialsFile
|
|
path: root.resolvePath(root.providerSettings?.credentialsPath ?? "~/.claude/.credentials.json")
|
|
watchChanges: true
|
|
onFileChanged: reload()
|
|
onLoaded: root.parseCredentials(text())
|
|
onLoadFailed: error => {
|
|
if (error === FileViewError.FileNotFound)
|
|
console.error("model-usage/claude", "credentials.json not found at", credentialsFile.path);
|
|
}
|
|
}
|
|
|
|
Process {
|
|
id: projectScanner
|
|
running: false
|
|
command: []
|
|
|
|
stdout: StdioCollector {
|
|
waitForEnd: true
|
|
onStreamFinished: root.applyProjectUsageSummary(text)
|
|
}
|
|
|
|
stderr: StdioCollector {
|
|
waitForEnd: true
|
|
onStreamFinished: if (text.trim() !== "") console.warn("model-usage/claude", text.trim())
|
|
}
|
|
|
|
onExited: {
|
|
root.finishRefresh();
|
|
if (root.projectScanRerunForce) {
|
|
root.projectScanRerunForce = false;
|
|
root.startProjectScanner(true);
|
|
}
|
|
}
|
|
}
|
|
|
|
Timer {
|
|
interval: root.probeMinIntervalMs
|
|
running: root.enabled && root.oauthAccessToken !== ""
|
|
repeat: true
|
|
onTriggered: root.probeRateLimits(false)
|
|
}
|
|
|
|
// The first probe fires seconds after login, often before DHCP has handed
|
|
// out a route, and comes back as a transport failure rather than an answer
|
|
// from Anthropic. Try again shortly instead of showing "limits unavailable"
|
|
// until the next background poll a quarter of an hour later.
|
|
Timer {
|
|
id: probeRetry
|
|
interval: root.probeRetryMs
|
|
repeat: false
|
|
// Straight to the probe: a retry that answered to the same throttle
|
|
// that spaces out ordinary polls would never get off the ground.
|
|
onTriggered: if (root.enabled && root.oauthAccessToken && !root.oauthTokenExpired()) root.probeOAuthUsage()
|
|
}
|
|
|
|
// Credentials load whether or not the panel wants this provider, so a
|
|
// switched-off Claude must not keep knocking on a downed network forever.
|
|
onEnabledChanged: if (!enabled) probeRetry.stop()
|
|
|
|
function localDateString() {
|
|
const now = new Date();
|
|
const y = now.getFullYear();
|
|
const m = String(now.getMonth() + 1).padStart(2, "0");
|
|
const d = String(now.getDate()).padStart(2, "0");
|
|
return y + "-" + m + "-" + d;
|
|
}
|
|
|
|
function parseStats(content) {
|
|
try {
|
|
const data = JSON.parse(content);
|
|
const today = localDateString();
|
|
|
|
const dailyModelTokens = data.dailyModelTokens ?? [];
|
|
const todayTokenEntry = dailyModelTokens.find(d => d.date === today);
|
|
root.todayTokensByModel = todayTokenEntry?.tokensByModel ?? {};
|
|
|
|
let tokenSum = 0;
|
|
const toks = root.todayTokensByModel;
|
|
for (const k in toks)
|
|
tokenSum += toks[k];
|
|
root.todayTotalTokens = tokenSum;
|
|
|
|
root.dailyActivity = data.dailyActivity ?? [];
|
|
root.recentDays = root.dailyActivity.slice(-7);
|
|
if (!root.hasProjectStats)
|
|
root.applyFallbackActiveDays(root.dailyActivity);
|
|
root.modelUsage = data.modelUsage ?? {};
|
|
root.totalPrompts = data.totalMessages ?? 0;
|
|
root.totalSessions = data.totalSessions ?? 0;
|
|
root.ready = true;
|
|
} catch (e) {
|
|
console.error("model-usage/claude", "Failed to parse stats-cache.json:", e);
|
|
}
|
|
}
|
|
|
|
// stats-cache.json has no day count of its own, so recover one from the
|
|
// daily activity it does carry. The project scan overrides this when it
|
|
// finds transcripts.
|
|
function applyFallbackActiveDays(dailyActivity) {
|
|
const days = Array.isArray(dailyActivity) ? dailyActivity : [];
|
|
const dates = [];
|
|
for (var i = 0; i < days.length; i++) {
|
|
const day = days[i] || {};
|
|
if (Number(day.messageCount || 0) > 0 && day.date)
|
|
dates.push(String(day.date));
|
|
}
|
|
root.activeDates = dates;
|
|
root.activeDays = dates.length;
|
|
}
|
|
|
|
function applyProjectUsageSummary(content) {
|
|
try {
|
|
const data = JSON.parse(String(content || "{}"));
|
|
const prompts = Math.max(0, Number(data.totalPrompts || 0));
|
|
if (prompts <= 0)
|
|
return;
|
|
|
|
root.hasProjectStats = true;
|
|
root.todayPrompts = Math.max(0, Number(data.todayPrompts || 0));
|
|
root.todaySessions = Math.max(0, Number(data.todaySessions || 0));
|
|
root.todayTotalTokens = Math.max(0, Number(data.todayTotalTokens || 0));
|
|
root.todayTokensByModel = data.todayTokensByModel || ({});
|
|
root.recentDays = data.recentDays || [];
|
|
root.modelUsage = data.modelUsage || ({});
|
|
root.totalPrompts = prompts;
|
|
root.totalSessions = Math.max(0, Number(data.totalSessions || 0));
|
|
root.activeDays = Math.max(0, Number(data.activeDays || 0));
|
|
root.activeDates = data.activeDates || [];
|
|
root.dailyActivity = data.dailyActivity || root.recentDays;
|
|
root.ready = true;
|
|
} catch (e) {
|
|
console.error("model-usage/claude", "Failed to parse project usage summary:", e);
|
|
}
|
|
}
|
|
|
|
function parseHistory(content) {
|
|
if (root.hasProjectStats)
|
|
return;
|
|
try {
|
|
const now = new Date();
|
|
const startOfDay = new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime();
|
|
const lines = content.split("\n");
|
|
let prompts = 0;
|
|
const sessions = {};
|
|
|
|
for (let i = lines.length - 1; i >= 0; i--) {
|
|
const line = lines[i].trim();
|
|
if (!line)
|
|
continue;
|
|
try {
|
|
const entry = JSON.parse(line);
|
|
if ((entry.timestamp ?? 0) < startOfDay)
|
|
break;
|
|
prompts++;
|
|
if (entry.sessionId)
|
|
sessions[entry.sessionId] = true;
|
|
} catch (e) {
|
|
continue;
|
|
}
|
|
}
|
|
|
|
root.todayPrompts = prompts;
|
|
root.todaySessions = Object.keys(sessions).length;
|
|
} catch (e) {
|
|
console.error("model-usage/claude", "Failed to parse history.jsonl:", e);
|
|
}
|
|
}
|
|
|
|
function parseCredentials(content) {
|
|
try {
|
|
const data = JSON.parse(content);
|
|
const oauth = data.claudeAiOauth ?? {};
|
|
const fileAccessToken = oauth.accessToken ?? "";
|
|
const fileExpiresAtMs = root.normalizeExpiresAtMs(oauth.expiresAt);
|
|
const fileHasOAuth = fileAccessToken !== "";
|
|
|
|
const tokenChanged = (root.oauthAccessToken !== fileAccessToken || root.oauthExpiresAtMs !== fileExpiresAtMs);
|
|
root.oauthAccessToken = fileAccessToken;
|
|
root.oauthExpiresAtMs = fileExpiresAtMs;
|
|
if (tokenChanged)
|
|
root.clearAuthoritativeRateLimits();
|
|
|
|
root.authMode = fileHasOAuth ? "oauth" : "none";
|
|
|
|
root.subscriptionType = oauth.subscriptionType ?? "";
|
|
root.rateLimitTier = oauth.rateLimitTier ?? "";
|
|
root.tierLabel = formatTier();
|
|
|
|
if (root.oauthAccessToken && !root.oauthTokenExpired()) {
|
|
if (root.usageStatusText === "Waiting for auth")
|
|
root.clearUsageStatus();
|
|
// A fresh token just wiped the limits it replaced, so don't let
|
|
// the old token's throttle keep them blank for the next quarter
|
|
// of an hour.
|
|
root.probeRateLimits(tokenChanged);
|
|
} else if (!root.oauthAccessToken) {
|
|
root.usageStatusText = "Waiting for auth";
|
|
root.clearAuthoritativeRateLimits();
|
|
} else {
|
|
root.clearUsageStatus();
|
|
}
|
|
} catch (e) {
|
|
console.error("model-usage/claude", "Failed to parse credentials.json:", e);
|
|
root.usageStatusText = "Waiting for auth";
|
|
root.clearAuthoritativeRateLimits();
|
|
}
|
|
}
|
|
|
|
function formatTier() {
|
|
if (!root.rateLimitTier)
|
|
return root.subscriptionType || "";
|
|
const match = root.rateLimitTier.match(/max_(\d+x)/i);
|
|
if (match)
|
|
return "Max " + match[1];
|
|
if (root.subscriptionType)
|
|
return root.subscriptionType.charAt(0).toUpperCase() + root.subscriptionType.slice(1);
|
|
return "";
|
|
}
|
|
|
|
function normalizeExpiresAtMs(value) {
|
|
const n = Number(value ?? 0);
|
|
return (isFinite(n) && n > 0) ? n : 0;
|
|
}
|
|
|
|
function oauthTokenExpired() {
|
|
if (!root.oauthAccessToken)
|
|
return true;
|
|
if (!root.oauthExpiresAtMs || !(root.oauthExpiresAtMs > 0))
|
|
return false;
|
|
return root.oauthExpiresAtMs <= Date.now();
|
|
}
|
|
|
|
function clearAuthoritativeRateLimits() {
|
|
root.hasAuthoritativeRateLimit = false;
|
|
root.rateLimitPercent = -1;
|
|
root.rateLimitLabel = "Session (5-hour)";
|
|
root.rateLimitResetAt = "";
|
|
root.secondaryRateLimitPercent = -1;
|
|
root.secondaryRateLimitLabel = "Weekly (7-day)";
|
|
root.secondaryRateLimitResetAt = "";
|
|
}
|
|
|
|
function clearUsageStatus() {
|
|
root.usageStatusText = "";
|
|
}
|
|
|
|
function parseNumber(value) {
|
|
if (value === null || value === undefined)
|
|
return NaN;
|
|
return parseFloat(String(value).trim().replace("%", ""));
|
|
}
|
|
|
|
function utilizationPayloadUsesPercentScale(values) {
|
|
for (let i = 0; i < values.length; i++) {
|
|
const n = parseNumber(values[i]);
|
|
if (n >= 1)
|
|
return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
function normalizeUtilization(value, percentScale) {
|
|
const n = parseNumber(value);
|
|
if (!(n >= 0))
|
|
return -1;
|
|
|
|
// Anthropic's OAuth usage endpoint currently reports percentages
|
|
// (for example 37.0 or 1.0). Older clients/examples sometimes used
|
|
// fractions (0.37). Treat a payload containing any value >= 1 as
|
|
// percent-scaled so 1.0 renders as 1%, not 100%.
|
|
if (percentScale === true || n > 1)
|
|
return Math.min(1, n / 100);
|
|
return Math.min(1, n);
|
|
}
|
|
|
|
function normalizeResetAt(value) {
|
|
if (value === null || value === undefined)
|
|
return "";
|
|
const raw = String(value).trim();
|
|
if (raw === "")
|
|
return "";
|
|
if (/^\d+$/.test(raw)) {
|
|
let ts = parseInt(raw, 10);
|
|
if (ts < 1e12)
|
|
ts = ts * 1000;
|
|
const d = new Date(ts);
|
|
if (!isNaN(d.getTime()))
|
|
return d.toISOString();
|
|
}
|
|
const parsed = new Date(raw);
|
|
if (!isNaN(parsed.getTime()))
|
|
return parsed.toISOString();
|
|
return raw;
|
|
}
|
|
|
|
function oauthUsageBucket(payload, key) {
|
|
const bucket = payload?.[key];
|
|
if (bucket && typeof bucket === "object")
|
|
return bucket;
|
|
return null;
|
|
}
|
|
|
|
function applyAuthoritativeRateLimits(weekly, weeklyReset, session, sessionReset, sourceLabel) {
|
|
const percentScale = root.utilizationPayloadUsesPercentScale([weekly, session]);
|
|
const weeklyNorm = root.normalizeUtilization(weekly, percentScale);
|
|
const sessionNorm = root.normalizeUtilization(session, percentScale);
|
|
if (weeklyNorm < 0 && sessionNorm < 0)
|
|
return false;
|
|
|
|
root.hasAuthoritativeRateLimit = true;
|
|
root.rateLimitPercent = -1;
|
|
root.rateLimitLabel = "Session (5-hour)";
|
|
root.rateLimitResetAt = "";
|
|
root.secondaryRateLimitPercent = -1;
|
|
root.secondaryRateLimitLabel = "Weekly (7-day)";
|
|
root.secondaryRateLimitResetAt = "";
|
|
|
|
if (sessionNorm >= 0)
|
|
root.rateLimitPercent = sessionNorm;
|
|
if (weeklyNorm >= 0)
|
|
root.secondaryRateLimitPercent = weeklyNorm;
|
|
if (sessionReset !== null && sessionReset !== undefined)
|
|
root.rateLimitResetAt = root.normalizeResetAt(sessionReset);
|
|
if (weeklyReset !== null && weeklyReset !== undefined)
|
|
root.secondaryRateLimitResetAt = root.normalizeResetAt(weeklyReset);
|
|
|
|
if (sourceLabel) {
|
|
if (root.secondaryRateLimitPercent >= 0)
|
|
root.secondaryRateLimitLabel = root.secondaryRateLimitLabel + " (" + sourceLabel + ")";
|
|
else if (root.rateLimitPercent >= 0)
|
|
root.rateLimitLabel = root.rateLimitLabel + " (" + sourceLabel + ")";
|
|
}
|
|
return true;
|
|
}
|
|
|
|
function finishRefresh() {
|
|
root.refreshing = false;
|
|
root.lastRefreshedAtMs = Date.now();
|
|
}
|
|
|
|
function probeOAuthUsage() {
|
|
// One probe at a time. The retry timer and a forced panel-open refresh
|
|
// can otherwise both be in flight, and the slower answer wins.
|
|
if (root.probeInFlight)
|
|
return;
|
|
root.probeInFlight = true;
|
|
root.refreshing = true;
|
|
root.lastProbeAtMs = Date.now();
|
|
const xhr = new XMLHttpRequest();
|
|
xhr.open("GET", "https://api.anthropic.com/api/oauth/usage");
|
|
xhr.setRequestHeader("Authorization", "Bearer " + root.oauthAccessToken);
|
|
xhr.setRequestHeader("anthropic-beta", "oauth-2025-04-20");
|
|
xhr.setRequestHeader("Accept", "application/json");
|
|
xhr.onreadystatechange = function () {
|
|
if (xhr.readyState !== XMLHttpRequest.DONE)
|
|
return;
|
|
root.probeInFlight = false;
|
|
|
|
if (xhr.status >= 200 && xhr.status < 300) {
|
|
try {
|
|
const payload = JSON.parse(xhr.responseText ?? "{}");
|
|
const weeklyBucket = root.oauthUsageBucket(payload, "seven_day_oauth_apps") || root.oauthUsageBucket(payload, "seven_day");
|
|
const sessionBucket = root.oauthUsageBucket(payload, "five_hour");
|
|
|
|
if (root.applyAuthoritativeRateLimits(weeklyBucket?.utilization, weeklyBucket?.resets_at, sessionBucket?.utilization, sessionBucket?.resets_at, "")) {
|
|
probeRetry.stop();
|
|
root.clearUsageStatus();
|
|
root.finishRefresh();
|
|
return;
|
|
}
|
|
} catch (e) {
|
|
console.error("model-usage/claude", "Failed to parse oauth usage response:", e);
|
|
}
|
|
}
|
|
|
|
const body = xhr.responseText ? String(xhr.responseText).slice(0, 220) : "";
|
|
const retryAfter = xhr.getResponseHeader("retry-after") || "";
|
|
console.warn("model-usage/claude", "OAuth usage probe unavailable (status " + xhr.status + ")" + (body ? " body=" + body : ""));
|
|
if (!root.hasAuthoritativeRateLimit) {
|
|
root.usageStatusText = "Claude limits unavailable";
|
|
root.authHelpText = xhr.status === 0
|
|
? "Couldn't reach Anthropic's usage endpoint. Retrying shortly. Local Claude Code stats are still shown."
|
|
: xhr.status === 429
|
|
? "Anthropic's usage endpoint is rate limiting checks right now" + (retryAfter ? " (retry after " + retryAfter + "s)" : "") + ". Local Claude Code stats are still shown."
|
|
: "Anthropic's usage endpoint returned status " + xhr.status + ". Local Claude Code stats are still shown.";
|
|
}
|
|
// Status 0 is a transport failure — no route, no DNS, no server
|
|
// reached. Nothing to be a good citizen about, so keep knocking.
|
|
// Any real answer, including 429, disarms the retry: a server that
|
|
// replied is a server we should stop pestering.
|
|
if (xhr.status === 0)
|
|
probeRetry.restart();
|
|
else
|
|
probeRetry.stop();
|
|
root.finishRefresh();
|
|
};
|
|
xhr.send();
|
|
}
|
|
|
|
function startProjectScanner(force) {
|
|
if (projectScanner.running) {
|
|
if (force === true)
|
|
root.projectScanRerunForce = true;
|
|
return false;
|
|
}
|
|
|
|
const command = ["python3", root.projectScannerScriptPath, root.resolvePath(root.providerSettings?.projectsPath ?? "~/.claude/projects")];
|
|
if (force === true)
|
|
command.push("--force");
|
|
projectScanner.command = command;
|
|
projectScanner.running = true;
|
|
return true;
|
|
}
|
|
|
|
function refresh(force) {
|
|
root.refreshing = true;
|
|
statsFile.reload();
|
|
historyFile.reload();
|
|
credentialsFile.reload();
|
|
root.startProjectScanner(force === true);
|
|
|
|
if (root.oauthAccessToken && root.authMode === "oauth" && !root.oauthTokenExpired())
|
|
root.probeRateLimits(force === true);
|
|
}
|
|
|
|
// The cheap half of a refresh: Anthropic's numbers without the disk walk.
|
|
function refreshLimits() {
|
|
if (root.oauthAccessToken && root.authMode === "oauth" && !root.oauthTokenExpired())
|
|
root.probeRateLimits(true);
|
|
}
|
|
|
|
function formatResetTime(isoTimestamp) {
|
|
if (!isoTimestamp)
|
|
return "";
|
|
const reset = new Date(isoTimestamp);
|
|
const now = new Date();
|
|
const diffMs = reset.getTime() - now.getTime();
|
|
if (diffMs <= 0)
|
|
return "now";
|
|
const hours = Math.floor(diffMs / 3600000);
|
|
const mins = Math.floor((diffMs % 3600000) / 60000);
|
|
if (hours > 24)
|
|
return Math.floor(hours / 24) + "d " + (hours % 24) + "h";
|
|
if (hours > 0)
|
|
return hours + "h " + mins + "m";
|
|
return mins + "m";
|
|
}
|
|
|
|
function probeRateLimits(force) {
|
|
if (!root.oauthAccessToken || root.authMode !== "oauth") {
|
|
root.usageStatusText = "Waiting for auth";
|
|
root.clearAuthoritativeRateLimits();
|
|
root.finishRefresh();
|
|
return;
|
|
}
|
|
|
|
if (root.oauthTokenExpired()) {
|
|
root.clearUsageStatus();
|
|
root.finishRefresh();
|
|
return;
|
|
}
|
|
|
|
const minIntervalMs = force === true ? root.probeForcedMinIntervalMs : root.probeMinIntervalMs;
|
|
if (root.lastProbeAtMs > 0 && (Date.now() - root.lastProbeAtMs) < minIntervalMs) {
|
|
root.finishRefresh();
|
|
return;
|
|
}
|
|
|
|
root.probeOAuthUsage();
|
|
}
|
|
}
|