mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-08 22:13:59 +01:00
feat: show the assignee's provider usage limit in task properties (#19270)
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - Agents run on the native Runner through the Codex app-server and the Claude ACP harness > - Both harnesses report the provider account's rate-limit windows: Codex sends `account/rateLimits/updated`, and Claude sends its SDK `rate_limit_event` as `usage_update._meta["_claude/rateLimit"]` > - Paperclip dropped both signals, so an operator could not see that an agent was near or past its provider limit > - This pull request normalizes both signals into one account-scoped snapshot, keeps the latest snapshot per agent, and shows it as one "Usage limit" line in the task properties pane > - The benefit is that an operator sees "5h 78% · resets 15:40" (amber above 80%, "Limited until …" when the provider refuses work, "using credits" when a used-up window falls back to credits) next to the assignee, and can click it for every window ## Linked Issues or Issue Description Refs #19246 Refs #19259 **Is your feature request related to a problem?** Codex and Claude report provider rate-limit windows during a run. Paperclip dropped them: the Codex notification fell through the Rust normalizer with no branch, and ACPX dropped Claude's `_meta`. Operators could not see how close an agent was to its provider limit. **Describe the solution you'd like** Store the latest reported windows per agent and show one compact line in the task properties pane, with a popover that lists every window. **Describe alternatives you've considered** A full gauge in the agent list and the run header. A design review chose one property line with a details popover instead. ## What Changed - Runner (Rust, the runnerd path): new `provider_rate_limits.rs`. Codex `account/rateLimits/updated` becomes a `provider.notice.recorded` event (category `provider.rate_limits`, scope `account`). `CodexProviderState` merges sparse updates and emits a notice only when the windows change. ACPX `usage_update` with a sanitized `rateLimit` field also emits the notice. - Runner (sidecar): `acpx-runtime-sidecar.ts` forwards only `status`, `rateLimitType`, `utilization` and `resetsAt` from `_meta["_claude/rateLimit"]`. This needs the ACPX `_meta` passthrough from #19259. Until that merges, Claude emits nothing. - Runner (TypeScript in-process drivers): the same mapping in `codex-session-notifications.ts` and `codex-acpx-driver.ts` through `drivers/provider-rate-limits.ts`. - Shared: `provider-rate-limits.ts` holds the snapshot type, the parser, the merge, and `summarizeProviderRateLimits` (fullest current window, expiry by reset time, limited versus using credits). - Server: `agent-provider-rate-limits.ts` merges each notice into `agent_runtime_state.state_json.providerRateLimits`, scoped by company and agent. A session reset keeps the snapshot. No migration. - UI: `AgentRateLimitPropertyValue` adds a "Usage limit" row under the assignee in `IssueProperties`. Transcripts treat the notice as run-log only. A run status event refreshes the assignee's runtime state. - Storybook: `Tasks and conversations/Task creation and lifecycle/Usage limit property`. - Docs: `doc/run-log-events.md` describes the notice and its storage. ## Verification - `cd packages/paperclip-runner/runner && cargo test -p paperclip-runner-core --lib rate_limit` and `--test acpx_provider_events`: sparse Codex merge, dedupe, reached window, checkpoint round trip, Claude utilization and rejection, notice shape. - `cd packages/paperclip-runner && npx vitest run src/drivers/provider-rate-limits.test.ts src/drivers/codex` - `cd packages/shared && npx vitest run src/provider-rate-limits.test.ts`: summary, expiry, limited, using credits, parse and merge. - `cd server && npx vitest run src/__tests__/agent-provider-rate-limits.test.ts`: create and merge, out-of-order snapshot, company isolation, unrelated notices, session reset keeps the snapshot. - `cd ui && npx vitest run src/components/transcript src/components/issue-properties src/context/LiveUpdatesProvider` - `pnpm -r typecheck` and `pnpm check:token-gates` pass. - Live test on a local worktree instance: a native Codex agent on a ChatGPT subscription login ran a real task. The runner recorded one `provider.rate_limits` notice. The server stored it. The task properties pane showed "7d 100% · using credits" (this account's weekly window was used up and had credits, and the run succeeded). The transcript showed no extra row. - Not verified live: Claude. The local Claude CLI was not signed in, and the Claude path also needs #19259. The Rust and sidecar paths have tests. - Not run locally: the full `pnpm test:run` and `pnpm build`. CI runs them. ## Risks - Low risk. The snapshot is display data. It does not hold or start work. The existing provider-limit wait (#19246) still reads typed run failures. - Codex can send a rate-limit update often. The runner emits a notice only when the windows change, so run-log growth stays small. - With API-key auth, the harnesses report no windows, so no row appears. This is the intended result. - `CodexProviderState` gains two optional serde fields with defaults, so older checkpoints still load. > For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and discuss it in `#dev` before opening the PR. Feature PRs that overlap with planned core work may need to be redirected — check the roadmap first. See `CONTRIBUTING.md`. ## Model Used - Claude Opus 5.5 (`claude-opus-5-5`), Anthropic, through Claude Code with tool use (shell, file edits, browser) and extended reasoning. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [ ] All Paperclip CI gates are green - [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
6a66c09f7f
commit
dd889e6a0b
27 files changed
+1911
-5
No files matched your search
@@ -954,6 +954,7 @@ export const LIVE_EVENT_TYPES = [
|
||||
"heartbeat.run.activity",
|
||||
"agent.session.goal.changed",
|
||||
"agent.status",
|
||||
"agent.runtime_state.updated",
|
||||
"activity.logged",
|
||||
"external_object.updated",
|
||||
"plugin.ui.updated",
|
||||
|
||||
@@ -2888,3 +2888,4 @@ export {
|
||||
export { readProviderFailure, type ProviderFailure } from "./provider-failure.generated.js";
|
||||
export type { BoardBootstrap, BoardBootstrapSession } from "./types/board-bootstrap.js";
|
||||
export { readBoardBootstrap } from "./board-bootstrap.js";
|
||||
export * from "./provider-rate-limits.js";
|
||||
@@ -0,0 +1,89 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
mergeProviderRateLimitSnapshots,
|
||||
parseProviderRateLimitSnapshot,
|
||||
providerRateLimitSnapshotFromNotice,
|
||||
summarizeProviderRateLimits,
|
||||
type ProviderRateLimitSnapshot,
|
||||
} from "./provider-rate-limits.js";
|
||||
|
||||
const NOW = Date.parse("2026-11-05T15:18:00.000Z");
|
||||
const base: ProviderRateLimitSnapshot = {
|
||||
schema: "paperclip.provider.rate_limits.v1",
|
||||
provider: "openai",
|
||||
windows: [
|
||||
{ id: "codex.primary", label: "5-hour window", shortLabel: "5h", windowMinutes: 300, usedPercent: 78, resetsAt: "2026-11-05T15:40:00.000Z", status: null },
|
||||
{ id: "codex.secondary", label: "Weekly", shortLabel: "7d", windowMinutes: 10080, usedPercent: 41, resetsAt: "2026-11-09T09:00:00.000Z", status: null },
|
||||
],
|
||||
credits: null,
|
||||
limitReached: null,
|
||||
observedAt: "2026-11-05T15:17:00.000Z",
|
||||
};
|
||||
|
||||
describe("provider rate limits", () => {
|
||||
it("summarizes the fullest current window and its tone", () => {
|
||||
expect(summarizeProviderRateLimits(base, NOW)).toMatchObject({ tone: "normal", limited: false, window: { id: "codex.primary" } });
|
||||
const warm = { ...base, windows: [{ ...base.windows[0]!, usedPercent: 86 }, base.windows[1]!] };
|
||||
expect(summarizeProviderRateLimits(warm, NOW)?.tone).toBe("warn");
|
||||
});
|
||||
|
||||
it("drops windows whose reset has passed", () => {
|
||||
const later = Date.parse("2026-11-05T16:00:00.000Z");
|
||||
expect(summarizeProviderRateLimits(base, later)?.window?.id).toBe("codex.secondary");
|
||||
expect(summarizeProviderRateLimits(base, Date.parse("2026-11-10T00:00:00.000Z"))).toBeNull();
|
||||
});
|
||||
|
||||
it("reports the limited state until the reset", () => {
|
||||
const limited = { ...base, limitReached: { windowId: "codex.primary", reason: "rate_limit_reached", resetsAt: "2026-11-05T15:40:00.000Z" } };
|
||||
expect(summarizeProviderRateLimits(limited, NOW)).toMatchObject({ tone: "reached", limited: true, limitedUntil: "2026-11-05T15:40:00.000Z" });
|
||||
expect(summarizeProviderRateLimits(limited, Date.parse("2026-11-05T15:41:00.000Z"))?.limited).toBe(false);
|
||||
});
|
||||
|
||||
it("treats a used-up window with credits as working on credits, not limited", () => {
|
||||
const onCredits = {
|
||||
...base,
|
||||
windows: [{ ...base.windows[1]!, id: "codex.primary", usedPercent: 100 }],
|
||||
credits: { hasCredits: true, unlimited: false, balance: null },
|
||||
limitReached: { windowId: "codex.primary", reason: "rate_limit_reached", resetsAt: "2026-11-09T09:00:00.000Z" },
|
||||
};
|
||||
expect(summarizeProviderRateLimits(onCredits, NOW)).toMatchObject({ tone: "warn", limited: false, usingCredits: true });
|
||||
});
|
||||
|
||||
it("reads the snapshot from a rate-limit notice and rejects other shapes", () => {
|
||||
const payload = {
|
||||
schema: "paperclip.provider.notice.v1", category: "provider.rate_limits",
|
||||
details: [{ name: "rateLimits", value: JSON.stringify(base) }],
|
||||
};
|
||||
expect(providerRateLimitSnapshotFromNotice("provider.notice.recorded", payload)).toEqual(base);
|
||||
expect(providerRateLimitSnapshotFromNotice("provider.notice.recorded", { ...payload, category: "warning" })).toBeNull();
|
||||
expect(providerRateLimitSnapshotFromNotice("provider.notice.recorded", { ...payload, details: [{ name: "rateLimits", value: "{" }] })).toBeNull();
|
||||
expect(parseProviderRateLimitSnapshot({ ...base, provider: "google" })).toBeNull();
|
||||
});
|
||||
|
||||
it("keeps known fields and another window's current rejection when merging", () => {
|
||||
const rejected: ProviderRateLimitSnapshot = {
|
||||
...base,
|
||||
provider: "anthropic",
|
||||
windows: [{ id: "claude.five_hour", label: "5-hour window", shortLabel: "5h", windowMinutes: 300, usedPercent: 100, resetsAt: "2026-11-05T15:40:00.000Z", status: "rejected" }],
|
||||
limitReached: { windowId: "claude.five_hour", reason: "rate_limit_reached", resetsAt: "2026-11-05T15:40:00.000Z" },
|
||||
};
|
||||
const weekly: ProviderRateLimitSnapshot = {
|
||||
...rejected,
|
||||
windows: [{ id: "claude.seven_day", label: "Weekly", shortLabel: "7d", windowMinutes: 10080, usedPercent: 40, resetsAt: null, status: "allowed" }],
|
||||
limitReached: null,
|
||||
observedAt: "2026-11-05T15:20:00.000Z",
|
||||
};
|
||||
const merged = mergeProviderRateLimitSnapshots(rejected, weekly);
|
||||
expect(merged.limitReached?.windowId).toBe("claude.five_hour");
|
||||
const sparse = { ...weekly, windows: [{ ...rejected.windows[0]!, usedPercent: null, resetsAt: null, status: "warning" as const }], observedAt: "2026-11-05T15:21:00.000Z" };
|
||||
const again = mergeProviderRateLimitSnapshots(merged, sparse);
|
||||
expect(again.windows.find((w) => w.id === "claude.five_hour")).toMatchObject({ usedPercent: 100, resetsAt: "2026-11-05T15:40:00.000Z", status: "warning" });
|
||||
expect(again.limitReached).toBeNull();
|
||||
});
|
||||
|
||||
it("merges by window and ignores out-of-order snapshots", () => {
|
||||
const next = { ...base, windows: [{ ...base.windows[0]!, usedPercent: 90 }], observedAt: "2026-11-05T15:20:00.000Z" };
|
||||
expect(mergeProviderRateLimitSnapshots(base, next).windows.map((w) => w.usedPercent)).toEqual([90, 41]);
|
||||
expect(mergeProviderRateLimitSnapshots(next, base)).toBe(next);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,209 @@
|
||||
/**
|
||||
* Latest provider rate-limit windows an agent's harness reported (Codex
|
||||
* `account/rateLimits/updated`, Claude `rate_limit_event`). The runner carries a
|
||||
* snapshot as one JSON detail on a `provider.notice.recorded` event with
|
||||
* category {@link PROVIDER_RATE_LIMITS_NOTICE_CATEGORY}; the server keeps the
|
||||
* merged latest snapshot in `agent_runtime_state.state_json` under
|
||||
* {@link PROVIDER_RATE_LIMITS_STATE_KEY}.
|
||||
*/
|
||||
export const PROVIDER_RATE_LIMITS_NOTICE_CATEGORY = "provider.rate_limits";
|
||||
export const PROVIDER_RATE_LIMITS_SCHEMA = "paperclip.provider.rate_limits.v1";
|
||||
export const PROVIDER_RATE_LIMITS_NOTICE_DETAIL = "rateLimits";
|
||||
export const PROVIDER_RATE_LIMITS_STATE_KEY = "providerRateLimits";
|
||||
export const PROVIDER_RATE_LIMIT_WARN_PERCENT = 80;
|
||||
|
||||
export type ProviderRateLimitProvider = "openai" | "anthropic";
|
||||
|
||||
export interface ProviderRateLimitWindow {
|
||||
id: string;
|
||||
/** "5-hour window", "Weekly", "Weekly (Opus)" */
|
||||
label: string;
|
||||
/** "5h", "7d" */
|
||||
shortLabel: string;
|
||||
windowMinutes: number | null;
|
||||
usedPercent: number | null;
|
||||
resetsAt: string | null;
|
||||
status: "allowed" | "warning" | "rejected" | null;
|
||||
}
|
||||
|
||||
export interface ProviderRateLimitSnapshot {
|
||||
schema: typeof PROVIDER_RATE_LIMITS_SCHEMA;
|
||||
provider: ProviderRateLimitProvider;
|
||||
windows: ProviderRateLimitWindow[];
|
||||
credits: { hasCredits: boolean | null; unlimited: boolean | null; balance: string | null } | null;
|
||||
limitReached: { windowId: string | null; reason: string; resetsAt: string | null } | null;
|
||||
observedAt: string;
|
||||
}
|
||||
|
||||
type Obj = Record<string, unknown>;
|
||||
const obj = (value: unknown): Obj | null =>
|
||||
value !== null && typeof value === "object" && !Array.isArray(value) ? (value as Obj) : null;
|
||||
const str = (value: unknown, max = 160): string | null =>
|
||||
typeof value === "string" && value.length > 0 ? value.slice(0, max) : null;
|
||||
const num = (value: unknown): number | null =>
|
||||
typeof value === "number" && Number.isFinite(value) ? value : null;
|
||||
const iso = (value: unknown): string | null => {
|
||||
const text = str(value, 64);
|
||||
return text && Number.isFinite(Date.parse(text)) ? text : null;
|
||||
};
|
||||
const bool = (value: unknown): boolean | null => (typeof value === "boolean" ? value : null);
|
||||
|
||||
/** Validate an untrusted snapshot (from a run event or stored state). */
|
||||
export function parseProviderRateLimitSnapshot(value: unknown): ProviderRateLimitSnapshot | null {
|
||||
const raw = obj(value);
|
||||
if (!raw || raw.schema !== PROVIDER_RATE_LIMITS_SCHEMA) return null;
|
||||
if (raw.provider !== "openai" && raw.provider !== "anthropic") return null;
|
||||
const observedAt = iso(raw.observedAt);
|
||||
if (!observedAt || !Array.isArray(raw.windows)) return null;
|
||||
const windows: ProviderRateLimitWindow[] = [];
|
||||
for (const entry of raw.windows.slice(0, 8)) {
|
||||
const window = obj(entry);
|
||||
const id = str(window?.id);
|
||||
const label = str(window?.label);
|
||||
if (!window || !id || !label) continue;
|
||||
const status = window.status === "allowed" || window.status === "warning" || window.status === "rejected" ? window.status : null;
|
||||
const usedPercent = num(window.usedPercent);
|
||||
const windowMinutes = num(window.windowMinutes);
|
||||
windows.push({
|
||||
id,
|
||||
label,
|
||||
shortLabel: str(window.shortLabel, 40) ?? label,
|
||||
windowMinutes: windowMinutes !== null && windowMinutes > 0 ? windowMinutes : null,
|
||||
usedPercent: usedPercent === null ? null : Math.min(1000, Math.max(0, usedPercent)),
|
||||
resetsAt: iso(window.resetsAt),
|
||||
status,
|
||||
});
|
||||
}
|
||||
const credits = obj(raw.credits);
|
||||
const reached = obj(raw.limitReached);
|
||||
return {
|
||||
schema: PROVIDER_RATE_LIMITS_SCHEMA,
|
||||
provider: raw.provider,
|
||||
windows,
|
||||
credits: credits
|
||||
? { hasCredits: bool(credits.hasCredits), unlimited: bool(credits.unlimited), balance: str(credits.balance, 40) }
|
||||
: null,
|
||||
limitReached: reached
|
||||
? { windowId: str(reached.windowId), reason: str(reached.reason) ?? "rate_limit_reached", resetsAt: iso(reached.resetsAt) }
|
||||
: null,
|
||||
observedAt,
|
||||
};
|
||||
}
|
||||
|
||||
/** The snapshot carried by a rate-limit `provider.notice.recorded` payload, if any. */
|
||||
export function providerRateLimitSnapshotFromNotice(
|
||||
eventType: string,
|
||||
payload: Record<string, unknown>,
|
||||
): ProviderRateLimitSnapshot | null {
|
||||
if (eventType !== "provider.notice.recorded") return null;
|
||||
if (payload.schema !== "paperclip.provider.notice.v1" || payload.category !== PROVIDER_RATE_LIMITS_NOTICE_CATEGORY) return null;
|
||||
const details = Array.isArray(payload.details) ? payload.details : [];
|
||||
const detail = details.map(obj).find((entry) => entry?.name === PROVIDER_RATE_LIMITS_NOTICE_DETAIL);
|
||||
if (typeof detail?.value !== "string") return null;
|
||||
try {
|
||||
return parseProviderRateLimitSnapshot(JSON.parse(detail.value));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function isProviderRateLimitNotice(eventType: string, payload: Record<string, unknown>): boolean {
|
||||
return eventType === "provider.notice.recorded"
|
||||
&& payload.schema === "paperclip.provider.notice.v1"
|
||||
&& payload.category === PROVIDER_RATE_LIMITS_NOTICE_CATEGORY;
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge a newer snapshot into the stored one. Runners already merge within a
|
||||
* session; this keeps windows another session reported and ignores
|
||||
* out-of-order deliveries.
|
||||
*/
|
||||
export function mergeProviderRateLimitSnapshots(
|
||||
stored: ProviderRateLimitSnapshot | null,
|
||||
next: ProviderRateLimitSnapshot,
|
||||
): ProviderRateLimitSnapshot {
|
||||
if (!stored || stored.provider !== next.provider) return next;
|
||||
const at = Date.parse(next.observedAt);
|
||||
if (at < Date.parse(stored.observedAt)) return stored;
|
||||
// Claude reports one window per event and Codex updates are sparse: a null
|
||||
// field never clears a known value.
|
||||
const windows = new Map(stored.windows.map((window) => [window.id, window]));
|
||||
for (const window of next.windows) {
|
||||
const prior = windows.get(window.id);
|
||||
windows.set(window.id, prior ? {
|
||||
...window,
|
||||
usedPercent: window.usedPercent ?? prior.usedPercent,
|
||||
resetsAt: window.resetsAt ?? prior.resetsAt,
|
||||
windowMinutes: window.windowMinutes ?? prior.windowMinutes,
|
||||
status: window.status ?? prior.status,
|
||||
} : window);
|
||||
}
|
||||
// A report about another window does not clear a still-current rejection.
|
||||
const reportedIds = new Set(next.windows.map((window) => window.id));
|
||||
const storedReached = stored.limitReached;
|
||||
const keepStoredReached = storedReached !== null
|
||||
&& storedReached.windowId !== null
|
||||
&& !reportedIds.has(storedReached.windowId)
|
||||
&& (storedReached.resetsAt === null || Date.parse(storedReached.resetsAt) > at);
|
||||
return {
|
||||
...next,
|
||||
windows: [...windows.values()].slice(0, 8),
|
||||
credits: next.credits ?? stored.credits,
|
||||
limitReached: next.limitReached ?? (keepStoredReached ? storedReached : null),
|
||||
};
|
||||
}
|
||||
|
||||
export function readProviderRateLimits(stateJson: Record<string, unknown> | null | undefined): ProviderRateLimitSnapshot | null {
|
||||
return parseProviderRateLimitSnapshot(stateJson?.[PROVIDER_RATE_LIMITS_STATE_KEY]);
|
||||
}
|
||||
|
||||
export interface ProviderRateLimitSummary {
|
||||
tone: "normal" | "warn" | "reached";
|
||||
/** The window the one-line summary describes. */
|
||||
window: ProviderRateLimitWindow | null;
|
||||
/** Set while the provider is refusing work and the reset is still ahead. */
|
||||
limitedUntil: string | null;
|
||||
limited: boolean;
|
||||
/**
|
||||
* The provider reports a window as used up but the account has credits, so
|
||||
* work continues on credits (Codex) instead of waiting for the reset.
|
||||
*/
|
||||
usingCredits: boolean;
|
||||
/** Windows whose reset has not passed yet, fullest first. */
|
||||
current: ProviderRateLimitWindow[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Which window to show, given the clock. A window whose reset time has passed
|
||||
* has rolled over, so its stored percentage no longer applies.
|
||||
*/
|
||||
export function summarizeProviderRateLimits(
|
||||
snapshot: ProviderRateLimitSnapshot | null,
|
||||
now: number,
|
||||
): ProviderRateLimitSummary | null {
|
||||
if (!snapshot) return null;
|
||||
const live = (resetsAt: string | null) => !resetsAt || Date.parse(resetsAt) > now;
|
||||
const current = snapshot.windows
|
||||
.filter((window) => window.usedPercent !== null && live(window.resetsAt))
|
||||
.sort((a, b) => (b.usedPercent ?? 0) - (a.usedPercent ?? 0));
|
||||
const reached = snapshot.limitReached;
|
||||
const hasCredits = snapshot.credits?.unlimited === true || snapshot.credits?.hasCredits === true;
|
||||
const reachedNow = reached !== null && live(reached.resetsAt)
|
||||
&& (reached.resetsAt !== null || now - Date.parse(snapshot.observedAt) < 3_600_000);
|
||||
if (reached && reachedNow && !hasCredits) {
|
||||
const window = snapshot.windows.find((candidate) => candidate.id === reached.windowId) ?? current[0] ?? null;
|
||||
return { tone: "reached", window, limitedUntil: reached.resetsAt, limited: true, usingCredits: false, current };
|
||||
}
|
||||
const window = current[0] ?? null;
|
||||
if (!window) return null;
|
||||
const pct = window.usedPercent ?? 0;
|
||||
const usingCredits = hasCredits && (reachedNow || pct >= 100);
|
||||
return {
|
||||
tone: usingCredits ? "warn" : pct >= 100 ? "reached" : pct > PROVIDER_RATE_LIMIT_WARN_PERCENT ? "warn" : "normal",
|
||||
window,
|
||||
limitedUntil: null,
|
||||
limited: false,
|
||||
usingCredits,
|
||||
current,
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user