feat(ai): added Synthetic usage provider
/usage and omp usage now report Synthetic (synthetic.new) quota state via GET /v2/quotas (free, does not consume quota): the rolling 5-hour request limit with per-tick regeneration rate, and the weekly credit quota in USD. Applies to API-key credentials for the synthetic provider; every payload section is parsed defensively since only subscription is documented.
This commit is contained in:
@@ -2,6 +2,11 @@
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
|
||||
- Added Synthetic (synthetic.new) usage provider: `/usage` now reports the rolling 5-hour request limit and weekly credit quota via `GET /v2/quotas`, including per-tick regeneration rates in the window labels.
|
||||
- Added optional `UsageWindow.resetLabel` so rolling windows can render their countdown with an accurate verb (e.g. "tick in 12m" / "regen in 51m" instead of "resets in") — both quota windows on Synthetic regenerate incrementally rather than hard-resetting.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed GitHub Copilot OpenAI-compatible requests being rejected when the session's native OpenAI service tier was set to `priority` ([#5160](https://github.com/can1357/oh-my-pi/pull/5160) by [@audreyt](https://github.com/audreyt)).
|
||||
|
||||
@@ -57,6 +57,7 @@ import {
|
||||
listCodexResetCredits,
|
||||
} from "./usage/openai-codex-reset";
|
||||
import { opencodeGoUsageProvider } from "./usage/opencode-go";
|
||||
import { syntheticUsageProvider } from "./usage/synthetic";
|
||||
import { zaiRankingStrategy, zaiUsageProvider } from "./usage/zai";
|
||||
|
||||
const USAGE_RANKING_METRIC_EPSILON = 1e-9;
|
||||
@@ -596,6 +597,7 @@ const DEFAULT_USAGE_PROVIDERS: UsageProvider[] = [
|
||||
opencodeGoUsageProvider,
|
||||
githubCopilotUsageProvider,
|
||||
cursorUsageProvider,
|
||||
syntheticUsageProvider,
|
||||
];
|
||||
|
||||
const DEFAULT_USAGE_PROVIDER_MAP = new Map<Provider, UsageProvider>(
|
||||
|
||||
@@ -39,6 +39,7 @@ export * from "./usage/ollama";
|
||||
export * from "./usage/openai-codex";
|
||||
export * from "./usage/openai-codex-reset";
|
||||
export * from "./usage/opencode-go";
|
||||
export * from "./usage/synthetic";
|
||||
export * from "./usage/zai";
|
||||
export * from "./utils/anthropic-auth";
|
||||
export * from "./utils/event-stream";
|
||||
|
||||
@@ -20,6 +20,12 @@ export interface UsageWindow {
|
||||
durationMs?: number;
|
||||
/** Absolute reset timestamp in milliseconds since epoch. */
|
||||
resetsAt?: number;
|
||||
/**
|
||||
* Verb rendered before the {@link resetsAt} countdown (e.g. "tick", "regen").
|
||||
* Defaults to "resets" — override for rolling windows where the timestamp is
|
||||
* an incremental regeneration step rather than a full window reset.
|
||||
*/
|
||||
resetLabel?: string;
|
||||
}
|
||||
|
||||
/** Quantitative usage data. */
|
||||
@@ -189,6 +195,7 @@ export const usageWindowSchema = type({
|
||||
label: "string",
|
||||
"durationMs?": "number",
|
||||
"resetsAt?": "number",
|
||||
"resetLabel?": "string",
|
||||
});
|
||||
|
||||
export const usageAmountSchema = type({
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
import type {
|
||||
UsageAmount,
|
||||
UsageFetchContext,
|
||||
UsageFetchParams,
|
||||
UsageLimit,
|
||||
UsageProvider,
|
||||
UsageReport,
|
||||
UsageStatus,
|
||||
UsageWindow,
|
||||
} from "../usage";
|
||||
import { isRecord } from "../utils";
|
||||
|
||||
const QUOTAS_URL = "https://api.synthetic.new/v2/quotas";
|
||||
const FIVE_HOUR_MS = 5 * 60 * 60 * 1000;
|
||||
const WEEK_MS = 7 * 24 * 60 * 60 * 1000;
|
||||
|
||||
function parseDollarAmount(value: unknown): number | undefined {
|
||||
if (typeof value !== "string") return undefined;
|
||||
const trimmed = value.replace(/^\$/, "").trim();
|
||||
const parsed = Number(trimmed);
|
||||
return Number.isFinite(parsed) ? parsed : undefined;
|
||||
}
|
||||
|
||||
function parseIsoMs(value: unknown): number | undefined {
|
||||
if (typeof value !== "string" || !value) return undefined;
|
||||
const ms = Date.parse(value);
|
||||
return Number.isFinite(ms) ? ms : undefined;
|
||||
}
|
||||
|
||||
function buildUsageAmount(args: {
|
||||
used: number | undefined;
|
||||
limit: number | undefined;
|
||||
remaining: number | undefined;
|
||||
usedFraction: number | undefined;
|
||||
unit: UsageAmount["unit"];
|
||||
}): UsageAmount {
|
||||
let usedFraction = args.usedFraction;
|
||||
if (usedFraction === undefined && args.used !== undefined && args.limit !== undefined && args.limit > 0) {
|
||||
usedFraction = Math.min(args.used / args.limit, 1);
|
||||
}
|
||||
const remainingFraction = usedFraction !== undefined ? Math.max(1 - usedFraction, 0) : undefined;
|
||||
return {
|
||||
...(args.used !== undefined ? { used: args.used } : {}),
|
||||
...(args.limit !== undefined ? { limit: args.limit } : {}),
|
||||
...(args.remaining !== undefined ? { remaining: args.remaining } : {}),
|
||||
...(usedFraction !== undefined ? { usedFraction } : {}),
|
||||
...(remainingFraction !== undefined ? { remainingFraction } : {}),
|
||||
unit: args.unit,
|
||||
};
|
||||
}
|
||||
|
||||
function getUsageStatus(usedFraction: number | undefined): UsageStatus | undefined {
|
||||
if (usedFraction === undefined) return undefined;
|
||||
if (usedFraction >= 1) return "exhausted";
|
||||
if (usedFraction >= 0.9) return "warning";
|
||||
return "ok";
|
||||
}
|
||||
|
||||
function parseRollingFiveHourLimit(raw: unknown, provider: UsageFetchParams["provider"]): UsageLimit | null {
|
||||
if (!isRecord(raw)) return null;
|
||||
const remaining = typeof raw.remaining === "number" ? raw.remaining : undefined;
|
||||
const max = typeof raw.max === "number" ? raw.max : undefined;
|
||||
const limited = raw.limited === true;
|
||||
const nextTickAt = parseIsoMs(raw.nextTickAt);
|
||||
const tickPercent = typeof raw.tickPercent === "number" ? raw.tickPercent : undefined;
|
||||
|
||||
if (remaining === undefined && max === undefined) return null;
|
||||
|
||||
const used = max !== undefined && remaining !== undefined ? max - remaining : undefined;
|
||||
const regenPercent = tickPercent !== undefined ? Number((tickPercent * 100).toFixed(2)) : undefined;
|
||||
const window: UsageWindow = {
|
||||
id: "5h",
|
||||
label: regenPercent !== undefined ? `5h Rolling · regen ${regenPercent}%/tick` : "5 Hour Rolling",
|
||||
durationMs: FIVE_HOUR_MS,
|
||||
...(nextTickAt !== undefined ? { resetsAt: nextTickAt, resetLabel: "tick" } : {}),
|
||||
};
|
||||
const amount = buildUsageAmount({
|
||||
used,
|
||||
limit: max,
|
||||
remaining,
|
||||
usedFraction: undefined,
|
||||
unit: "requests",
|
||||
});
|
||||
const status: UsageStatus = limited ? "exhausted" : (getUsageStatus(amount.usedFraction) ?? "ok");
|
||||
return {
|
||||
id: "synthetic:requests:5h",
|
||||
label: "Synthetic 5h Rolling Limit",
|
||||
scope: { provider, windowId: "5h", shared: true },
|
||||
window,
|
||||
amount,
|
||||
status,
|
||||
};
|
||||
}
|
||||
|
||||
function parseWeeklyTokenLimit(raw: unknown, provider: UsageFetchParams["provider"]): UsageLimit | null {
|
||||
if (!isRecord(raw)) return null;
|
||||
const remainingCredits = parseDollarAmount(raw.remainingCredits);
|
||||
const maxCredits = parseDollarAmount(raw.maxCredits);
|
||||
const percentRemaining = typeof raw.percentRemaining === "number" ? raw.percentRemaining : undefined;
|
||||
const nextRegenAt = parseIsoMs(raw.nextRegenAt);
|
||||
|
||||
if (remainingCredits === undefined && maxCredits === undefined) return null;
|
||||
|
||||
const usedFraction =
|
||||
percentRemaining !== undefined ? Math.min(Math.max(1 - percentRemaining / 100, 0), 1) : undefined;
|
||||
const used = usedFraction !== undefined && maxCredits !== undefined ? usedFraction * maxCredits : undefined;
|
||||
const nextRegenCredits = parseDollarAmount(raw.nextRegenCredits);
|
||||
const window: UsageWindow = {
|
||||
id: "7d",
|
||||
label: nextRegenCredits !== undefined ? `7d Rolling · regen $${nextRegenCredits.toFixed(2)}/tick` : "Weekly",
|
||||
durationMs: WEEK_MS,
|
||||
...(nextRegenAt !== undefined ? { resetsAt: nextRegenAt, resetLabel: "regen" } : {}),
|
||||
};
|
||||
const amount = buildUsageAmount({
|
||||
used,
|
||||
limit: maxCredits,
|
||||
remaining: remainingCredits,
|
||||
usedFraction,
|
||||
unit: "usd",
|
||||
});
|
||||
return {
|
||||
id: "synthetic:usd:7d",
|
||||
label: "Synthetic Weekly Credit Limit",
|
||||
scope: { provider, windowId: "7d", shared: true },
|
||||
window,
|
||||
amount,
|
||||
status: getUsageStatus(amount.usedFraction),
|
||||
};
|
||||
}
|
||||
|
||||
async function fetchSyntheticUsage(params: UsageFetchParams, ctx: UsageFetchContext): Promise<UsageReport | null> {
|
||||
if (params.provider !== "synthetic") return null;
|
||||
const credential = params.credential;
|
||||
if (credential.type !== "api_key" || !credential.apiKey) return null;
|
||||
|
||||
let payload: unknown = null;
|
||||
try {
|
||||
const response = await ctx.fetch(QUOTAS_URL, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${credential.apiKey}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
signal: params.signal,
|
||||
});
|
||||
if (!response.ok) {
|
||||
ctx.logger?.warn("Synthetic usage fetch failed", { status: response.status, statusText: response.statusText });
|
||||
return null;
|
||||
}
|
||||
payload = await response.json();
|
||||
} catch (error) {
|
||||
ctx.logger?.warn("Synthetic usage fetch error", { error: String(error) });
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!isRecord(payload)) return null;
|
||||
|
||||
const limits: UsageLimit[] = [];
|
||||
|
||||
const fiveHour = parseRollingFiveHourLimit(payload.rollingFiveHourLimit, params.provider);
|
||||
if (fiveHour) limits.push(fiveHour);
|
||||
|
||||
const weekly = parseWeeklyTokenLimit(payload.weeklyTokenLimit, params.provider);
|
||||
if (weekly) limits.push(weekly);
|
||||
|
||||
if (limits.length === 0) return null;
|
||||
|
||||
return {
|
||||
provider: params.provider,
|
||||
fetchedAt: Date.now(),
|
||||
limits,
|
||||
metadata: { endpoint: QUOTAS_URL },
|
||||
raw: payload,
|
||||
};
|
||||
}
|
||||
|
||||
export const syntheticUsageProvider: UsageProvider = {
|
||||
id: "synthetic",
|
||||
fetchUsage: fetchSyntheticUsage,
|
||||
supports: params => params.provider === "synthetic" && params.credential.type === "api_key",
|
||||
};
|
||||
@@ -0,0 +1,203 @@
|
||||
import { describe, expect, it, mock } from "bun:test";
|
||||
|
||||
// pi-natives requires a built Rust nightly .node addon — mock it so the
|
||||
// @oh-my-pi/pi-utils barrel (which re-exports procmgr/ptree) doesn't crash.
|
||||
mock.module("@oh-my-pi/pi-natives", () => ({
|
||||
Process: class {},
|
||||
ProcessStatus: { Running: "running", Exited: "exited" },
|
||||
Shell: class {},
|
||||
PtySession: class {},
|
||||
}));
|
||||
|
||||
import type { FetchImpl } from "@oh-my-pi/pi-ai/types";
|
||||
import type { UsageFetchContext, UsageFetchParams } from "@oh-my-pi/pi-ai/usage";
|
||||
import { syntheticUsageProvider } from "@oh-my-pi/pi-ai/usage/synthetic";
|
||||
|
||||
const FULL_FIXTURE = {
|
||||
subscription: { limit: 500, requests: 12, renewsAt: "2026-07-10T11:33:46.399Z" },
|
||||
search: { hourly: { limit: 250, requests: 0, renewsAt: "2026-07-10T07:33:46.399Z" } },
|
||||
freeToolCalls: { limit: 0, requests: 0, renewsAt: "2026-07-11T06:33:46.405Z" },
|
||||
weeklyTokenLimit: {
|
||||
nextRegenAt: "2026-07-10T08:17:04.000Z",
|
||||
percentRemaining: 7.615,
|
||||
maxCredits: "$24.00",
|
||||
remainingCredits: "$1.82",
|
||||
nextRegenCredits: "$0.48",
|
||||
},
|
||||
rollingFiveHourLimit: {
|
||||
nextTickAt: "2026-07-10T06:46:05.000Z",
|
||||
tickPercent: 0.05,
|
||||
remaining: 500,
|
||||
max: 500,
|
||||
limited: false,
|
||||
},
|
||||
};
|
||||
|
||||
function makeCredential(): UsageFetchParams["credential"] {
|
||||
return {
|
||||
type: "api_key",
|
||||
apiKey: "synthetic-test-key",
|
||||
};
|
||||
}
|
||||
|
||||
function makeCtx(payload: unknown, status = 200): UsageFetchContext {
|
||||
const fetch: FetchImpl = async () => {
|
||||
return new Response(JSON.stringify(payload), {
|
||||
status,
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
};
|
||||
return { fetch };
|
||||
}
|
||||
|
||||
function makeCtxThrow(): UsageFetchContext {
|
||||
const fetch: FetchImpl = async () => {
|
||||
throw new Error("Network error");
|
||||
};
|
||||
return { fetch };
|
||||
}
|
||||
|
||||
describe("synthetic usage provider", () => {
|
||||
it("happy path: full fixture returns exactly 2 limits — 5h rolling and weekly credits", async () => {
|
||||
const report = await syntheticUsageProvider.fetchUsage!(
|
||||
{ provider: "synthetic", credential: makeCredential(), signal: undefined },
|
||||
makeCtx(FULL_FIXTURE),
|
||||
);
|
||||
|
||||
expect(report).not.toBeNull();
|
||||
expect(report!.limits).toHaveLength(2);
|
||||
expect(report!.limits.map(l => l.id)).toEqual(["synthetic:requests:5h", "synthetic:usd:7d"]);
|
||||
expect(report!.limits.map(l => l.label)).toEqual(["Synthetic 5h Rolling Limit", "Synthetic Weekly Credit Limit"]);
|
||||
expect(report!.limits.map(l => l.scope.windowId)).toEqual(["5h", "7d"]);
|
||||
expect(report!.limits.map(l => l.window?.durationMs)).toEqual([5 * 60 * 60 * 1000, 7 * 24 * 60 * 60 * 1000]);
|
||||
});
|
||||
|
||||
it("5h rolling limit: used/remaining math (500 max, 500 remaining → 0 used, status ok)", async () => {
|
||||
const report = await syntheticUsageProvider.fetchUsage!(
|
||||
{ provider: "synthetic", credential: makeCredential(), signal: undefined },
|
||||
makeCtx(FULL_FIXTURE),
|
||||
);
|
||||
|
||||
const fiveHour = report!.limits.find(l => l.id === "synthetic:requests:5h")!;
|
||||
expect(fiveHour.amount.remaining).toBe(500);
|
||||
expect(fiveHour.amount.limit).toBe(500);
|
||||
expect(fiveHour.amount.used).toBe(0);
|
||||
expect(fiveHour.amount.usedFraction).toBe(0);
|
||||
expect(fiveHour.status).toBe("ok");
|
||||
});
|
||||
|
||||
it("5h rolling limit: regen rate folded into window label, tick time drives resetsAt", async () => {
|
||||
const report = await syntheticUsageProvider.fetchUsage!(
|
||||
{ provider: "synthetic", credential: makeCredential(), signal: undefined },
|
||||
makeCtx(FULL_FIXTURE),
|
||||
);
|
||||
|
||||
const fiveHour = report!.limits.find(l => l.id === "synthetic:requests:5h")!;
|
||||
// Single-line rendering: no notes; tickPercent 0.05 → "5%" in the window label.
|
||||
expect(fiveHour.notes).toBeUndefined();
|
||||
expect(fiveHour.window?.label).toContain("regen 5%/tick");
|
||||
// nextTickAt drives the countdown, rendered as "tick in …" (not a window reset).
|
||||
expect(fiveHour.window?.resetsAt).toBe(Date.parse("2026-07-10T06:46:05.000Z"));
|
||||
expect(fiveHour.window?.resetLabel).toBe("tick");
|
||||
});
|
||||
|
||||
it("weekly USD limit: parses dollar amounts, uses percentRemaining for usedFraction", async () => {
|
||||
const report = await syntheticUsageProvider.fetchUsage!(
|
||||
{ provider: "synthetic", credential: makeCredential(), signal: undefined },
|
||||
makeCtx(FULL_FIXTURE),
|
||||
);
|
||||
|
||||
const weekly = report!.limits.find(l => l.id === "synthetic:usd:7d")!;
|
||||
expect(weekly.amount.limit).toBeCloseTo(24.0);
|
||||
expect(weekly.amount.remaining).toBeCloseTo(1.82);
|
||||
// usedFraction = 1 - 7.615/100 ≈ 0.92385
|
||||
expect(weekly.amount.usedFraction).toBeCloseTo(1 - 7.615 / 100, 5);
|
||||
expect(weekly.amount.unit).toBe("usd");
|
||||
// usedFraction ~0.924 ≥ 0.9 → "warning"
|
||||
expect(weekly.status).toBe("warning");
|
||||
// Weekly credits regenerate incrementally too: "regen in …" + $/tick in the label.
|
||||
expect(weekly.window?.resetLabel).toBe("regen");
|
||||
expect(weekly.window?.label).toContain("regen $0.48/tick");
|
||||
expect(weekly.window?.resetsAt).toBe(Date.parse("2026-07-10T08:17:04.000Z"));
|
||||
});
|
||||
|
||||
it("docs-minimal response (subscription-only) → null (no surfaceable limits)", async () => {
|
||||
const minimal = {
|
||||
subscription: { limit: 100, requests: 5, renewsAt: "2026-08-01T00:00:00.000Z" },
|
||||
};
|
||||
const report = await syntheticUsageProvider.fetchUsage!(
|
||||
{ provider: "synthetic", credential: makeCredential(), signal: undefined },
|
||||
makeCtx(minimal),
|
||||
);
|
||||
// subscription is dropped; no 5h or weekly data → nothing to show
|
||||
expect(report).toBeNull();
|
||||
});
|
||||
|
||||
it("limited: true on rollingFiveHourLimit → status is exhausted", async () => {
|
||||
const payload = {
|
||||
...FULL_FIXTURE,
|
||||
rollingFiveHourLimit: { ...FULL_FIXTURE.rollingFiveHourLimit, remaining: 0, limited: true },
|
||||
};
|
||||
const report = await syntheticUsageProvider.fetchUsage!(
|
||||
{ provider: "synthetic", credential: makeCredential(), signal: undefined },
|
||||
makeCtx(payload),
|
||||
);
|
||||
|
||||
const fiveHour = report!.limits.find(l => l.id === "synthetic:requests:5h")!;
|
||||
expect(fiveHour.status).toBe("exhausted");
|
||||
});
|
||||
|
||||
it("non-synthetic provider → returns null", async () => {
|
||||
const report = await syntheticUsageProvider.fetchUsage!(
|
||||
{ provider: "openai", credential: makeCredential(), signal: undefined },
|
||||
makeCtx(FULL_FIXTURE),
|
||||
);
|
||||
expect(report).toBeNull();
|
||||
});
|
||||
|
||||
it("non-api_key credential → returns null", async () => {
|
||||
const report = await syntheticUsageProvider.fetchUsage!(
|
||||
{ provider: "synthetic", credential: { type: "oauth" } as UsageFetchParams["credential"], signal: undefined },
|
||||
makeCtx(FULL_FIXTURE),
|
||||
);
|
||||
expect(report).toBeNull();
|
||||
});
|
||||
|
||||
it("HTTP error response → returns null", async () => {
|
||||
const report = await syntheticUsageProvider.fetchUsage!(
|
||||
{ provider: "synthetic", credential: makeCredential(), signal: undefined },
|
||||
makeCtx({ message: "Unauthorized" }, 401),
|
||||
);
|
||||
expect(report).toBeNull();
|
||||
});
|
||||
|
||||
it("network error / thrown fetch → returns null", async () => {
|
||||
const report = await syntheticUsageProvider.fetchUsage!(
|
||||
{ provider: "synthetic", credential: makeCredential(), signal: undefined },
|
||||
makeCtxThrow(),
|
||||
);
|
||||
expect(report).toBeNull();
|
||||
});
|
||||
|
||||
it("malformed JSON payload → returns null", async () => {
|
||||
const fetch: FetchImpl = async () => {
|
||||
return new Response("not-json{{{{", {
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
};
|
||||
const report = await syntheticUsageProvider.fetchUsage!(
|
||||
{ provider: "synthetic", credential: makeCredential(), signal: undefined },
|
||||
{ fetch },
|
||||
);
|
||||
expect(report).toBeNull();
|
||||
});
|
||||
|
||||
it("empty object payload → returns null", async () => {
|
||||
const report = await syntheticUsageProvider.fetchUsage!(
|
||||
{ provider: "synthetic", credential: makeCredential(), signal: undefined },
|
||||
makeCtx({}),
|
||||
);
|
||||
expect(report).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -409,7 +409,7 @@ function formatLimitLine(limit: UsageLimit, labelWidth: number, nowMs: number):
|
||||
const details: string[] = [describeAmount(limit)];
|
||||
const resetsAt = limit.window?.resetsAt;
|
||||
if (resetsAt !== undefined && resetsAt > nowMs) {
|
||||
details.push(`resets in ${formatDuration(resetsAt - nowMs)}`);
|
||||
details.push(`${limit.window?.resetLabel ?? "resets"} in ${formatDuration(resetsAt - nowMs)}`);
|
||||
}
|
||||
const lines = [
|
||||
` ${STATUS_COLOR[status]("●")} ${padded} ${renderBar(limit)} ${chalk.dim(details.join(" · "))}`,
|
||||
|
||||
@@ -1636,17 +1636,24 @@ function formatAggregateAmount(limits: UsageLimit[]): string {
|
||||
}
|
||||
|
||||
function resolveResetRange(limits: UsageLimit[], nowMs: number): string | null {
|
||||
const absolute = limits
|
||||
.map(limit => limit.window?.resetsAt)
|
||||
.filter((value): value is number => value !== undefined && Number.isFinite(value) && value > nowMs);
|
||||
if (absolute.length === 0) return null;
|
||||
const offsets = absolute.map(value => value - nowMs);
|
||||
const windows = limits
|
||||
.map(limit => limit.window)
|
||||
.filter(
|
||||
(window): window is NonNullable<UsageLimit["window"]> =>
|
||||
window?.resetsAt !== undefined && Number.isFinite(window.resetsAt) && window.resetsAt > nowMs,
|
||||
);
|
||||
if (windows.length === 0) return null;
|
||||
// Use the shared verb when every contributing window agrees (e.g. all "tick");
|
||||
// mixed or absent labels fall back to the generic "resets".
|
||||
const labels = new Set(windows.map(window => window.resetLabel ?? "resets"));
|
||||
const verb = labels.size === 1 ? [...labels][0]! : "resets";
|
||||
const offsets = windows.map(window => window.resetsAt! - nowMs);
|
||||
const minReset = Math.min(...offsets);
|
||||
const maxReset = Math.max(...offsets);
|
||||
if (maxReset - minReset > 60_000) {
|
||||
return `resets in ${formatDuration(minReset)}–${formatDuration(maxReset)}`;
|
||||
return `${verb} in ${formatDuration(minReset)}–${formatDuration(maxReset)}`;
|
||||
}
|
||||
return `resets in ${formatDuration(minReset)}`;
|
||||
return `${verb} in ${formatDuration(minReset)}`;
|
||||
}
|
||||
/**
|
||||
* Compact one-line quota summary for a single advisor's provider.
|
||||
|
||||
@@ -124,7 +124,9 @@ function renderUsageReports(
|
||||
);
|
||||
lines.push(` ${renderAsciiBar(limit.amount.usedFraction)}`);
|
||||
if (limit.window?.resetsAt && limit.window.resetsAt > nowMs) {
|
||||
lines.push(` resets in ${formatDuration(limit.window.resetsAt - nowMs)}`);
|
||||
lines.push(
|
||||
` ${limit.window.resetLabel ?? "resets"} in ${formatDuration(limit.window.resetsAt - nowMs)}`,
|
||||
);
|
||||
}
|
||||
if (limit.notes && limit.notes.length > 0)
|
||||
lines.push(
|
||||
|
||||
Reference in New Issue
Block a user