mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-10 02:15:02 +00:00
Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f56a1add91 | ||
|
|
c9b03eb309 | ||
|
|
9f68af0274 | ||
|
|
79f2db8d56 |
@@ -195,6 +195,9 @@ See the [public usage source reference](../public-docs/plugins/reference.md#usag
|
||||
contract, account and window identity, provider-derived period names, login fallback, and
|
||||
read-only credential rules. Usage adapters own the interpretation of provider fields; the app
|
||||
renders their names and resolves pins without provider-specific duration guesses.
|
||||
Claude's OAuth usage endpoint is undocumented. Extra-usage amounts are reported in minor currency
|
||||
units and displayed only for USD; the balance contract does not represent other currencies.
|
||||
Codex individual spending limits provide percentages, not dollar amounts.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -45,6 +45,11 @@ process.stdin.on("data", (chunk) => {
|
||||
for (;;) {
|
||||
const paste = /\x1b\[200~([^]*?)\x1b\[201~/.exec(buffer);
|
||||
if (!paste) break;
|
||||
const pasted = JSON.stringify(paste[1]).replace(
|
||||
/\s/g,
|
||||
(space) => "\\u" + space.charCodeAt(0).toString(16).padStart(4, "0"),
|
||||
);
|
||||
process.stdout.write("PASTED:" + pasted + "\r\n");
|
||||
for (const [, path] of paste[1].matchAll(/'([^']*)'/g)) {
|
||||
const digest = crypto.createHash("sha256").update(fs.readFileSync(path)).digest("hex");
|
||||
const location = /[\\/]uploads[\\/]upload_/.test(path) ? "upload" : "local";
|
||||
@@ -202,6 +207,21 @@ test.describe("Terminal protocol queries", () => {
|
||||
await expect.poll(() => terminalOutput(terminalId), { timeout: 10_000 }).toContain(text);
|
||||
}
|
||||
|
||||
// Every bracketed paste body the receiver consumed, in order and joined: what a
|
||||
// shell would read as one command line.
|
||||
async function pastedText(terminalId: string): Promise<string> {
|
||||
const output = await terminalOutput(terminalId);
|
||||
return [...output.matchAll(/PASTED:("(?:[^"\\]|\\.)*")/g)]
|
||||
.map(([, json]) => JSON.parse(json) as string)
|
||||
.join("");
|
||||
}
|
||||
|
||||
// Exactly one ASCII space after each quoted path and nothing else between them,
|
||||
// so the paths stay separate shell words.
|
||||
async function expectSeparatePastedPaths(terminalId: string): Promise<void> {
|
||||
await expect.poll(() => pastedText(terminalId)).toMatch(/^'[^']+' '[^']+' $/);
|
||||
}
|
||||
|
||||
test("pastes images from two panes into their own terminals as uploaded host files", async ({
|
||||
page,
|
||||
}) => {
|
||||
@@ -238,6 +258,23 @@ test.describe("Terminal protocol queries", () => {
|
||||
|
||||
await expectTerminalOutput(terminal.id, fileRead(pngBytes(3), "upload"));
|
||||
await expectTerminalOutput(terminal.id, fileRead(Buffer.from("remote notes"), "upload"));
|
||||
await expectSeparatePastedPaths(terminal.id);
|
||||
} finally {
|
||||
await harness.killTerminal(terminal.id);
|
||||
}
|
||||
});
|
||||
|
||||
test("keeps the paths of two consecutive drops to another machine apart", async ({ page }) => {
|
||||
const terminal = await openFileReceiver(page, "file-drop-remote-twice");
|
||||
const first = { name: "first.png", type: "image/png", bytes: pngBytes(10) };
|
||||
const second = { name: "second.txt", type: "text/plain", bytes: Buffer.from("second drop") };
|
||||
try {
|
||||
await dropFiles(page, [first]);
|
||||
await expectTerminalOutput(terminal.id, fileRead(first.bytes, "upload"));
|
||||
await dropFiles(page, [second]);
|
||||
await expectTerminalOutput(terminal.id, fileRead(second.bytes, "upload"));
|
||||
|
||||
await expectSeparatePastedPaths(terminal.id);
|
||||
} finally {
|
||||
await harness.killTerminal(terminal.id);
|
||||
}
|
||||
|
||||
@@ -1,65 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
createFloatingPanelHosts,
|
||||
type FloatingPanelHostRect,
|
||||
type MeasurableFloatingPanelHost,
|
||||
} from "./floating-panel-hosts";
|
||||
|
||||
function createHost(rect: FloatingPanelHostRect): MeasurableFloatingPanelHost {
|
||||
return {
|
||||
measureInWindow: (callback) => callback(rect.x, rect.y, rect.width, rect.height),
|
||||
};
|
||||
}
|
||||
|
||||
const WORKSPACE_RECT: FloatingPanelHostRect = { x: 10, y: 20, width: 800, height: 600 };
|
||||
const RETAINED_VIEWPORT: FloatingPanelHostRect = { x: 0, y: 0, width: 1200, height: 900 };
|
||||
|
||||
describe("floating panel hosts", () => {
|
||||
it("measures the newest host registered under the name", async () => {
|
||||
const hosts = createFloatingPanelHosts();
|
||||
hosts.register("workspace", createHost(WORKSPACE_RECT));
|
||||
hosts.register("workspace", createHost(RETAINED_VIEWPORT));
|
||||
|
||||
expect(await hosts.measure("workspace")).toEqual(RETAINED_VIEWPORT);
|
||||
});
|
||||
|
||||
it("keeps measuring a survivor after the newest host unregisters", async () => {
|
||||
const hosts = createFloatingPanelHosts();
|
||||
const unregisterSurvivor = hosts.register("workspace", createHost(WORKSPACE_RECT));
|
||||
const unregisterNewest = hosts.register("workspace", createHost(RETAINED_VIEWPORT));
|
||||
|
||||
unregisterNewest();
|
||||
|
||||
expect(await hosts.measure("workspace")).toEqual(WORKSPACE_RECT);
|
||||
unregisterSurvivor();
|
||||
});
|
||||
|
||||
it("returns null once every host under the name is gone", async () => {
|
||||
const hosts = createFloatingPanelHosts();
|
||||
const unregisterFirst = hosts.register("workspace", createHost(WORKSPACE_RECT));
|
||||
const unregisterSecond = hosts.register("workspace", createHost(RETAINED_VIEWPORT));
|
||||
|
||||
unregisterFirst();
|
||||
unregisterSecond();
|
||||
|
||||
expect(await hosts.measure("workspace")).toBeNull();
|
||||
});
|
||||
|
||||
it("skips hosts that are not laid out", async () => {
|
||||
const hosts = createFloatingPanelHosts();
|
||||
hosts.register("workspace", createHost(WORKSPACE_RECT));
|
||||
hosts.register("workspace", createHost({ x: 0, y: 0, width: 0, height: 0 }));
|
||||
|
||||
expect(await hosts.measure("workspace")).toEqual(WORKSPACE_RECT);
|
||||
});
|
||||
|
||||
it("ignores a repeated unregister of the same host", async () => {
|
||||
const hosts = createFloatingPanelHosts();
|
||||
const unregister = hosts.register("workspace", createHost(WORKSPACE_RECT));
|
||||
|
||||
unregister();
|
||||
unregister();
|
||||
|
||||
expect(await hosts.measure("workspace")).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1,57 +0,0 @@
|
||||
export interface FloatingPanelHostRect {
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
export interface MeasurableFloatingPanelHost {
|
||||
measureInWindow(callback: (x: number, y: number, width: number, height: number) => void): void;
|
||||
}
|
||||
|
||||
export interface FloatingPanelHosts<T extends MeasurableFloatingPanelHost> {
|
||||
register(name: string, host: T): () => void;
|
||||
measure(name: string): Promise<FloatingPanelHostRect | null>;
|
||||
}
|
||||
|
||||
function measureHost(host: MeasurableFloatingPanelHost): Promise<FloatingPanelHostRect> {
|
||||
return new Promise((resolve) => {
|
||||
host.measureInWindow((x, y, width, height) => resolve({ x, y, width, height }));
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Hosts are addressed by name, and one name can have several live hosts: a workspace
|
||||
* screen mounts its own host while retained copies of that screen stay mounted. Track
|
||||
* every host so unmounting one leaves the others measurable.
|
||||
*/
|
||||
export function createFloatingPanelHosts<
|
||||
T extends MeasurableFloatingPanelHost,
|
||||
>(): FloatingPanelHosts<T> {
|
||||
const hostsByName = new Map<string, T[]>();
|
||||
|
||||
function register(name: string, host: T): () => void {
|
||||
const hosts = hostsByName.get(name) ?? [];
|
||||
hostsByName.set(name, hosts);
|
||||
hosts.push(host);
|
||||
|
||||
return () => {
|
||||
const index = hosts.indexOf(host);
|
||||
if (index === -1) return;
|
||||
hosts.splice(index, 1);
|
||||
if (hosts.length === 0 && hostsByName.get(name) === hosts) {
|
||||
hostsByName.delete(name);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
async function measure(name: string): Promise<FloatingPanelHostRect | null> {
|
||||
const hosts = hostsByName.get(name);
|
||||
if (!hosts || hosts.length === 0) return null;
|
||||
|
||||
const rects = await Promise.all(hosts.toReversed().map(measureHost));
|
||||
return rects.find((rect) => rect.width > 0 && rect.height > 0) ?? rects[0];
|
||||
}
|
||||
|
||||
return { register, measure };
|
||||
}
|
||||
@@ -1,40 +0,0 @@
|
||||
import React, { act } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { Portal, PortalProvider } from "@gorhom/portal";
|
||||
import { FloatingPanelPortalHost, measureFloatingPanelPortalHost } from "./floating-panel-portal";
|
||||
|
||||
describe("Floating panel host lifetime", () => {
|
||||
test("removing the newest host preserves the surviving host measurement", async () => {
|
||||
const container = document.createElement("div");
|
||||
container.style.cssText = "position:relative;width:800px;height:600px";
|
||||
document.body.appendChild(container);
|
||||
const root = createRoot(container);
|
||||
function Content({ overlap }: { overlap: boolean }) {
|
||||
return (
|
||||
<PortalProvider shouldAddRootHost={false}>
|
||||
<FloatingPanelPortalHost name="workspace" />
|
||||
{overlap && <FloatingPanelPortalHost name="workspace" />}
|
||||
<Portal hostName="workspace" name="picker">
|
||||
<div data-testid="picker">Commands</div>
|
||||
</Portal>
|
||||
</PortalProvider>
|
||||
);
|
||||
}
|
||||
try {
|
||||
await act(async () => root.render(<Content overlap={true} />));
|
||||
expect(await measureFloatingPanelPortalHost("workspace")).toMatchObject({
|
||||
width: 800,
|
||||
height: 600,
|
||||
});
|
||||
await act(async () => root.render(<Content overlap={false} />));
|
||||
expect(await measureFloatingPanelPortalHost("workspace")).toMatchObject({
|
||||
width: 800,
|
||||
height: 600,
|
||||
});
|
||||
} finally {
|
||||
await act(async () => root.unmount());
|
||||
container.remove();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -8,21 +8,32 @@ import {
|
||||
} from "react";
|
||||
import { StyleSheet, View } from "react-native";
|
||||
import { PortalHost } from "@gorhom/portal";
|
||||
import {
|
||||
createFloatingPanelHosts,
|
||||
type FloatingPanelHostRect,
|
||||
} from "@/components/ui/floating-panel-hosts";
|
||||
|
||||
export const DEFAULT_FLOATING_PANEL_PORTAL_HOST = "content-floating-panels";
|
||||
|
||||
const FloatingPanelPortalHostNameContext = createContext(DEFAULT_FLOATING_PANEL_PORTAL_HOST);
|
||||
|
||||
const portalHosts = createFloatingPanelHosts<View>();
|
||||
interface Rect {
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
export function measureFloatingPanelPortalHost(
|
||||
name: string,
|
||||
): Promise<FloatingPanelHostRect | null> {
|
||||
return portalHosts.measure(name);
|
||||
const portalHostElements = new Map<string, View>();
|
||||
|
||||
function measureElement(element: View): Promise<Rect> {
|
||||
return new Promise((resolve) => {
|
||||
element.measureInWindow((x, y, width, height) => {
|
||||
resolve({ x, y, width, height });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export function measureFloatingPanelPortalHost(name: string): Promise<Rect | null> {
|
||||
const element = portalHostElements.get(name);
|
||||
if (!element) return Promise.resolve(null);
|
||||
return measureElement(element);
|
||||
}
|
||||
|
||||
export function FloatingPanelPortalHostNameProvider({
|
||||
@@ -53,7 +64,12 @@ export function FloatingPanelPortalHost({
|
||||
useEffect(() => {
|
||||
const host = hostRef.current;
|
||||
if (!host) return undefined;
|
||||
return portalHosts.register(name, host);
|
||||
portalHostElements.set(name, host);
|
||||
return () => {
|
||||
if (portalHostElements.get(name) === host) {
|
||||
portalHostElements.delete(name);
|
||||
}
|
||||
};
|
||||
}, [name]);
|
||||
|
||||
return (
|
||||
|
||||
@@ -74,7 +74,8 @@ describe("terminal file paste queue", () => {
|
||||
|
||||
firstUpload.resolve("/host/uploads/a.png");
|
||||
await settle();
|
||||
expect(host.pasted).toEqual(["'/host/uploads/a.png'", "'/host/uploads/b.png'"]);
|
||||
expect(host.pasted).toEqual(["'/host/uploads/a.png' ", "'/host/uploads/b.png' "]);
|
||||
expect(host.pasted.join("")).toBe("'/host/uploads/a.png' '/host/uploads/b.png' ");
|
||||
expect(events).toEqual({ uploading: [true, false, true, false], uploaded: 2, failures: [] });
|
||||
});
|
||||
|
||||
@@ -85,7 +86,7 @@ describe("terminal file paste queue", () => {
|
||||
queue.add(Promise.resolve(file("shot.png")), host);
|
||||
await settle();
|
||||
|
||||
expect(host.pasted).toEqual(['"C:\\Users\\Jo Doe\\.paseo\\uploads\\upload_1\\shot.png"']);
|
||||
expect(host.pasted).toEqual(['"C:\\Users\\Jo Doe\\.paseo\\uploads\\upload_1\\shot.png" ']);
|
||||
});
|
||||
|
||||
it("uploads under a file name that needs no shell quoting", async () => {
|
||||
@@ -120,7 +121,7 @@ describe("terminal file paste queue", () => {
|
||||
|
||||
expect(events.failures).toEqual(["disk full"]);
|
||||
expect(events.uploaded).toBe(1);
|
||||
expect(host.pasted).toEqual(["'/host/uploads/b.png'"]);
|
||||
expect(host.pasted).toEqual(["'/host/uploads/b.png' "]);
|
||||
});
|
||||
|
||||
it("reports unreadable and oversized files without uploading them", async () => {
|
||||
@@ -166,7 +167,7 @@ describe("terminal file paste queue", () => {
|
||||
queue.add(Promise.resolve(file("next.png")), host);
|
||||
await settle();
|
||||
|
||||
expect(host.pasted).toEqual(["'/host/uploads/next.png'"]);
|
||||
expect(host.pasted).toEqual(["'/host/uploads/next.png' "]);
|
||||
expect(events.uploading).toEqual([true, false, true, false]);
|
||||
expect(queue.cancel()).toBe(false);
|
||||
});
|
||||
|
||||
@@ -75,7 +75,7 @@ export class TerminalFilePasteQueue {
|
||||
this.setUploading(true);
|
||||
const path = await target.upload({ ...file, fileName: toShellSafeFileName(file.fileName) });
|
||||
if (isCurrent()) {
|
||||
target.paste(prepareTerminalPath(path));
|
||||
target.paste(`${prepareTerminalPath(path)} `);
|
||||
this.events.onUploaded();
|
||||
}
|
||||
} catch (error) {
|
||||
|
||||
@@ -48,7 +48,8 @@ afterEach(() => {
|
||||
process.env.PATH = originalEnv.PATH;
|
||||
process.env.PATHEXT = originalEnv.PATHEXT;
|
||||
for (const dir of tempDirs.splice(0)) {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
// Windows can briefly retain a lock on the copied executable after its probe exits.
|
||||
rmSync(dir, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -162,6 +162,64 @@ describe("claude usage source", () => {
|
||||
expect.objectContaining({ signal: expect.any(AbortSignal) }),
|
||||
);
|
||||
});
|
||||
it.each([false, true])("reports USD extra usage with rolling windows %j", async (hasWindows) => {
|
||||
writeClaudeCredentials(claudeHome, "at_valid");
|
||||
fetchApi = mockFetch(
|
||||
new Map([
|
||||
[
|
||||
"https://api.anthropic.com/api/oauth/usage",
|
||||
() =>
|
||||
jsonResponse({
|
||||
...(hasWindows ? makeClaudeResponse() : {}),
|
||||
extra_usage: {
|
||||
is_enabled: true,
|
||||
monthly_limit: 5000,
|
||||
used_credits: 1250,
|
||||
currency: "USD",
|
||||
},
|
||||
}),
|
||||
],
|
||||
]),
|
||||
);
|
||||
|
||||
const claude = findProvider(await service().listUsage(), "claude");
|
||||
|
||||
expect(claude.balances).toEqual([
|
||||
expect.objectContaining({
|
||||
id: "extra_usage_monthly",
|
||||
used: 12.5,
|
||||
remaining: 37.5,
|
||||
limit: 50,
|
||||
unit: "usd",
|
||||
}),
|
||||
]);
|
||||
expect(claude.details).toContainEqual(
|
||||
expect.objectContaining({ id: "extra_usage", value: "Enabled" }),
|
||||
);
|
||||
});
|
||||
|
||||
it.each(["EUR", null, undefined])(
|
||||
"does not report extra usage balances for currency %j",
|
||||
async (currency) => {
|
||||
writeClaudeCredentials(claudeHome, "at_valid");
|
||||
fetchApi = mockFetch(
|
||||
new Map([
|
||||
[
|
||||
"https://api.anthropic.com/api/oauth/usage",
|
||||
() =>
|
||||
jsonResponse({
|
||||
...makeClaudeResponse(),
|
||||
extra_usage: { monthly_limit: 5000, used_credits: 1250, currency },
|
||||
}),
|
||||
],
|
||||
]),
|
||||
);
|
||||
|
||||
const claude = findProvider(await service().listUsage(), "claude");
|
||||
|
||||
expect(claude.balances).toEqual([]);
|
||||
},
|
||||
);
|
||||
|
||||
it("accepts a null Claude resets_at when a window has no scheduled reset", async () => {
|
||||
writeClaudeCredentials(claudeHome, "at_valid");
|
||||
|
||||
@@ -68,6 +68,9 @@ const ClaudeUsageResponseSchema = z.object({
|
||||
extra_usage: z
|
||||
.object({
|
||||
is_enabled: z.boolean().optional(),
|
||||
monthly_limit: ApiNumberSchema.nullish(),
|
||||
used_credits: ApiNumberSchema.nullish(),
|
||||
currency: z.string().nullish(),
|
||||
})
|
||||
.nullish(),
|
||||
});
|
||||
@@ -491,6 +494,26 @@ function toCredentialRecord(credentials: ClaudeCredentials): ClaudeCredentialRec
|
||||
: null;
|
||||
}
|
||||
|
||||
function extraUsageBalances(extraUsage: ClaudeUsageResponse["extra_usage"]) {
|
||||
if (extraUsage?.currency?.toUpperCase() !== "USD") return [];
|
||||
const used = extraUsage.used_credits == null ? undefined : extraUsage.used_credits / 100;
|
||||
const limit = extraUsage.monthly_limit == null ? undefined : extraUsage.monthly_limit / 100;
|
||||
if (used == null && limit == null) return [];
|
||||
return [
|
||||
{
|
||||
id: "extra_usage_monthly",
|
||||
label: "Extra usage",
|
||||
used,
|
||||
remaining: used != null && limit != null ? Math.max(0, limit - used) : undefined,
|
||||
limit,
|
||||
unit: "usd" as const,
|
||||
tone: toneFromUsedPct(
|
||||
used != null && limit != null && limit > 0 ? (used / limit) * 100 : null,
|
||||
),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
export async function fetchUsage(
|
||||
input: UsageInput,
|
||||
fetchApi: typeof fetch = fetch,
|
||||
@@ -594,20 +617,21 @@ export async function fetchUsage(
|
||||
}
|
||||
|
||||
const details: UsageDetail[] = [];
|
||||
const extraUsageEnabled = resp.extra_usage?.is_enabled;
|
||||
if (extraUsageEnabled !== undefined) {
|
||||
const extraUsage = resp.extra_usage;
|
||||
if (extraUsage?.is_enabled !== undefined) {
|
||||
details.push({
|
||||
id: "extra_usage",
|
||||
label: "Extra usage",
|
||||
value: extraUsageEnabled ? "Enabled" : "Disabled",
|
||||
value: extraUsage.is_enabled ? "Enabled" : "Disabled",
|
||||
});
|
||||
}
|
||||
const balances = extraUsageBalances(extraUsage);
|
||||
|
||||
return {
|
||||
status: "available",
|
||||
planLabel: plan ?? undefined,
|
||||
windows,
|
||||
balances: [],
|
||||
balances,
|
||||
details,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -117,6 +117,56 @@ test("coerces credit balance and marks a 96 percent window dangerous", async ()
|
||||
]);
|
||||
});
|
||||
|
||||
test("reports enterprise individual spend limit percentage and reset", async () => {
|
||||
const report = await fetchUsage(authInput(fixtureHome), async () =>
|
||||
Response.json({
|
||||
plan_type: "enterprise",
|
||||
spend_control: {
|
||||
individual_limit: { used_percent: 5, remaining_percent: 95, reset_at: 1795996800 },
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
expect(report).toMatchObject({
|
||||
planLabel: "enterprise",
|
||||
windows: [
|
||||
expect.objectContaining({
|
||||
id: "individual_limit",
|
||||
label: "Individual spend limit",
|
||||
summary: true,
|
||||
usedPct: 5,
|
||||
remainingPct: 95,
|
||||
resetsAt: new Date(1795996800 * 1000).toISOString(),
|
||||
}),
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
test.each([
|
||||
[{ used_percent: 5 }, 5, 95],
|
||||
[{ remaining_percent: 95 }, 5, 95],
|
||||
[{ used_percent: 5, remaining_percent: 90 }, 5, 90],
|
||||
[{ used_percent: 110 }, 110, 0],
|
||||
])(
|
||||
"completes individual spending percentages for %j",
|
||||
async (individual_limit, usedPct, remainingPct) => {
|
||||
const report = await fetchUsage(authInput(fixtureHome), async () =>
|
||||
Response.json({ spend_control: { individual_limit } }),
|
||||
);
|
||||
expect(report).toMatchObject({
|
||||
status: "available",
|
||||
windows: [expect.objectContaining({ id: "individual_limit", usedPct, remainingPct })],
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
test("omits individual spend limit without a percentage", async () => {
|
||||
const report = await fetchUsage(authInput(fixtureHome), async () =>
|
||||
Response.json({ spend_control: { individual_limit: { reset_at: 1795996800 } } }),
|
||||
);
|
||||
expect(report.windows).toEqual([]);
|
||||
});
|
||||
|
||||
test("summarizes the session and weekly windows by default, not code review", async () => {
|
||||
const report = await fetchUsage(
|
||||
authInput(fixtureHome),
|
||||
|
||||
@@ -34,6 +34,15 @@ const rateLimitSchema = z.object({
|
||||
primary_window: windowSchema.nullish(),
|
||||
secondary_window: windowSchema.nullish(),
|
||||
});
|
||||
const spendControlSchema = z.object({
|
||||
individual_limit: z
|
||||
.object({
|
||||
used_percent: number.nullish(),
|
||||
remaining_percent: number.nullish(),
|
||||
reset_at: number.nullish(),
|
||||
})
|
||||
.nullish(),
|
||||
});
|
||||
const responseSchema = z.object({
|
||||
plan_type: z.string().optional(),
|
||||
email: z.string().optional(),
|
||||
@@ -48,6 +57,7 @@ const responseSchema = z.object({
|
||||
)
|
||||
.nullish(),
|
||||
code_review_rate_limit: rateLimitSchema.nullish(),
|
||||
spend_control: spendControlSchema.nullish(),
|
||||
credits: z.object({ balance: number.optional() }).nullish(),
|
||||
});
|
||||
|
||||
@@ -227,6 +237,27 @@ export async function fetchUsage(
|
||||
},
|
||||
});
|
||||
}),
|
||||
...(usage.spend_control?.individual_limit
|
||||
? (() => {
|
||||
const limit = usage.spend_control.individual_limit;
|
||||
const usedPct =
|
||||
limit.used_percent ??
|
||||
(limit.remaining_percent != null ? 100 - limit.remaining_percent : undefined);
|
||||
if (usedPct === undefined) return [];
|
||||
return [
|
||||
{
|
||||
id: "individual_limit",
|
||||
label: "Individual spend limit",
|
||||
summary: true,
|
||||
usedPct,
|
||||
remainingPct: limit.remaining_percent ?? Math.max(0, 100 - usedPct),
|
||||
resetsAt:
|
||||
limit.reset_at != null ? new Date(limit.reset_at * 1000).toISOString() : null,
|
||||
tone: toneFromUsedPct(usedPct),
|
||||
},
|
||||
];
|
||||
})()
|
||||
: []),
|
||||
...usageWindows({
|
||||
rateLimit: usage.code_review_rate_limit,
|
||||
scope: { id: "code_review", label: "Code review" },
|
||||
|
||||
Reference in New Issue
Block a user