diff --git a/CHANGELOG.md b/CHANGELOG.md index e77fe7b5d4..52458f24a9 100755 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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). diff --git a/apps/desktop-tauri/scripts/proof-check.mjs b/apps/desktop-tauri/scripts/proof-check.mjs index 4462d4fbd5..2b4eb9e5ef 100644 --- a/apps/desktop-tauri/scripts/proof-check.mjs +++ b/apps/desktop-tauri/scripts/proof-check.mjs @@ -12,6 +12,20 @@ // pnpm proof:check -- --browser browser binary (or env PROOF_BROWSER) // pnpm proof:check -- --out 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 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"; @@ -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, @@ -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 { diff --git a/apps/desktop-tauri/scripts/proof-scenarios.json b/apps/desktop-tauri/scripts/proof-scenarios.json index 3212f4a12c..616af28aa0 100644 --- a/apps/desktop-tauri/scripts/proof-scenarios.json +++ b/apps/desktop-tauri/scripts/proof-scenarios.json @@ -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", diff --git a/apps/desktop-tauri/src/components/MenuCard.tsx b/apps/desktop-tauri/src/components/MenuCard.tsx index d9953e6043..41a16c95be 100644 --- a/apps/desktop-tauri/src/components/MenuCard.tsx +++ b/apps/desktop-tauri/src/components/MenuCard.tsx @@ -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"; @@ -299,7 +299,13 @@ export default function MenuCard({ )} {presence.wayfinderUsage && } {presence.openAiThroughput && ( - + )} {dashboard && } {showCost && provider.cost && ( diff --git a/apps/desktop-tauri/src/components/MenuCardOpenAIUsage.test.tsx b/apps/desktop-tauri/src/components/MenuCardOpenAIUsage.test.tsx index 2aa6cc58b3..bd427bc881 100644 --- a/apps/desktop-tauri/src/components/MenuCardOpenAIUsage.test.tsx +++ b/apps/desktop-tauri/src/components/MenuCardOpenAIUsage.test.tsx @@ -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(() => ({ @@ -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"; @@ -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("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"; diff --git a/apps/desktop-tauri/src/components/OpenAIApiUsageChart.tsx b/apps/desktop-tauri/src/components/OpenAIApiUsageChart.tsx index a24c640c0a..9bdebbe771 100644 --- a/apps/desktop-tauri/src/components/OpenAIApiUsageChart.tsx +++ b/apps/desktop-tauri/src/components/OpenAIApiUsageChart.tsx @@ -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"; @@ -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; } diff --git a/apps/desktop-tauri/src/components/OpenAILiveSpendChart.tsx b/apps/desktop-tauri/src/components/OpenAILiveSpendChart.tsx new file mode 100644 index 0000000000..d1020d2a87 --- /dev/null +++ b/apps/desktop-tauri/src/components/OpenAILiveSpendChart.tsx @@ -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(() => 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 ; + + const setMode = (next: ProviderChartMode) => { + setModeState(next); + storeChartMode(providerId, next); + }; + + return ( +
+
+ {MODES.map((option) => ( + + ))} +
+
+
+ +
+
+ +
+
+
+ ); +} + +function OpenAISpendChart({ usage, t }: { usage: OpenAiApiUsageSnapshot; t: T }) { + const summaryId = useId(); + const [selectedId, setSelectedId] = useState(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 ( +
+
{title}
+ +

+ {selectedDay ? spendDaySummary(selectedDay, t) : "\u00a0"} +

+
+ ); +} diff --git a/apps/desktop-tauri/src/components/OpenAITokensPerMinuteChart.test.tsx b/apps/desktop-tauri/src/components/OpenAITokensPerMinuteChart.test.tsx index babc247e10..b1d08b9472 100644 --- a/apps/desktop-tauri/src/components/OpenAITokensPerMinuteChart.test.tsx +++ b/apps/desktop-tauri/src/components/OpenAITokensPerMinuteChart.test.tsx @@ -1,7 +1,7 @@ -import { render } from "@testing-library/react"; +import { fireEvent, render } from "@testing-library/react"; import { describe, expect, it } from "vitest"; import type { LocaleKey } from "../i18n/keys"; -import { formatTokens } from "../lib/openaiThroughput"; +import { formatClock, formatTokens } from "../lib/openaiThroughput"; import type { ModelRateLimitSnapshot, ProviderThroughputSnapshot, @@ -12,6 +12,18 @@ import { OpenAITokensPerMinuteChart } from "./OpenAITokensPerMinuteChart"; const START = 1_788_220_800; const t = (key: LocaleKey) => key as string; +/** jsdom has no PointerEvent, so fireEvent.pointer* drops clientX; React only reads the fields. */ +function pointer(target: Element, type: string, clientX: number) { + fireEvent(target, new MouseEvent(type, { bubbles: true, clientX })); +} + +/** jsdom has no PointerEvent, so pointerType is patched onto a MouseEvent. */ +function pointerUp(target: Element, clientX: number, clientY: number) { + const event = new MouseEvent("pointerup", { bubbles: true, clientX, clientY }); + Object.defineProperty(event, "pointerType", { value: "mouse" }); + fireEvent(target, event); +} + function model(name: string, overrides: Partial = {}): ThroughputModelSnapshot { return { name, @@ -96,6 +108,92 @@ describe("OpenAITokensPerMinuteChart", () => { expect(unmatched.container.querySelector(".openai-tpm__limit")).toBeNull(); }); + it("inspects the minute under the pointer and clears on leave", () => { + const { container, getByTestId } = render( + , + ); + const chart = getByTestId("openai-tpm-chart"); + chart.getBoundingClientRect = () => + ({ left: 0, top: 0, width: 100, height: 56, right: 100, bottom: 56, x: 0, y: 0 }) as DOMRect; + const summary = () => container.querySelector(".openai-tpm__summary")?.textContent; + + pointer(chart, "pointermove", 30); + expect(summary()).toBe( + `${formatClock(START + 60)} · OpenAiTpmHoverMinute`, + ); + expect(container.querySelectorAll("rect.openai-tpm__bar--selected")).toHaveLength(1); + + pointer(chart, "pointermove", 95); + expect(summary()).toBe(`${formatClock(START + 240)} · OpenAiTpmReporting`); + + fireEvent.pointerLeave(chart); + expect(summary()).toBe("\u00a0"); + expect(container.querySelector("rect.openai-tpm__bar--selected")).toBeNull(); + }); + + it("keeps a drag selection when the pointer leaves the strip", () => { + const { container, getByTestId } = render( + , + ); + const chart = getByTestId("openai-tpm-chart"); + chart.getBoundingClientRect = () => + ({ left: 0, top: 0, width: 100, height: 56, right: 100, bottom: 56, x: 0, y: 0 }) as DOMRect; + + pointer(chart, "pointerdown", 5); + pointer(chart, "pointermove", 150); + fireEvent.pointerLeave(chart); + expect(container.querySelector(".openai-tpm__summary")?.textContent).toBe( + `${formatClock(START + 240)} · OpenAiTpmReporting`, + ); + }); + + it("clears the selection when a mouse drag ends outside the strip", () => { + const { container, getByTestId } = render( + , + ); + const chart = getByTestId("openai-tpm-chart"); + chart.getBoundingClientRect = () => + ({ left: 0, top: 0, width: 100, height: 56, right: 100, bottom: 56, x: 0, y: 0 }) as DOMRect; + const summary = () => container.querySelector(".openai-tpm__summary")?.textContent; + + // A drag released inside keeps the hovered minute. + pointer(chart, "pointerdown", 30); + pointerUp(chart, 30, 10); + expect(summary()).toBe(`${formatClock(START + 60)} · OpenAiTpmHoverMinute`); + + // Released past the right edge, nothing is hovered anymore. + pointerUp(chart, 150, 10); + expect(summary()).toBe("\u00a0"); + expect(container.querySelector("rect.openai-tpm__bar--selected")).toBeNull(); + }); + + it("steps through minutes with the arrow keys", () => { + const { container, getByTestId } = render( + , + ); + const chart = getByTestId("openai-tpm-chart"); + const summary = () => container.querySelector(".openai-tpm__summary")?.textContent; + fireEvent.keyDown(chart, { key: "ArrowRight" }); + expect(summary()).toBe(`${formatClock(START)} · OpenAiTpmHoverMinute`); + fireEvent.keyDown(chart, { key: "End" }); + expect(summary()).toBe(`${formatClock(START + 240)} · OpenAiTpmReporting`); + fireEvent.keyDown(chart, { key: "Escape" }); + expect(summary()).toBe("\u00a0"); + }); + + it("drops the selection when its minute leaves the window", () => { + const { container, getByTestId, rerender } = render( + , + ); + fireEvent.keyDown(getByTestId("openai-tpm-chart"), { key: "Home" }); + expect(container.querySelector("rect.openai-tpm__bar--selected")).not.toBeNull(); + const shifted = throughput(); + shifted.minutes = shifted.minutes.slice(1); + rerender(); + expect(container.querySelector("rect.openai-tpm__bar--selected")).toBeNull(); + expect(container.querySelector(".openai-tpm__summary")?.textContent).toBe("\u00a0"); + }); + it("lists per-model rows only when showModels is set", () => { const busy = throughput({ models: [model("gpt-5", { latestTpm: 900, peakTpm: 4_000 })] }); const card = render(); diff --git a/apps/desktop-tauri/src/components/OpenAITokensPerMinuteChart.tsx b/apps/desktop-tauri/src/components/OpenAITokensPerMinuteChart.tsx index 0c04777d52..ce6bcad04b 100644 --- a/apps/desktop-tauri/src/components/OpenAITokensPerMinuteChart.tsx +++ b/apps/desktop-tauri/src/components/OpenAITokensPerMinuteChart.tsx @@ -1,11 +1,14 @@ +import { useId, useState } from "react"; import type { LocaleKey } from "../i18n/keys"; import { formatClock, formatTokens, hasReportedTraffic, throughputLimitLine, + tpmBarSummary, tpmBars, } from "../lib/openaiThroughput"; +import { ScrubBarStrip } from "./charts/ScrubBarStrip"; import type { ModelRateLimitSnapshot, ProviderThroughputSnapshot, @@ -14,7 +17,9 @@ import type { /** * Last-hour Tokens/min grid for the OpenAI API (upstream 0.75.0 #4495). Reported * minutes are solid bars; minutes after the reporting frontier are drawn as - * pending outlines and never count as zero or as a peak. + * pending outlines and never count as zero or as a peak. Hovering, dragging or + * arrowing across the grid inspects one minute in the summary line, which keeps + * its height while empty so the card does not jump. */ type T = (key: LocaleKey) => string; @@ -36,9 +41,16 @@ export function OpenAITokensPerMinuteChart({ limits = NO_LIMITS, showModels = false, }: OpenAITokensPerMinuteChartProps) { + const summaryId = useId(); + const [selectedId, setSelectedId] = useState(null); const bars = tpmBars(throughput); + const stripBars = bars.map((bar) => ({ + id: String(bar.start), + value: bar.tokens, + isPending: bar.isPending, + })); + const selectedBar = bars.find((bar) => String(bar.start) === selectedId) ?? null; const reported = hasReportedTraffic(throughput); - const maxTokens = Math.max(1, ...bars.filter((bar) => !bar.isPending).map((bar) => bar.tokens)); const hasPending = bars.some((bar) => bar.isPending); const limit = throughputLimitLine(throughput, limits); @@ -76,36 +88,21 @@ export function OpenAITokensPerMinuteChart({ {reported && bars.length > 0 ? ( - - {bars.map((bar, index) => - bar.isPending ? ( - - ) : ( - - ), - )} - + <> + +

+ {selectedBar ? tpmBarSummary(selectedBar, t) : "\u00a0"} +

+ ) : (

{t("OpenAiTpmEmpty")}

)} diff --git a/apps/desktop-tauri/src/components/charts/ScrubBarStrip.test.tsx b/apps/desktop-tauri/src/components/charts/ScrubBarStrip.test.tsx new file mode 100644 index 0000000000..442688c8f4 --- /dev/null +++ b/apps/desktop-tauri/src/components/charts/ScrubBarStrip.test.tsx @@ -0,0 +1,58 @@ +import { cleanup, fireEvent, render } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { ScrubBarStrip, type ScrubBar } from "./ScrubBarStrip"; + +const bars: ScrubBar[] = [ + { id: "a", value: 1 }, + { id: "b", value: 2 }, +]; + +function renderStrip(selectedId: string | null, onSelect: (id: string | null) => void) { + return render( + , + ); +} + +describe("ScrubBarStrip Escape key", () => { + afterEach(() => { + cleanup(); + }); + + it("clears the selection without letting Escape reach window listeners", () => { + const onSelect = vi.fn(); + const windowListener = vi.fn(); + window.addEventListener("keydown", windowListener); + try { + const { container } = renderStrip("a", onSelect); + fireEvent.keyDown(container.querySelector("svg")!, { key: "Escape" }); + + expect(onSelect).toHaveBeenCalledTimes(1); + expect(onSelect).toHaveBeenCalledWith(null); + expect(windowListener).not.toHaveBeenCalled(); + } finally { + window.removeEventListener("keydown", windowListener); + } + }); + + it("lets Escape reach window listeners when no bar is selected", () => { + const onSelect = vi.fn(); + const windowListener = vi.fn(); + window.addEventListener("keydown", windowListener); + try { + const { container } = renderStrip(null, onSelect); + fireEvent.keyDown(container.querySelector("svg")!, { key: "Escape" }); + + expect(onSelect).not.toHaveBeenCalled(); + expect(windowListener).toHaveBeenCalledTimes(1); + } finally { + window.removeEventListener("keydown", windowListener); + } + }); +}); diff --git a/apps/desktop-tauri/src/components/charts/ScrubBarStrip.tsx b/apps/desktop-tauri/src/components/charts/ScrubBarStrip.tsx new file mode 100644 index 0000000000..27d810af18 --- /dev/null +++ b/apps/desktop-tauri/src/components/charts/ScrubBarStrip.tsx @@ -0,0 +1,162 @@ +import { type KeyboardEvent, type PointerEvent, useEffect, useRef } from "react"; + +/** + * Bar strip the pointer can hover or drag across to inspect one bar (upstream + * 0.75.0 `MenuUsageBarStrip`). The parent owns the selection so it can swap + * its summary line for the selected bar's summary; ids keep the selection on + * the same bar across refreshes and drop it when that bar leaves the window. + */ + +export interface ScrubBar { + id: string; + value: number; + /** Not reported yet: a full-height outline that never sets the scale. */ + isPending?: boolean; +} + +export interface ScrubBarStripProps { + bars: readonly ScrubBar[]; + selectedId: string | null; + onSelect: (id: string | null) => void; + /** BEM block for `__bar`, `__bar--pending` and `__bar--selected`. */ + classPrefix: string; + className: string; + label: string; + /** Id of the summary line that reads out the selected bar. */ + describedBy?: string; + testId?: string; +} + +export function ScrubBarStrip({ + bars, + selectedId, + onSelect, + classPrefix, + className, + label, + describedBy, + testId, +}: ScrubBarStripProps) { + const dragging = useRef(false); + const max = Math.max(1, ...bars.filter((bar) => !bar.isPending).map((bar) => bar.value)); + const selectedIndex = bars.findIndex((bar) => bar.id === selectedId); + + useEffect(() => { + if (selectedId !== null && selectedIndex < 0) onSelect(null); + }, [selectedId, selectedIndex, onSelect]); + + const indexAt = (event: PointerEvent): number | null => { + const rect = event.currentTarget.getBoundingClientRect(); + if (rect.width <= 0 || bars.length === 0) return null; + const ratio = (event.clientX - rect.left) / rect.width; + if (!Number.isFinite(ratio)) return null; + return Math.min(bars.length - 1, Math.max(0, Math.floor(ratio * bars.length))); + }; + + const selectAt = (event: PointerEvent) => { + const index = indexAt(event); + if (index !== null) onSelect(bars[index].id); + }; + + const onPointerDown = (event: PointerEvent) => { + dragging.current = true; + // Capture keeps a drag scrubbing after the pointer slips off the strip. + event.currentTarget.setPointerCapture?.(event.pointerId); + selectAt(event); + }; + + const endDrag = (event: PointerEvent) => { + dragging.current = false; + if (event.currentTarget.hasPointerCapture?.(event.pointerId)) { + event.currentTarget.releasePointerCapture(event.pointerId); + } + }; + + const onPointerUp = (event: PointerEvent) => { + endDrag(event); + // Touch and pen have no hover to fall back to once the finger lifts. + if (event.pointerType !== "mouse") { + onSelect(null); + return; + } + // Capture keeps pointerup on the strip even when the drag ended outside + // it; a mouse released out there is not hovering any bar anymore. A + // zero-size rect (no layout yet) says nothing, so it keeps the selection. + const rect = event.currentTarget.getBoundingClientRect(); + if ( + rect.width > 0 && + (event.clientX < rect.left || + event.clientX > rect.right || + event.clientY < rect.top || + event.clientY > rect.bottom) + ) { + onSelect(null); + } + }; + + const onPointerLeave = () => { + if (!dragging.current) onSelect(null); + }; + + const onPointerCancel = (event: PointerEvent) => { + endDrag(event); + onSelect(null); + }; + + const onKeyDown = (event: KeyboardEvent) => { + if (bars.length === 0) return; + let next: number | null = null; + if (event.key === "ArrowLeft") next = selectedIndex < 0 ? bars.length - 1 : Math.max(0, selectedIndex - 1); + else if (event.key === "ArrowRight") next = selectedIndex < 0 ? 0 : Math.min(bars.length - 1, selectedIndex + 1); + else if (event.key === "Home") next = 0; + else if (event.key === "End") next = bars.length - 1; + else if (event.key === "Escape" && selectedIndex >= 0) { + event.preventDefault(); + // Only clear the inspected bar; with nothing selected Escape still + // reaches the window listener that dismisses the tray panel. + event.stopPropagation(); + onSelect(null); + return; + } + if (next === null) return; + event.preventDefault(); + onSelect(bars[next].id); + }; + + return ( + onSelect(null)} + > + {bars.map((bar, index) => { + const classes = [`${classPrefix}__bar`]; + if (bar.isPending) classes.push(`${classPrefix}__bar--pending`); + if (index === selectedIndex) classes.push(`${classPrefix}__bar--selected`); + const height = bar.isPending ? 100 : (bar.value / max) * 100; + return ( + + ); + })} + + ); +} diff --git a/apps/desktop-tauri/src/i18n/keyGroups/core.ts b/apps/desktop-tauri/src/i18n/keyGroups/core.ts index 4d153aec4d..ee8e37fd3e 100644 --- a/apps/desktop-tauri/src/i18n/keyGroups/core.ts +++ b/apps/desktop-tauri/src/i18n/keyGroups/core.ts @@ -911,6 +911,13 @@ export const CORE_LOCALE_KEYS = [ "OpenAiTpmEmpty", "OpenAiTpmModels", "OpenAiTpmLimitLine", + "OpenAiChartModeLabel", + "OpenAiChartModeLive", + "OpenAiChartModeSpend", + "OpenAiTpmHoverMinute", + "OpenAiTpmReporting", + "OpenAiSpendChartTitle", + "OpenAiSpendHoverDay", "OpenAiLimitsTitle", "OpenAiLimitsHelp", "OpenAiLimitModelLabel", diff --git a/apps/desktop-tauri/src/lib/openaiSpend.test.ts b/apps/desktop-tauri/src/lib/openaiSpend.test.ts new file mode 100644 index 0000000000..f81e5eb135 --- /dev/null +++ b/apps/desktop-tauri/src/lib/openaiSpend.test.ts @@ -0,0 +1,73 @@ +import { afterEach, describe, expect, it } from "vitest"; +import type { OpenAiApiDailyUsageSnapshot } from "../types/bridge"; +import { + formatUsd, + hasSpendDays, + PROVIDER_CHART_MODES_STORAGE_KEY, + readChartMode, + spendDaySummary, + storeChartMode, +} from "./openaiSpend"; + +const START = 1_788_220_800; + +function day(overrides: Partial = {}): OpenAiApiDailyUsageSnapshot { + return { + startTime: START, + endTime: START + 86_400, + costUsd: 12.5, + requests: 10, + inputTokens: 1000, + cachedInputTokens: 0, + outputTokens: 400, + totalTokens: 1_400, + lineItems: [], + models: [], + ...overrides, + }; +} + +const t = (key: string) => (key === "OpenAiSpendHoverDay" ? "{}: {} · {} tokens" : key); + +describe("spend helpers", () => { + it("formats sub-cent spend without rounding it to zero", () => { + expect(formatUsd(0.004)).toBe("<$0.01"); + expect(formatUsd(0)).toBe("$0.00"); + expect(formatUsd(12.5)).toBe("$12.50"); + }); + + it("summarizes a UTC day as date, cost and tokens", () => { + expect(spendDaySummary(day(), t)).toBe("2026-09-01: $12.50 · 1.4K tokens"); + }); + + it("needs at least one day of history to offer Spend", () => { + expect(hasSpendDays(null)).toBe(false); + expect(hasSpendDays({ historyDays: 30, projectId: null, daily: [] })).toBe(false); + expect(hasSpendDays({ historyDays: 30, projectId: null, daily: [day()] })).toBe(true); + }); +}); + +describe("chart mode persistence", () => { + afterEach(() => window.localStorage.removeItem(PROVIDER_CHART_MODES_STORAGE_KEY)); + + it("defaults to Live and remembers the choice per provider", () => { + expect(readChartMode("openaiapi")).toBe("live"); + storeChartMode("openaiapi", "spend"); + expect(readChartMode("openaiapi")).toBe("spend"); + expect(readChartMode("other")).toBe("live"); + storeChartMode("other", "live"); + expect(JSON.parse(window.localStorage.getItem(PROVIDER_CHART_MODES_STORAGE_KEY) ?? "")).toEqual({ + openaiapi: "spend", + other: "live", + }); + }); + + it("falls back to Live for unknown or malformed stored values", () => { + window.localStorage.setItem(PROVIDER_CHART_MODES_STORAGE_KEY, JSON.stringify({ openaiapi: "weekly" })); + expect(readChartMode("openaiapi")).toBe("live"); + window.localStorage.setItem(PROVIDER_CHART_MODES_STORAGE_KEY, "not json"); + expect(readChartMode("openaiapi")).toBe("live"); + window.localStorage.setItem(PROVIDER_CHART_MODES_STORAGE_KEY, "[]"); + expect(readChartMode("openaiapi")).toBe("live"); + }); +}); diff --git a/apps/desktop-tauri/src/lib/openaiSpend.ts b/apps/desktop-tauri/src/lib/openaiSpend.ts new file mode 100644 index 0000000000..26e2b090df --- /dev/null +++ b/apps/desktop-tauri/src/lib/openaiSpend.ts @@ -0,0 +1,61 @@ +import type { LocaleKey } from "../i18n/keys"; +import type { OpenAiApiDailyUsageSnapshot, OpenAiApiUsageSnapshot } from "../types/bridge"; +import { formatTokens } from "./openaiThroughput"; + +const usdFormat = new Intl.NumberFormat("en-US", { style: "currency", currency: "USD" }); + +/** USD amount; sub-cent spend reads as "<$0.01" rather than "$0.00". */ +export function formatUsd(value: number): string { + if (value > 0 && value < 0.01) return "<$0.01"; + return usdFormat.format(value); +} + +/** The Admin API reports UTC days, so the label stays on the UTC calendar. */ +export function dayLabel(day: OpenAiApiDailyUsageSnapshot): string { + return new Date(day.startTime * 1000).toISOString().slice(0, 10); +} + +/** True when the OpenAI API usage history has at least one day to chart. */ +export function hasSpendDays(usage: OpenAiApiUsageSnapshot | null | undefined): boolean { + return (usage?.daily.length ?? 0) > 0; +} + +/** Hover line for one day, upstream `InlineUsageDashboardModel.HoverDetail.summary`. */ +export function spendDaySummary(day: OpenAiApiDailyUsageSnapshot, t: (key: LocaleKey) => string): string { + return t("OpenAiSpendHoverDay") + .replace("{}", dayLabel(day)) + .replace("{}", formatUsd(day.costUsd)) + .replace("{}", formatTokens(day.totalTokens)); +} + +export type ProviderChartMode = "live" | "spend"; + +export const PROVIDER_CHART_MODES_STORAGE_KEY = "codexbar.providerMenuChartModes"; + +function readModes(): Record { + try { + const raw = window.localStorage.getItem(PROVIDER_CHART_MODES_STORAGE_KEY); + if (!raw) return {}; + const parsed: unknown = JSON.parse(raw); + return parsed && typeof parsed === "object" && !Array.isArray(parsed) + ? (parsed as Record) + : {}; + } catch { + return {}; + } +} + +/** The stored Live / Spend choice for a provider; anything unknown is Live. */ +export function readChartMode(providerId: string): ProviderChartMode { + return readModes()[providerId] === "spend" ? "spend" : "live"; +} + +export function storeChartMode(providerId: string, mode: ProviderChartMode): void { + try { + const modes = readModes(); + modes[providerId] = mode; + window.localStorage.setItem(PROVIDER_CHART_MODES_STORAGE_KEY, JSON.stringify(modes)); + } catch { + // Not persisting the mode is harmless; the card falls back to Live. + } +} diff --git a/apps/desktop-tauri/src/lib/openaiThroughput.ts b/apps/desktop-tauri/src/lib/openaiThroughput.ts index 140c7c3360..f416079c9d 100644 --- a/apps/desktop-tauri/src/lib/openaiThroughput.ts +++ b/apps/desktop-tauri/src/lib/openaiThroughput.ts @@ -21,6 +21,7 @@ export interface TpmBar { /** Minute start, epoch seconds. */ start: number; tokens: number; + requests: number; /** Not reported yet: drawn as pending, never as a zero or a peak. */ isPending: boolean; } @@ -30,10 +31,21 @@ export function tpmBars(snapshot: ProviderThroughputSnapshot): TpmBar[] { return snapshot.minutes.map((minute) => ({ start: minute.start, tokens: minute.totalTokens, + requests: minute.requests, isPending: minute.isPending, })); } +/** Hover line for one minute, upstream `ProviderThroughputSection.points`. */ +export function tpmBarSummary(bar: TpmBar, t: (key: LocaleKey) => string): string { + const time = formatClock(bar.start); + if (bar.isPending) return `${time} · ${t("OpenAiTpmReporting")}`; + const detail = t("OpenAiTpmHoverMinute") + .replace("{}", formatTokens(bar.tokens)) + .replace("{}", formatTokens(bar.requests)); + return `${time} · ${detail}`; +} + /** True when at least one reported (non-pending) minute carries tokens. */ export function hasReportedTraffic(snapshot: ProviderThroughputSnapshot): boolean { return snapshot.minutes.some((minute) => !minute.isPending && minute.totalTokens > 0); diff --git a/apps/desktop-tauri/src/proof/fixtures/scenarios.ts b/apps/desktop-tauri/src/proof/fixtures/scenarios.ts index fb4ca464e4..5234af4679 100644 --- a/apps/desktop-tauri/src/proof/fixtures/scenarios.ts +++ b/apps/desktop-tauri/src/proof/fixtures/scenarios.ts @@ -22,6 +22,7 @@ export const PROOF_SCENARIO_NAMES = [ "two-codex-accounts", "errors", "empty", + "openai-live-spend", "claude-plan-hidden", "claude-reset-credits", "claude-reset-credits-hidden", @@ -359,6 +360,88 @@ function emptyScenario(): ProofScenario { }; } +const MINUTE_SECONDS = 60; +const DAY_SECONDS = 86_400; + +/** + * OpenAI API card with both a Tokens/min window and daily spend, so the card + * offers the Live · Spend switch. The last two minutes are still reporting. + */ +function openAiApiSnapshot(now: Date): ProviderUsageSnapshot { + const nowSeconds = Math.floor(now.getTime() / 1000); + const firstMinute = Math.floor(nowSeconds / MINUTE_SECONDS) * MINUTE_SECONDS - 59 * MINUTE_SECONDS; + const minutes = Array.from({ length: 60 }, (_, i) => { + const isPending = i >= 58; + const totalTokens = isPending ? 0 : 6_000 + ((i * 7_919) % 18_000); + return { + start: firstMinute + i * MINUTE_SECONDS, + totalTokens, + requests: isPending ? 0 : 2 + (i % 5), + isPending, + }; + }); + const reported = minutes.filter((minute) => !minute.isPending); + const peak = reported.reduce((best, minute) => (minute.totalTokens > best.totalTokens ? minute : best)); + const lastFive = reported.slice(-5); + const firstDay = Math.floor(nowSeconds / DAY_SECONDS) * DAY_SECONDS - 29 * DAY_SECONDS; + const daily = Array.from({ length: 30 }, (_, i) => { + const costUsd = Math.round((1.5 + ((i * 37) % 11) * 0.45) * 100) / 100; + const totalTokens = Math.round(costUsd * 190_000); + return { + startTime: firstDay + i * DAY_SECONDS, + endTime: firstDay + (i + 1) * DAY_SECONDS, + costUsd, + requests: 120 + i, + inputTokens: Math.round(totalTokens * 0.7), + cachedInputTokens: Math.round(totalTokens * 0.1), + outputTokens: Math.round(totalTokens * 0.3), + totalTokens, + lineItems: [{ name: "Text tokens", costUsd }], + models: [], + }; + }); + return providerSnapshot( + { + providerId: "openaiapi", + displayName: "OpenAI API", + primary: rateWindow(now, 0, null, null), + primaryLabel: "Spend", + updatedAt: isoBefore(now, 1), + }, + { + planName: "Admin API", + sourceLabel: "api", + throughput: { + minutes, + frontier: reported[reported.length - 1].start, + latestTokensPerMinute: reported[reported.length - 1].totalTokens, + peakTokensPerMinute: peak.totalTokens, + peakAt: peak.start, + fiveMinuteAverageTokensPerMinute: Math.round( + lastFive.reduce((sum, minute) => sum + minute.totalTokens, 0) / lastFive.length, + ), + models: [], + windowMinutes: 60, + fetchedAt: nowSeconds, + projectId: null, + }, + openAiApiUsage: { historyDays: 30, projectId: null, daily }, + }, + ); +} + +function openAiLiveSpendScenario(now: Date): ProofScenario { + return { + name: "openai-live-spend", + settings: baseSettings({ enabledProviders: ["openaiapi"] }), + catalog: PROOF_PROVIDER_CATALOG, + providers: [openAiApiSnapshot(now)], + codexAccounts: emptyCodexAccounts(), + claudeAccounts: [], + localSpend: [], + }; +} + /** Default data with the Claude "Plan renewal / expiry" usage item hidden (`section:subscription`). */ function claudePlanHiddenScenario(now: Date): ProofScenario { const base = defaultScenario(now); @@ -407,6 +490,7 @@ const BUILDERS: Record ProofScenario> = { "two-codex-accounts": twoCodexAccountsScenario, errors: errorsScenario, empty: emptyScenario, + "openai-live-spend": openAiLiveSpendScenario, "claude-plan-hidden": claudePlanHiddenScenario, "claude-reset-credits": (now) => claudeResetCreditsScenario(now, false), "claude-reset-credits-hidden": (now) => claudeResetCreditsScenario(now, true), diff --git a/apps/desktop-tauri/src/styles/provider-details.css b/apps/desktop-tauri/src/styles/provider-details.css index a59d74210a..0b6a57fe67 100644 --- a/apps/desktop-tauri/src/styles/provider-details.css +++ b/apps/desktop-tauri/src/styles/provider-details.css @@ -705,6 +705,66 @@ opacity: 0.6; } +.openai-tpm__chart { + cursor: crosshair; + touch-action: none; + outline: none; +} + +.openai-tpm__chart:focus-visible { + outline: 1px solid var(--provider-accent, #6aa9ff); + outline-offset: 2px; +} + +.openai-tpm__bar--selected { + stroke: var(--text-primary); + stroke-width: 1.5; + stroke-dasharray: none; + vector-effect: non-scaling-stroke; + opacity: 1; +} + +.openai-tpm__summary { + margin: 0; + min-height: 1.4em; + font-size: 11px; + color: var(--text-secondary); + font-variant-numeric: tabular-nums; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +/* Live · Spend switch: both layers share one cell so the card keeps its height. */ +.openai-live-spend { + display: flex; + flex-direction: column; + gap: 4px; + margin-top: 6px; +} + +.openai-live-spend__switch { + align-self: flex-start; +} + +.openai-live-spend__stack { + display: grid; +} + +.openai-live-spend__layer { + grid-area: 1 / 1; + min-width: 0; +} + +.openai-live-spend__layer > .openai-tpm { + margin-top: 0; +} + +.openai-live-spend__layer.is-hidden { + visibility: hidden; + pointer-events: none; +} + .openai-tpm__legend { display: flex; align-items: center; diff --git a/rust/src/locale.rs b/rust/src/locale.rs index 151d0f07cf..32cfc74458 100644 --- a/rust/src/locale.rs +++ b/rust/src/locale.rs @@ -1194,6 +1194,13 @@ locale_keys! { OpenAiTpmEmpty, OpenAiTpmModels, OpenAiTpmLimitLine, + OpenAiChartModeLabel, + OpenAiChartModeLive, + OpenAiChartModeSpend, + OpenAiTpmHoverMinute, + OpenAiTpmReporting, + OpenAiSpendChartTitle, + OpenAiSpendHoverDay, OpenAiLimitsTitle, OpenAiLimitsHelp, OpenAiLimitModelLabel, diff --git a/rust/src/locale/en-US.ftl b/rust/src/locale/en-US.ftl index 6dd122774c..98fc57898e 100644 --- a/rust/src/locale/en-US.ftl +++ b/rust/src/locale/en-US.ftl @@ -1713,6 +1713,13 @@ OpenAiTpmPendingLegend = Pending: not reported yet OpenAiTpmEmpty = No traffic reported in this window yet. OpenAiTpmModels = Models OpenAiTpmLimitLine = { "{}" } peaked at { "{}" }% of its { "{}" } tokens-per-minute limit +OpenAiChartModeLabel = Chart +OpenAiChartModeLive = Live +OpenAiChartModeSpend = Spend +OpenAiTpmHoverMinute = { "{}" } tokens · { "{}" } requests +OpenAiTpmReporting = reporting… +OpenAiSpendChartTitle = Daily spend · Last { "{}" } days +OpenAiSpendHoverDay = { "{}" }: { "{}" } · { "{}" } tokens OpenAiLimitsTitle = Model rate limits OpenAiLimitsHelp = Enter your organization's per-model limits from the OpenAI dashboard to see utilization. Leave requests per minute empty when the model has no request limit. OpenAiLimitModelLabel = Model ID diff --git a/rust/src/locale/es-MX.ftl b/rust/src/locale/es-MX.ftl index ff0d267348..b20085de31 100644 --- a/rust/src/locale/es-MX.ftl +++ b/rust/src/locale/es-MX.ftl @@ -1710,6 +1710,13 @@ OpenAiTpmPendingLegend = Pendiente: aún no reportado OpenAiTpmEmpty = Aún no hay tráfico reportado en esta ventana. OpenAiTpmModels = Modelos OpenAiTpmLimitLine = El pico de { "{}" } usó el { "{}" }% de su límite de tokens por minuto ({ "{}" }) +OpenAiChartModeLabel = Gráfico +OpenAiChartModeLive = En vivo +OpenAiChartModeSpend = Gasto +OpenAiTpmHoverMinute = { "{}" } tokens · { "{}" } solicitudes +OpenAiTpmReporting = informando… +OpenAiSpendChartTitle = Gasto diario · Últimos { "{}" } días +OpenAiSpendHoverDay = { "{}" }: { "{}" } · { "{}" } tokens OpenAiLimitsTitle = Límites de tasa por modelo OpenAiLimitsHelp = Escribe los límites por modelo de tu organización desde el panel de OpenAI para ver el uso. Deja solicitudes por minuto vacío si el modelo no tiene límite de solicitudes. OpenAiLimitModelLabel = ID del modelo diff --git a/rust/src/locale/ja-JP.ftl b/rust/src/locale/ja-JP.ftl index cb1af2dc65..8d98d1285a 100644 --- a/rust/src/locale/ja-JP.ftl +++ b/rust/src/locale/ja-JP.ftl @@ -1711,6 +1711,13 @@ OpenAiTpmPendingLegend = 保留中:まだ報告されていません OpenAiTpmEmpty = この期間にはまだ報告されたトラフィックがありません。 OpenAiTpmModels = モデル OpenAiTpmLimitLine = { "{}" } のピークは上限の { "{}" }%(毎分 { "{}" } トークン) +OpenAiChartModeLabel = グラフ +OpenAiChartModeLive = ライブ +OpenAiChartModeSpend = 支出 +OpenAiTpmHoverMinute = { "{}" } トークン · { "{}" } リクエスト +OpenAiTpmReporting = 集計中… +OpenAiSpendChartTitle = 日別支出 · 過去 { "{}" } 日間 +OpenAiSpendHoverDay = { "{}" }: { "{}" } · { "{}" } トークン OpenAiLimitsTitle = モデル別レート制限 OpenAiLimitsHelp = OpenAI ダッシュボードで確認した組織のモデル別上限を入力すると、使用率が表示されます。モデルにリクエスト数の上限がない場合、毎分リクエスト数は空欄のままにしてください。 OpenAiLimitModelLabel = モデル ID diff --git a/rust/src/locale/ko-KR.ftl b/rust/src/locale/ko-KR.ftl index c1e12dcbe6..7f535627de 100644 --- a/rust/src/locale/ko-KR.ftl +++ b/rust/src/locale/ko-KR.ftl @@ -1710,6 +1710,13 @@ OpenAiTpmPendingLegend = 대기 중: 아직 보고되지 않음 OpenAiTpmEmpty = 이 기간에 보고된 트래픽이 아직 없습니다. OpenAiTpmModels = 모델 OpenAiTpmLimitLine = { "{}" }의 최고치가 분당 토큰 한도의 { "{}" }%입니다(한도 { "{}" }) +OpenAiChartModeLabel = 차트 +OpenAiChartModeLive = 실시간 +OpenAiChartModeSpend = 지출 +OpenAiTpmHoverMinute = 토큰 { "{}" }개 · 요청 { "{}" }건 +OpenAiTpmReporting = 집계 중… +OpenAiSpendChartTitle = 일별 지출 · 최근 { "{}" }일 +OpenAiSpendHoverDay = { "{}" }: { "{}" } · 토큰 { "{}" }개 OpenAiLimitsTitle = 모델별 속도 제한 OpenAiLimitsHelp = OpenAI 대시보드에서 확인한 조직의 모델별 한도를 입력하면 사용률을 볼 수 있습니다. 모델에 요청 한도가 없으면 분당 요청 수를 비워 두세요. OpenAiLimitModelLabel = 모델 ID diff --git a/rust/src/locale/pt-BR.ftl b/rust/src/locale/pt-BR.ftl index d740b7bf6f..77ee414f8f 100644 --- a/rust/src/locale/pt-BR.ftl +++ b/rust/src/locale/pt-BR.ftl @@ -1715,6 +1715,13 @@ OpenAiTpmPendingLegend = Pendente: ainda não informado OpenAiTpmEmpty = Nenhum tráfego informado nesta janela ainda. OpenAiTpmModels = Modelos OpenAiTpmLimitLine = O pico de { "{}" } usou { "{}" }% do limite de tokens por minuto ({ "{}" }) +OpenAiChartModeLabel = Gráfico +OpenAiChartModeLive = Ao vivo +OpenAiChartModeSpend = Gastos +OpenAiTpmHoverMinute = { "{}" } tokens · { "{}" } solicitações +OpenAiTpmReporting = informando… +OpenAiSpendChartTitle = Gasto diário · Últimos { "{}" } dias +OpenAiSpendHoverDay = { "{}" }: { "{}" } · { "{}" } tokens OpenAiLimitsTitle = Limites de taxa por modelo OpenAiLimitsHelp = Informe os limites por modelo da sua organização no painel da OpenAI para ver a utilização. Deixe requisições por minuto vazio quando o modelo não tiver limite de requisições. OpenAiLimitModelLabel = ID do modelo diff --git a/rust/src/locale/ru-RU.ftl b/rust/src/locale/ru-RU.ftl index 6b8c3f04c6..ff39aae8ee 100644 --- a/rust/src/locale/ru-RU.ftl +++ b/rust/src/locale/ru-RU.ftl @@ -1711,6 +1711,13 @@ OpenAiTpmPendingLegend = Ожидается: данные ещё не получ OpenAiTpmEmpty = За этот период пока нет переданного трафика. OpenAiTpmModels = Модели OpenAiTpmLimitLine = Пик { "{}" } составил { "{}" }% от лимита токенов в минуту ({ "{}" }) +OpenAiChartModeLabel = График +OpenAiChartModeLive = Сейчас +OpenAiChartModeSpend = Расходы +OpenAiTpmHoverMinute = { "{}" } токенов · { "{}" } запросов +OpenAiTpmReporting = идёт отчёт… +OpenAiSpendChartTitle = Расходы по дням · Последние { "{}" } дн. +OpenAiSpendHoverDay = { "{}" }: { "{}" } · { "{}" } токенов OpenAiLimitsTitle = Лимиты скорости по моделям OpenAiLimitsHelp = Введите лимиты организации по моделям из панели OpenAI, чтобы видеть загрузку. Если у модели нет лимита запросов, оставьте поле запросов в минуту пустым. OpenAiLimitModelLabel = ID модели diff --git a/rust/src/locale/tr-TR.ftl b/rust/src/locale/tr-TR.ftl index 229b569803..2d59a9e402 100644 --- a/rust/src/locale/tr-TR.ftl +++ b/rust/src/locale/tr-TR.ftl @@ -1711,6 +1711,13 @@ OpenAiTpmPendingLegend = Bekliyor: henüz bildirilmedi OpenAiTpmEmpty = Bu pencerede henüz bildirilmiş trafik yok. OpenAiTpmModels = Modeller OpenAiTpmLimitLine = { "{}" } zirvesi, dakikadaki token limitinin %{ "{}" }'ine ulaştı (limit { "{}" }) +OpenAiChartModeLabel = Grafik +OpenAiChartModeLive = Canlı +OpenAiChartModeSpend = Harcama +OpenAiTpmHoverMinute = { "{}" } token · { "{}" } istek +OpenAiTpmReporting = raporlanıyor… +OpenAiSpendChartTitle = Günlük harcama · Son { "{}" } gün +OpenAiSpendHoverDay = { "{}" }: { "{}" } · { "{}" } token OpenAiLimitsTitle = Modele göre hız limitleri OpenAiLimitsHelp = Kuruluşunuzun modele göre limitlerini OpenAI panelinden girin; kullanım oranını görürsünüz. Modelde istek limiti yoksa dakikadaki istek alanını boş bırakın. OpenAiLimitModelLabel = Model kimliği diff --git a/rust/src/locale/uk-UA.ftl b/rust/src/locale/uk-UA.ftl index cd9a8498be..960714f770 100644 --- a/rust/src/locale/uk-UA.ftl +++ b/rust/src/locale/uk-UA.ftl @@ -1713,6 +1713,13 @@ OpenAiTpmPendingLegend = Очікується: дані ще не отриман OpenAiTpmEmpty = За цей період ще немає переданого трафіку. OpenAiTpmModels = Моделі OpenAiTpmLimitLine = Пік { "{}" } становить { "{}" }% ліміту токенів за хвилину ({ "{}" }) +OpenAiChartModeLabel = Графік +OpenAiChartModeLive = Зараз +OpenAiChartModeSpend = Витрати +OpenAiTpmHoverMinute = { "{}" } токенів · { "{}" } запитів +OpenAiTpmReporting = надходять дані… +OpenAiSpendChartTitle = Витрати за днями · Останні { "{}" } дн. +OpenAiSpendHoverDay = { "{}" }: { "{}" } · { "{}" } токенів OpenAiLimitsTitle = Ліміти швидкості за моделями OpenAiLimitsHelp = Введіть ліміти організації за моделями з панелі OpenAI, щоб бачити завантаження. Якщо для моделі немає ліміту запитів, залиште поле запитів за хвилину порожнім. OpenAiLimitModelLabel = ID моделі diff --git a/rust/src/locale/zh-CN.ftl b/rust/src/locale/zh-CN.ftl index 409333aca9..daf29f2390 100644 --- a/rust/src/locale/zh-CN.ftl +++ b/rust/src/locale/zh-CN.ftl @@ -1711,6 +1711,13 @@ OpenAiTpmPendingLegend = 待定:尚未报告 OpenAiTpmEmpty = 此时间窗口内尚无已报告的流量。 OpenAiTpmModels = 模型 OpenAiTpmLimitLine = { "{}" } 的峰值达到其每分钟 Token 上限的 { "{}" }%(上限 { "{}" }) +OpenAiChartModeLabel = 图表 +OpenAiChartModeLive = 实时 +OpenAiChartModeSpend = 花费 +OpenAiTpmHoverMinute = { "{}" } 个 Token · { "{}" } 次请求 +OpenAiTpmReporting = 正在上报… +OpenAiSpendChartTitle = 每日花费 · 最近 { "{}" } 天 +OpenAiSpendHoverDay = { "{}" }: { "{}" } · { "{}" } 个 Token OpenAiLimitsTitle = 模型速率限制 OpenAiLimitsHelp = 请填写 OpenAI 控制台中组织的各模型限额以查看使用率。若模型没有请求数限制,请将每分钟请求数留空。 OpenAiLimitModelLabel = 模型 ID diff --git a/rust/src/locale/zh-TW.ftl b/rust/src/locale/zh-TW.ftl index 5fe33e6928..632fc60a72 100644 --- a/rust/src/locale/zh-TW.ftl +++ b/rust/src/locale/zh-TW.ftl @@ -1711,6 +1711,13 @@ OpenAiTpmPendingLegend = 待定:尚未回報 OpenAiTpmEmpty = 此時間範圍內尚無已回報的流量。 OpenAiTpmModels = 模型 OpenAiTpmLimitLine = { "{}" } 的高峰達到其每分鐘 Token 上限的 { "{}" }%(上限 { "{}" }) +OpenAiChartModeLabel = 圖表 +OpenAiChartModeLive = 即時 +OpenAiChartModeSpend = 花費 +OpenAiTpmHoverMinute = { "{}" } 個 Token · { "{}" } 次請求 +OpenAiTpmReporting = 正在回報… +OpenAiSpendChartTitle = 每日花費 · 最近 { "{}" } 天 +OpenAiSpendHoverDay = { "{}" }: { "{}" } · { "{}" } 個 Token OpenAiLimitsTitle = 模型速率限制 OpenAiLimitsHelp = 請填入 OpenAI 後台中組織的各模型限額以查看使用率。若模型沒有請求數限制,請將每分鐘請求數留空。 OpenAiLimitModelLabel = 模型 ID