Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@
- Localization: align the Raycast auto-import and cookie-disabled strings in ru-RU, uk-UA, tr-TR and zh-CN with each locale's existing cookie and installed-browser terminology.
- OpenAI API: each Admin refresh also reads the last hour of 1-minute usage from the Admin Usage API (input plus output tokens, cached input included, Batch API traffic kept out of limit headroom) and carries a Tokens/min grid with a reporting frontier, so buckets that arrive late show as pending instead of zero. The backend can also load 15-minute, 1-hour or 6-hour windows, read per-project rate-limit caps, and measure headroom against manual per-model organization limits stored in settings. The per-refresh request can be turned off, and a failure never affects the daily usage (upstream #4495).
- OpenAI API: the card shows the last-hour Tokens/min grid (latest, peak, 5-minute average, reporting frontier, and pending minutes drawn as outlines), and Settings > Providers > OpenAI adds a Live Usage section with a refresh switch, a per-model organization limits editor, a 15-minute / 1-hour / 6-hour window with per-model rows, and the organization caps list, which stays hidden when a project id is saved (upstream #4495).
- OpenAI API: the card adds a **Live · Spend** switch when it has both the Tokens/min window and daily spend history. Live (the default) shows the Tokens/min grid and Spend shows daily spend for the history window; the card keeps the same height in both modes, and the choice is remembered. Hovering, dragging across, or arrowing through either chart selects one bar and shows its minute (tokens and requests, or "reporting…" for pending minutes) or its day (cost and tokens) below the chart (upstream #4495).
- OpenAI API: add **Tokens/min** to the Settings > Providers > OpenAI API menu and floating bar metric picker. With it selected, the tray tooltip and tray status row show the latest reported minute, for example `OpenAI API 49M/min`, or "Tokens/min unavailable" when Live usage has not reported a complete minute. The tray meter and floating bar keep the Automatic usage lane (upstream #4495).
- Tray: add a **Progress bars stacked** menu bar style that draws the session and weekly meters as two equal-height rows on the single tray icon, with the same windows and pace tint as **Meter bars**; older settings files keep their style (upstream #4436).
- Settings providers: show only enabled providers from the sidebar options menu, search by CLI alias (such as `openai` for Codex), restore the default provider order, and undo a drag reorder or a restore with Ctrl+Z or the options menu. Hidden providers keep their slots when the order changes (upstream #4440).
Expand Down
32 changes: 32 additions & 0 deletions apps/desktop-tauri/scripts/proof-check.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,20 @@
// pnpm proof:check -- --browser <path> browser binary (or env PROOF_BROWSER)
// pnpm proof:check -- --out <dir> screenshot folder (default .proof/screens)
//
// Scenario steps (scripts/proof-scenarios.json), run in order:
// expectText / expectNoText page text includes / excludes each string
// expectSelector a DOM selector matches
// expectTheme documentElement dataset.theme
// expectField a native <select> by label: value, options, enabled
// expectCall a backend command was invoked (optional args match)
// expectNoCall a backend command was not invoked (waits 300 ms first)
// click mouse click on the first match (label, optional near)
// key key press (CDP Input.dispatchKeyEvent) at the focused
// element: Escape, ArrowLeft, ArrowRight, Home, End
// select set a native <select> by label
// screenshot PNG into --out (clip "panel" crops to the tray height)
// wait sleep N ms
//
// Exit codes: 0 all scenarios passed, 1 a scenario failed, 2 setup failed.

import { spawn } from "node:child_process";
Expand Down Expand Up @@ -317,6 +331,8 @@ class Page {
}

const js = (value) => JSON.stringify(value);
// Windows virtual-key codes for the keys a `key` step may press.
const KEY_CODES = { Escape: 27, End: 35, Home: 36, ArrowLeft: 37, ArrowRight: 39 };
const asList = (value) => (Array.isArray(value) ? value : [value]);

// textContent rather than innerText: the app splits emails across elements,
Expand Down Expand Up @@ -417,6 +433,22 @@ async function runStep(page, step, ctx) {
return "";
})()`;
if (!(await page.poll(`${mismatch} === ""`))) throw new Error(await page.eval(mismatch));
} else if (step.expectNoCall !== undefined) {
// A call that would have been made lands during the same event turn; the
// delay gives an asynchronous one a chance to be recorded before checking.
await sleep(300);
const { cmd } = step.expectNoCall;
const made = await page.eval(
`(window.__CODEXBAR_PROOF__?.calls() ?? []).some((call) => call.cmd === ${js(cmd)})`,
);
if (made) throw new Error(`unexpected backend call: ${cmd}`);
} else if (step.key !== undefined) {
// Keys go to the focused element, as a real key press would.
const info = KEY_CODES[step.key];
if (!info) throw new Error(`unsupported key ${js(step.key)}`);
const params = { key: step.key, code: step.key, windowsVirtualKeyCode: info, nativeVirtualKeyCode: info };
await page.send("Input.dispatchKeyEvent", { type: "keyDown", ...params });
await page.send("Input.dispatchKeyEvent", { type: "keyUp", ...params });
} else if (step.wait !== undefined) {
await sleep(step.wait);
} else {
Expand Down
25 changes: 25 additions & 0 deletions apps/desktop-tauri/scripts/proof-scenarios.json
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,31 @@
{ "screenshot": "settings-menu-work-days-6" }
]
},
{
"name": "tray-openai-live-spend",
"query": "window=main&fixture=openai-live-spend",
"size": [310, 1032],
"steps": [
{ "expectText": ["OpenAI API", "Tokens per minute", "Live", "Spend"] },
{ "click": { "selector": "button", "label": "Live" } },
{ "expectSelector": ".openai-live-spend[data-mode=\"live\"] [data-testid=\"openai-spend-layer\"][aria-hidden=\"true\"]" },
{ "screenshot": "tray-openai-live", "clip": "panel" },
{ "click": { "selector": ".openai-tpm__chart", "label": "Tokens per minute" } },
{ "expectText": "requests" },
{ "expectSelector": "[data-testid=\"openai-live-layer\"] rect.openai-tpm__bar--selected" },
{ "screenshot": "tray-openai-live-inspect", "clip": "panel" },
{ "key": "Escape" },
{ "expectNoCall": { "cmd": "dismiss_tray_panel" } },
{ "expectSelector": ".tray-panel-reveal--ready" },
{ "click": { "selector": "button", "label": "Spend" } },
{ "expectText": "Daily spend · Last 30 days" },
{ "expectSelector": ".openai-live-spend[data-mode=\"spend\"] [data-testid=\"openai-live-layer\"][aria-hidden=\"true\"]" },
{ "click": { "selector": ".openai-tpm__chart", "label": "Daily spend · Last 30 days" } },
{ "expectText": ": $" },
{ "expectSelector": "[data-testid=\"openai-spend-layer\"] rect.openai-tpm__bar--selected" },
{ "screenshot": "tray-openai-spend-inspect", "clip": "panel" }
]
},
{
"name": "settings-claude-desktop-cookies",
"query": "window=settings&tab=providers:claude&fixture=default&theme=dark",
Expand Down
10 changes: 8 additions & 2 deletions apps/desktop-tauri/src/components/MenuCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ import DetailSections from "./card/DetailSections";
import InlineDashboard from "./card/InlineDashboard";
import ExtraUsageBlock from "./card/ExtraUsageBlock";
import WayfinderBlock from "./card/WayfinderBlock";
import { OpenAITokensPerMinuteChart } from "./OpenAITokensPerMinuteChart";
import { OpenAILiveSpendChart } from "./OpenAILiveSpendChart";
import { describeCard } from "./card/describeCard";
import { hideEmailsInText } from "../lib/personalInfo";

Expand Down Expand Up @@ -299,7 +299,13 @@ export default function MenuCard({
)}
{presence.wayfinderUsage && <WayfinderBlock usage={presence.wayfinderUsage} />}
{presence.openAiThroughput && (
<OpenAITokensPerMinuteChart throughput={presence.openAiThroughput} t={t} />
<OpenAILiveSpendChart
key={provider.providerId}
providerId={provider.providerId}
throughput={presence.openAiThroughput}
usage={provider.openAiApiUsage}
t={t}
/>
)}
{dashboard && <InlineDashboard model={dashboard} />}
{showCost && provider.cost && (
Expand Down
47 changes: 46 additions & 1 deletion apps/desktop-tauri/src/components/MenuCardOpenAIUsage.test.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { render, waitFor } from "@testing-library/react";
import { fireEvent, render, waitFor } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";

const tauriMocks = vi.hoisted(() => ({
Expand All @@ -20,6 +20,7 @@ vi.mock("../lib/tauri", async (importOriginal) => ({
vi.mock("@tauri-apps/api/event", () => eventMocks);

import { LocaleProvider } from "../i18n/LocaleProvider";
import { PROVIDER_CHART_MODES_STORAGE_KEY } from "../lib/openaiSpend";
import { buildBundle } from "../test/localeHarness";
import type { OpenAiApiUsageSnapshot, ProviderUsageSnapshot } from "../types/bridge";
import MenuCard from "./MenuCard";
Expand Down Expand Up @@ -138,6 +139,50 @@ describe("MenuCard OpenAI daily usage", () => {
expect(container.querySelector(".openai-tpm")).not.toBeNull();
});

it("offers the Live · Spend switch only when both charts have points", async () => {
window.localStorage.removeItem(PROVIDER_CHART_MODES_STORAGE_KEY);
const onlyLive = renderCard(withThroughput(snapshot("openaiapi", null)));
await waitFor(() => expect(onlyLive.container.querySelector(".openai-tpm")).not.toBeNull());
expect(onlyLive.container.querySelector(".openai-live-spend__switch")).toBeNull();
onlyLive.unmount();

const { container } = renderCard(withThroughput(snapshot("openaiapi", usage())));
await waitFor(() => expect(container.querySelector(".openai-live-spend__switch")).not.toBeNull());
// Plain toggle buttons: both stay tabbable and need no arrow-key wiring.
const switchGroup = container.querySelector(".openai-live-spend__switch");
expect(switchGroup).toHaveAttribute("role", "group");
const [live, spend] = Array.from(switchGroup!.querySelectorAll<HTMLButtonElement>("button"));
expect(live).toHaveAttribute("aria-pressed", "true");
expect(spend).toHaveAttribute("aria-pressed", "false");
expect(container.querySelector('[data-testid="openai-spend-layer"]')).toHaveAttribute("aria-hidden", "true");

fireEvent.click(spend);
expect(spend).toHaveAttribute("aria-pressed", "true");
expect(live).toHaveAttribute("aria-pressed", "false");
expect(container.querySelector('[data-testid="openai-live-layer"]')).toHaveAttribute("aria-hidden", "true");
expect(container.querySelector('[data-testid="openai-spend-layer"]')).toHaveAttribute("aria-hidden", "false");
expect(container).toHaveTextContent("OpenAiSpendChartTitle");
expect(JSON.parse(window.localStorage.getItem(PROVIDER_CHART_MODES_STORAGE_KEY) ?? "{}")).toEqual({
openaiapi: "spend",
});
window.localStorage.removeItem(PROVIDER_CHART_MODES_STORAGE_KEY);
});

it("keeps the plain grid when the Tokens/min window has no reported traffic", async () => {
window.localStorage.removeItem(PROVIDER_CHART_MODES_STORAGE_KEY);
const provider = withThroughput(snapshot("openaiapi", usage()));
const throughput = provider.throughput!;
provider.throughput = {
...throughput,
minutes: throughput.minutes.map((minute) => ({ ...minute, isPending: true })),
};
const { container } = renderCard(provider);
await waitFor(() => expect(container.querySelector(".openai-tpm")).not.toBeNull());
expect(container.querySelector(".openai-tpm__empty")).not.toBeNull();
expect(container.querySelector(".openai-live-spend")).toBeNull();
expect(container.querySelector(".openai-live-spend__switch")).toBeNull();
});

it("masks the Admin project id in the card identity when privacy is enabled", async () => {
const provider = snapshot("openaiapi", usage());
provider.planName = "Admin API: proj-private";
Expand Down
11 changes: 1 addition & 10 deletions apps/desktop-tauri/src/components/OpenAIApiUsageChart.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import type {
OpenAiApiDailyUsageSnapshot,
OpenAiApiUsageSnapshot,
} from "../types/bridge";
import { dayLabel, formatUsd } from "../lib/openaiSpend";
import { BarChart } from "./charts/BarChart";
import { providerCostColor } from "./charts/chartPalette";

Expand All @@ -19,22 +20,12 @@ type T = (key: LocaleKey) => string;
const METRICS: readonly Metric[] = ["cost", "tokens"];
const MAX_DETAIL_ROWS = 5;

const usdFormat = new Intl.NumberFormat("en-US", { style: "currency", currency: "USD" });
const compactFormat = new Intl.NumberFormat("en-US", {
notation: "compact",
maximumFractionDigits: 1,
});
const countFormat = new Intl.NumberFormat("en-US");

function formatUsd(value: number): string {
if (value > 0 && value < 0.01) return "<$0.01";
return usdFormat.format(value);
}

function dayLabel(day: OpenAiApiDailyUsageSnapshot): string {
return new Date(day.startTime * 1000).toISOString().slice(0, 10);
}

function metricValue(day: OpenAiApiDailyUsageSnapshot, metric: Metric): number {
return metric === "cost" ? day.costUsd : day.totalTokens;
}
Expand Down
113 changes: 113 additions & 0 deletions apps/desktop-tauri/src/components/OpenAILiveSpendChart.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
import { useId, useState } from "react";
import type { LocaleKey } from "../i18n/keys";
import { hasReportedTraffic } from "../lib/openaiThroughput";
import {
hasSpendDays,
type ProviderChartMode,
readChartMode,
spendDaySummary,
storeChartMode,
} from "../lib/openaiSpend";
import type { OpenAiApiUsageSnapshot, ProviderThroughputSnapshot } from "../types/bridge";
import { ScrubBarStrip } from "./charts/ScrubBarStrip";
import { OpenAITokensPerMinuteChart } from "./OpenAITokensPerMinuteChart";

/**
* OpenAI API card chart with the upstream 0.75.0 Live · Spend switch
* (`ProviderMenuChartContent`). The switch only appears when there is both a
* Tokens/min window and daily spend to show; otherwise the card keeps the plain
* Tokens/min grid. Both layers stay mounted in one grid cell so switching never
* changes the card height, and the choice persists per provider.
*/

type T = (key: LocaleKey) => string;

const MODES: readonly { id: ProviderChartMode; label: LocaleKey }[] = [
{ id: "live", label: "OpenAiChartModeLive" },
{ id: "spend", label: "OpenAiChartModeSpend" },
];

export interface OpenAILiveSpendChartProps {
providerId: string;
throughput: ProviderThroughputSnapshot;
usage: OpenAiApiUsageSnapshot | null | undefined;
t: T;
}

export function OpenAILiveSpendChart({ providerId, throughput, usage, t }: OpenAILiveSpendChartProps) {
const [mode, setModeState] = useState<ProviderChartMode>(() => readChartMode(providerId));
// The switch needs both charts to have something to show: reported Tokens/min
// traffic and daily spend. Otherwise the card keeps the plain grid.
const spendUsage = usage && hasSpendDays(usage) && hasReportedTraffic(throughput) ? usage : null;
if (!spendUsage) return <OpenAITokensPerMinuteChart throughput={throughput} t={t} />;

const setMode = (next: ProviderChartMode) => {
setModeState(next);
storeChartMode(providerId, next);
};

return (
<div className="openai-live-spend" data-mode={mode}>
<div
className="provider-detail-segmented openai-live-spend__switch"
role="group"
aria-label={t("OpenAiChartModeLabel")}
>
{MODES.map((option) => (
<button
key={option.id}
type="button"
aria-pressed={option.id === mode}
className={`provider-detail-segmented__option${option.id === mode ? " is-active" : ""}`}
onClick={() => setMode(option.id)}
>
{t(option.label)}
</button>
))}
</div>
<div className="openai-live-spend__stack">
<div
className={`openai-live-spend__layer${mode === "live" ? "" : " is-hidden"}`}
aria-hidden={mode !== "live"}
data-testid="openai-live-layer"
>
<OpenAITokensPerMinuteChart throughput={throughput} t={t} />
</div>
<div
className={`openai-live-spend__layer${mode === "spend" ? "" : " is-hidden"}`}
aria-hidden={mode !== "spend"}
data-testid="openai-spend-layer"
>
<OpenAISpendChart usage={spendUsage} t={t} />
</div>
</div>
</div>
);
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

function OpenAISpendChart({ usage, t }: { usage: OpenAiApiUsageSnapshot; t: T }) {
const summaryId = useId();
const [selectedId, setSelectedId] = useState<string | null>(null);
const bars = usage.daily.map((day) => ({ id: String(day.startTime), value: day.costUsd }));
const selectedDay = usage.daily.find((day) => String(day.startTime) === selectedId) ?? null;
const title = t("OpenAiSpendChartTitle").replace("{}", String(usage.historyDays));

return (
<section className="openai-tpm openai-spend" aria-label={title}>
<div className="openai-tpm__title">{title}</div>
<ScrubBarStrip
bars={bars}
selectedId={selectedId}
onSelect={setSelectedId}
classPrefix="openai-tpm"
className="openai-tpm__chart"
label={title}
describedBy={summaryId}
testId="openai-spend-chart"
/>
<p id={summaryId} className="openai-tpm__summary" aria-live="polite">
{selectedDay ? spendDaySummary(selectedDay, t) : "\u00a0"}
</p>
</section>
);
}
Loading