diff --git a/apps/desktop-tauri/src-tauri/src/commands/provider_detail.rs b/apps/desktop-tauri/src-tauri/src/commands/provider_detail.rs index 418bd5c146..1a7128d12a 100644 --- a/apps/desktop-tauri/src-tauri/src/commands/provider_detail.rs +++ b/apps/desktop-tauri/src-tauri/src/commands/provider_detail.rs @@ -37,6 +37,9 @@ pub struct ProviderDetail { // Cost / pace. pub cost: Option, pub pace: Option, + /// Per-day OpenAI Admin API history for the Settings daily usage chart. + #[serde(skip_serializing_if = "Option::is_none")] + pub open_ai_api_usage: Option, // Error / state. pub last_error: Option, @@ -107,6 +110,7 @@ pub(crate) fn build_provider_detail( display_details: Vec::new(), cost: None, pace: None, + open_ai_api_usage: None, last_error: None, error_state: None, dashboard_url: dashboard_url.clone(), @@ -172,6 +176,7 @@ pub fn get_provider_detail( detail.display_details = snapshot.display_details.clone(); detail.cost = snapshot.cost.clone(); detail.pace = snapshot.pace.clone(); + detail.open_ai_api_usage = snapshot.open_ai_api_usage.clone(); } detail.last_error = snapshot.error.clone(); detail.error_state = Some(snapshot.error_state); diff --git a/apps/desktop-tauri/src-tauri/src/commands/tests.rs b/apps/desktop-tauri/src-tauri/src/commands/tests.rs index 67dae555be..78d429040f 100644 --- a/apps/desktop-tauri/src-tauri/src/commands/tests.rs +++ b/apps/desktop-tauri/src-tauri/src/commands/tests.rs @@ -935,6 +935,23 @@ fn provider_detail_roundtrips_through_serde() { assert!(json.contains("\"statusPageUrl\"")); } +#[test] +fn provider_detail_carries_openai_daily_usage_only_when_present() { + let (mut detail, _settings, _id) = + super::build_provider_detail("openaiapi").expect("known provider"); + assert!(detail.open_ai_api_usage.is_none()); + let json = serde_json::to_string(&detail).expect("serialize"); + assert!(!json.contains("openAiApiUsage")); + + detail.open_ai_api_usage = Some(super::OpenAiApiUsageSnapshot { + history_days: 30, + project_id: None, + daily: Vec::new(), + }); + let json = serde_json::to_string(&detail).expect("serialize"); + assert!(json.contains("\"openAiApiUsage\":{\"historyDays\":30")); +} + #[test] fn usage_item_descriptors_keep_raw_ids_and_redact_titles() { let metadata = instantiate_provider(ProviderId::Codex).metadata().clone(); diff --git a/apps/desktop-tauri/src/components/MenuCard.tsx b/apps/desktop-tauri/src/components/MenuCard.tsx index 493ff08e98..383ac5331a 100644 --- a/apps/desktop-tauri/src/components/MenuCard.tsx +++ b/apps/desktop-tauri/src/components/MenuCard.tsx @@ -9,6 +9,7 @@ import { useLocale } from "../hooks/useLocale"; import { formatRelativeUpdated } from "../lib/relativeTime"; import type { LocaleKey } from "../i18n/keys"; import { providerSupportsChartData } from "../lib/providerCharts"; +import { hideOpenAiApiProjectId } from "../lib/openAiApiIdentity"; import MenuCardDetails, { describeCard, type MetricEntry } from "./MenuCardDetails"; import CodexAccountsMenu from "./CodexAccountsMenu"; import ClaudeAccountsMenu from "./ClaudeAccountsMenu"; @@ -194,7 +195,13 @@ export default function MenuCard({ ? maskEmail(provider.accountEmail) : provider.accountEmail : null; - const planName = !isWayfinder ? displayPlanName(provider.planName, t) : null; + const planName = !isWayfinder + ? displayPlanName(provider.planName, t) + : null; + const displayedPlanName = hideOpenAiApiProjectId( + planName, + provider.providerId === "openaiapi" && hideEmail, + ); const metrics: MetricEntry[] = [ ...(isWayfinder @@ -285,8 +292,8 @@ export default function MenuCard({ ? provider.updatedAt : formatRelativeUpdated(Date.parse(provider.updatedAt), t)} - {planName && ( - {planName} + {displayedPlanName && ( + {displayedPlanName} )} )} diff --git a/apps/desktop-tauri/src/components/MenuCardDetails.tsx b/apps/desktop-tauri/src/components/MenuCardDetails.tsx index 31634bd910..e3de1a22c9 100644 --- a/apps/desktop-tauri/src/components/MenuCardDetails.tsx +++ b/apps/desktop-tauri/src/components/MenuCardDetails.tsx @@ -5,6 +5,7 @@ import type { ProviderDisplayDetail, PaceSnapshot, ProviderInventoryItem, + OpenAiApiUsageSnapshot, ProviderChartData, ProviderLocalUsageSummary, ProviderUsageSnapshot, @@ -22,6 +23,7 @@ import type { LocaleKey } from "../i18n/keys"; import { paceCategory } from "../surfaces/tray/paceCategory"; import { SimpleBarChart, StackedBarChart } from "./MiniBarChart"; import { InventoryItemRow } from "./InventoryRows"; +import { OpenAIApiUsageChart } from "./OpenAIApiUsageChart"; import { QuotaWindowHistory } from "./QuotaWindowHistory"; import { getPaceBudget, type PaceBudget } from "../lib/paceBudget"; import PaceDetailsChart from "./PaceDetailsChart"; @@ -439,6 +441,8 @@ export interface MenuCardPresence { hasQuotaWindowHistory: boolean; localUsage: ProviderChartData["localUsage"] | null; wayfinderUsage: ProviderUsageSnapshot["wayfinderUsage"] | null; + /** Per-day OpenAI Admin API history; `openaiapi` only, and only with data. */ + openAiApiUsage: OpenAiApiUsageSnapshot | null; hasDetails: boolean; } @@ -477,6 +481,12 @@ export function describeCard( const isWayfinder = provider.providerId === "wayfinder"; const localUsage = provider.error ? null : chartData?.localUsage ?? null; const wayfinderUsage = isWayfinder ? provider.wayfinderUsage : null; + const openAiApiUsage = + provider.providerId === "openaiapi" && + !provider.error && + (provider.openAiApiUsage?.daily.length ?? 0) > 0 + ? (provider.openAiApiUsage ?? null) + : null; const hasMetrics = visibleMetrics.length > 0; const hasInventory = !provider.error && (provider.inventory?.length ?? 0) > 0; const hasDisplayDetails = !provider.error && (provider.displayDetails?.length ?? 0) > 0; @@ -496,7 +506,8 @@ export function describeCard( hasPace || hasCharts || !!localUsage || - !!wayfinderUsage) && + !!wayfinderUsage || + !!openAiApiUsage) && // Compact Overview suppresses supplemental sections entirely; a card // whose only content would be suppressed renders header-only so no empty // divider or details container appears. @@ -514,6 +525,7 @@ export function describeCard( hasQuotaWindowHistory, localUsage, wayfinderUsage, + openAiApiUsage, hasDetails, }; } @@ -555,6 +567,7 @@ export default function MenuCardDetails({ hasQuotaWindowHistory, localUsage, wayfinderUsage, + openAiApiUsage, } = presence; return ( @@ -689,6 +702,21 @@ export default function MenuCardDetails({ )} + {!compactOverview && openAiApiUsage && ( +
+ {t("OpenAIChartTitle")} +
+ {/* Tray cards open often; skip the bar entrance animation there. */} + +
+
+ )} + {!compactOverview && (localUsage || hasPace || hasCharts) && (
{t("PanelUsageDetails")} diff --git a/apps/desktop-tauri/src/components/MenuCardOpenAIUsage.test.tsx b/apps/desktop-tauri/src/components/MenuCardOpenAIUsage.test.tsx new file mode 100644 index 0000000000..fa72167b1c --- /dev/null +++ b/apps/desktop-tauri/src/components/MenuCardOpenAIUsage.test.tsx @@ -0,0 +1,187 @@ +import { render, waitFor } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const tauriMocks = vi.hoisted(() => ({ + getProviderChartData: vi.fn(), + getDeepSeekPricingStatus: vi.fn(), + getLocaleStrings: vi.fn(), + setUiLanguage: vi.fn(), + claudeAccountsList: vi.fn(), +})); + +const eventMocks = vi.hoisted(() => ({ + listen: vi.fn(), +})); + +vi.mock("../lib/tauri", async (importOriginal) => ({ + ...(await importOriginal()), + ...tauriMocks, +})); +vi.mock("@tauri-apps/api/event", () => eventMocks); + +import { LocaleProvider } from "../i18n/LocaleProvider"; +import { buildBundle } from "../test/localeHarness"; +import type { OpenAiApiUsageSnapshot, ProviderUsageSnapshot } from "../types/bridge"; +import MenuCard from "./MenuCard"; +import { describeCard } from "./MenuCardDetails"; + +const START = 1_788_220_800; + +function usage(days = 2): OpenAiApiUsageSnapshot { + return { + historyDays: 30, + projectId: null, + daily: Array.from({ length: days }, (_, i) => ({ + startTime: START + i * 86_400, + endTime: START + (i + 1) * 86_400, + costUsd: 1 + i, + requests: 10, + inputTokens: 1000, + cachedInputTokens: 100, + outputTokens: 400, + totalTokens: 1400, + lineItems: [{ name: "Text tokens", costUsd: 1 + i }], + models: [], + })), + }; +} + +function rateWindow() { + return { + usedPercent: 10, + remainingPercent: 90, + windowMinutes: null, + resetsAt: null, + resetDescription: null, + isExhausted: false, + reservePercent: null, + reserveDescription: null, + reserveWillLastToReset: false, + reserveEtaSeconds: null, + }; +} + +function snapshot( + providerId: string, + openAiApiUsage: OpenAiApiUsageSnapshot | null, + error: string | null = null, +): ProviderUsageSnapshot { + return { + providerId, + displayName: providerId, + primary: rateWindow(), + selectedMetric: rateWindow(), + primaryLabel: "Session", + secondary: null, + modelSpecific: null, + tertiary: null, + extraRateWindows: [], + cost: null, + planName: null, + accountEmail: null, + sourceLabel: "api", + updatedAt: "2026-05-24T00:00:00Z", + error, + errorState: "unknown", + pace: null, + accountOrganization: null, + trayStatusLabel: null, + fetchDurationMs: null, + openAiApiUsage, + }; +} + +function renderCard( + snap: ProviderUsageSnapshot, + compactOverview = false, + hidePersonalInfo = false, +) { + return render( + + + , + ); +} + +describe("MenuCard OpenAI daily usage section", () => { + beforeEach(() => { + vi.clearAllMocks(); + tauriMocks.claudeAccountsList.mockResolvedValue([]); + tauriMocks.getLocaleStrings.mockResolvedValue(buildBundle({})); + tauriMocks.getDeepSeekPricingStatus.mockResolvedValue(null); + tauriMocks.getProviderChartData.mockResolvedValue({ + providerId: "openaiapi", + costHistory: [], + creditsHistory: [], + usageBreakdown: [], + localUsage: null, + }); + eventMocks.listen.mockResolvedValue(() => {}); + }); + + it("renders a collapsed Daily usage section for openaiapi", async () => { + const { container } = renderCard(snapshot("openaiapi", usage())); + await waitFor(() => { + expect(container.querySelector(".menu-card__daily-usage")).not.toBeNull(); + }); + const section = container.querySelector(".menu-card__daily-usage") as HTMLDetailsElement; + expect(section.tagName).toBe("DETAILS"); + expect(section.open).toBe(false); + expect(section.querySelector("summary")).toHaveTextContent("OpenAIChartTitle"); + expect(section.querySelectorAll('[role="option"]')).toHaveLength(2); + }); + + it("does not show another provider's OpenAI payload (provider siloing)", async () => { + const { container } = renderCard(snapshot("claude", usage())); + await waitFor(() => { + expect(tauriMocks.getProviderChartData).toHaveBeenCalled(); + }); + expect(container.querySelector(".menu-card__daily-usage")).toBeNull(); + expect(container.querySelector('[role="option"]')).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"; + const { container } = renderCard(provider, false, true); + await waitFor(() => expect(container.querySelector(".menu-card__plan-badge")).not.toBeNull()); + expect(container.querySelector(".menu-card__plan-badge")).toHaveTextContent("Admin API: ••••"); + expect(container).not.toHaveTextContent("proj-private"); + }); + + it("hides the section on error, in compact overview and with an empty window", async () => { + const errored = renderCard(snapshot("openaiapi", usage(), "boom")); + await waitFor(() => expect(tauriMocks.getLocaleStrings).toHaveBeenCalled()); + expect(errored.container.querySelector(".menu-card__daily-usage")).toBeNull(); + errored.unmount(); + + const compact = renderCard(snapshot("openaiapi", usage()), true); + expect(compact.container.querySelector(".menu-card__daily-usage")).toBeNull(); + compact.unmount(); + + const empty = renderCard(snapshot("openaiapi", usage(0))); + expect(empty.container.querySelector(".menu-card__daily-usage")).toBeNull(); + }); +}); + +describe("describeCard OpenAI usage presence", () => { + it("is present only for openaiapi with days and no error", () => { + expect(describeCard(snapshot("openaiapi", usage()), null, []).openAiApiUsage).not.toBeNull(); + expect(describeCard(snapshot("claude", usage()), null, []).openAiApiUsage).toBeNull(); + expect(describeCard(snapshot("openaiapi", usage(0)), null, []).openAiApiUsage).toBeNull(); + expect(describeCard(snapshot("openaiapi", null), null, []).openAiApiUsage).toBeNull(); + expect(describeCard(snapshot("openaiapi", usage(), "boom"), null, []).openAiApiUsage).toBeNull(); + }); + + it("makes a card that only has daily usage count as having details", () => { + expect(describeCard(snapshot("openaiapi", usage()), null, []).hasDetails).toBe(true); + expect(describeCard(snapshot("openaiapi", usage(0)), null, []).hasDetails).toBe(false); + }); + + it("keeps compact overview header-only when daily usage is the only content", () => { + expect(describeCard(snapshot("openaiapi", usage()), null, [], "detailed", true, true).hasDetails).toBe(false); + }); +}); diff --git a/apps/desktop-tauri/src/components/OpenAIApiUsageChart.test.tsx b/apps/desktop-tauri/src/components/OpenAIApiUsageChart.test.tsx new file mode 100644 index 0000000000..f3d157fecf --- /dev/null +++ b/apps/desktop-tauri/src/components/OpenAIApiUsageChart.test.tsx @@ -0,0 +1,234 @@ +import { fireEvent, render, screen, within } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import type { LocaleKey } from "../i18n/keys"; +import type { + OpenAiApiDailyUsageSnapshot, + OpenAiApiUsageSnapshot, +} from "../types/bridge"; +import { OpenAIApiUsageChart } from "./OpenAIApiUsageChart"; + +const DAY = 86_400; +// 2026-09-01T00:00:00Z +const START = 1_788_220_800; + +const strings: Partial> = { + OpenAIChartMore: "+{} more", + OpenAIChartWindowToday: "Today", + OpenAIChartWindowDays: "Last {} days", + UsageSpendRequests: "requests", +}; +const t = (key: LocaleKey) => strings[key] ?? key; + +function day( + index: number, + overrides: Partial = {}, +): OpenAiApiDailyUsageSnapshot { + return { + startTime: START + index * DAY, + endTime: START + (index + 1) * DAY, + costUsd: 1 + index, + requests: 10 * (index + 1), + inputTokens: 1_000 * (index + 1), + cachedInputTokens: 100 * (index + 1), + outputTokens: 500 * (index + 1), + totalTokens: 1_500 * (index + 1), + lineItems: [{ name: "Text tokens", costUsd: 1 + index }], + models: [ + { + name: `gpt-model-${index}`, + requests: 10 * (index + 1), + inputTokens: 1_000 * (index + 1), + cachedInputTokens: 100 * (index + 1), + outputTokens: 500 * (index + 1), + totalTokens: 1_500 * (index + 1), + }, + ], + ...overrides, + }; +} + +function usage(days: OpenAiApiDailyUsageSnapshot[], historyDays = 30): OpenAiApiUsageSnapshot { + return { historyDays, projectId: null, daily: days }; +} + +function renderChart(snapshot: OpenAiApiUsageSnapshot, onLayoutChange?: () => void) { + return render( + , + ); +} + +function bars() { + return screen.getAllByRole("option"); +} + +describe("OpenAIApiUsageChart", () => { + it("selects the latest day by default and shows its detail", () => { + renderChart(usage([day(0), day(1), day(2)])); + expect(bars()).toHaveLength(3); + expect(bars()[2]).toHaveAttribute("aria-selected", "true"); + expect(bars()[2]).toHaveAttribute("tabindex", "0"); + expect(bars()[0]).toHaveAttribute("tabindex", "-1"); + const detail = document.querySelector(".openai-usage__detail") as HTMLElement; + expect(within(detail).getByText("2026-09-03")).toBeInTheDocument(); + // Day cost in the stat grid plus the single line item carrying the same cost. + expect(within(detail).getAllByText("$3.00")).toHaveLength(2); + expect(within(detail).getByText("30")).toBeInTheDocument(); + expect(within(detail).getByText("3,000")).toBeInTheDocument(); + expect(within(detail).getByText("300")).toBeInTheDocument(); + expect(within(detail).getByText("1,500")).toBeInTheDocument(); + expect(within(detail).getByText("gpt-model-2")).toBeInTheDocument(); + }); + + it("shows the window total in the footer", () => { + renderChart(usage([day(0), day(1), day(2)])); + expect(screen.getByText("Last 30 days").nextElementSibling).toHaveTextContent("$6.00"); + }); + + it("labels a one-day window Today", () => { + renderChart(usage([day(0)], 1)); + expect(screen.getByText("Today")).toBeInTheDocument(); + }); + + it("selects a day on hover", () => { + renderChart(usage([day(0), day(1), day(2)])); + fireEvent.mouseEnter(bars()[0]); + expect(bars()[0]).toHaveAttribute("aria-selected", "true"); + expect(document.querySelector(".openai-usage__day")).toHaveTextContent("2026-09-01"); + expect(screen.getByText("gpt-model-0")).toBeInTheDocument(); + expect(screen.queryByText("gpt-model-2")).toBeNull(); + }); + + it("moves the selection with Left/Right/Home/End and keeps focus on the bar", () => { + renderChart(usage([day(0), day(1), day(2), day(3)])); + const list = screen.getByRole("listbox"); + fireEvent.keyDown(list, { key: "ArrowLeft" }); + expect(bars()[2]).toHaveAttribute("aria-selected", "true"); + expect(bars()[2]).toHaveFocus(); + fireEvent.keyDown(list, { key: "Home" }); + expect(bars()[0]).toHaveAttribute("aria-selected", "true"); + fireEvent.keyDown(list, { key: "ArrowLeft" }); + expect(bars()[0]).toHaveAttribute("aria-selected", "true"); + fireEvent.keyDown(list, { key: "End" }); + expect(bars()[3]).toHaveAttribute("aria-selected", "true"); + fireEvent.keyDown(list, { key: "ArrowRight" }); + expect(bars()[3]).toHaveAttribute("aria-selected", "true"); + expect(bars()[3]).toHaveAttribute("tabindex", "0"); + }); + + it("keeps the selected day when a refresh delivers new data", () => { + const { rerender } = renderChart(usage([day(0), day(1), day(2)])); + fireEvent.mouseEnter(bars()[1]); + rerender( + , + ); + expect(bars()).toHaveLength(4); + expect(bars()[1]).toHaveAttribute("aria-selected", "true"); + }); + + it("toggles between Cost and Tokens with the tab strip", () => { + renderChart(usage([day(0), day(1)])); + const cost = screen.getByRole("tab", { name: "OpenAIChartMetricCost" }); + const tokens = screen.getByRole("tab", { name: "OpenAIChartMetricTokens" }); + expect(cost).toHaveAttribute("aria-selected", "true"); + expect(tokens).toHaveAttribute("tabindex", "-1"); + expect(bars()[1]).toHaveAttribute("aria-label", "2026-09-02: $2.00"); + + fireEvent.click(tokens); + expect(tokens).toHaveAttribute("aria-selected", "true"); + expect(bars()[1]).toHaveAttribute("aria-label", "2026-09-02: 3K"); + expect(screen.getByText("Last 30 days").nextElementSibling).toHaveTextContent("4.5K"); + expect(screen.getByRole("tabpanel")).toHaveAttribute("aria-labelledby", tokens.id); + }); + + it("moves between the metric tabs with the arrow keys", () => { + renderChart(usage([day(0), day(1)])); + const cost = screen.getByRole("tab", { name: "OpenAIChartMetricCost" }); + const tokens = screen.getByRole("tab", { name: "OpenAIChartMetricTokens" }); + fireEvent.keyDown(cost, { key: "ArrowRight" }); + expect(tokens).toHaveAttribute("aria-selected", "true"); + expect(tokens).toHaveFocus(); + fireEvent.keyDown(tokens, { key: "ArrowLeft" }); + expect(cost).toHaveAttribute("aria-selected", "true"); + expect(cost).toHaveFocus(); + }); + + it("caps line items and models at five rows plus a more row", () => { + const many = day(0, { + lineItems: Array.from({ length: 7 }, (_, i) => ({ name: `item-${i}`, costUsd: 7 - i })), + models: Array.from({ length: 6 }, (_, i) => ({ + name: `model-${i}`, + requests: 1, + inputTokens: 1, + cachedInputTokens: 0, + outputTokens: 1, + totalTokens: 2, + })), + }); + renderChart(usage([many])); + expect(screen.getByText("item-4")).toBeInTheDocument(); + expect(screen.queryByText("item-5")).toBeNull(); + expect(screen.getByText("+2 more")).toBeInTheDocument(); + expect(screen.getByText("model-4")).toBeInTheDocument(); + expect(screen.queryByText("model-5")).toBeNull(); + expect(screen.getByText("+1 more")).toBeInTheDocument(); + }); + + it("formats sub-cent costs and zero-token days", () => { + renderChart( + usage([ + day(0, { + costUsd: 0.004, + totalTokens: 0, + inputTokens: 0, + cachedInputTokens: 0, + outputTokens: 0, + requests: 0, + lineItems: [], + models: [], + }), + ]), + ); + const detail = document.querySelector(".openai-usage__detail") as HTMLElement; + expect(within(detail).getByText("<$0.01")).toBeInTheDocument(); + expect(within(detail).queryByText("OpenAIChartLineItems")).toBeNull(); + expect(within(detail).queryByText("OpenAIChartModels")).toBeNull(); + }); + + it("keeps the full history in a horizontally scrollable chart", () => { + const days = Array.from({ length: 90 }, (_, i) => day(i)); + renderChart(usage(days, 90)); + expect(bars()).toHaveLength(90); + expect(screen.getByText("Last 90 days")).toBeInTheDocument(); + const chart = document.querySelector(".chart--scrollable"); + expect(chart).toBeInTheDocument(); + expect(Number(chart?.querySelector("svg")?.getAttribute("width"))).toBeGreaterThan(280); + expect(bars()[0]).toHaveAttribute("aria-label", "2026-09-01: $1.00"); + expect(bars()[89]).toHaveAttribute("aria-label", "2026-11-29: $90.00"); + expect(screen.getByText("Last 90 days").nextElementSibling).toHaveTextContent( + "$4,095.00", + ); + }); + + it("shows the empty message when the window has no days", () => { + renderChart(usage([])); + expect(screen.getByText("DetailChartEmpty")).toBeInTheDocument(); + expect(screen.queryByRole("listbox")).toBeNull(); + }); + + it("asks the host to re-measure when the detail panel changes", async () => { + const onLayoutChange = vi.fn(); + renderChart(usage([day(0), day(1)]), onLayoutChange); + await vi.waitFor(() => expect(onLayoutChange).toHaveBeenCalledTimes(1)); + fireEvent.mouseEnter(bars()[0]); + await vi.waitFor(() => expect(onLayoutChange).toHaveBeenCalledTimes(2)); + }); +}); diff --git a/apps/desktop-tauri/src/components/OpenAIApiUsageChart.tsx b/apps/desktop-tauri/src/components/OpenAIApiUsageChart.tsx new file mode 100644 index 0000000000..a24c640c0a --- /dev/null +++ b/apps/desktop-tauri/src/components/OpenAIApiUsageChart.tsx @@ -0,0 +1,239 @@ +import { type KeyboardEvent, useEffect, useId, useMemo, useRef, useState } from "react"; +import type { LocaleKey } from "../i18n/keys"; +import type { + OpenAiApiDailyUsageSnapshot, + OpenAiApiUsageSnapshot, +} from "../types/bridge"; +import { BarChart } from "./charts/BarChart"; +import { providerCostColor } from "./charts/chartPalette"; + +/** + * Per-UTC-day chart for the OpenAI Admin API (upstream 0.66.0). Cost or token + * bars, one per day; hovering or focusing a bar selects the day and fills the + * detail panel (requests, token split, line items, models). USD only. + */ + +type Metric = "cost" | "tokens"; +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; +} + +function formatMetric(value: number, metric: Metric): string { + return metric === "cost" ? formatUsd(value) : compactFormat.format(value); +} + +function windowLabel(days: number, t: T): string { + return days === 1 + ? t("OpenAIChartWindowToday") + : t("OpenAIChartWindowDays").replace("{}", String(days)); +} + +interface DetailRow { + key: string; + name: string; + value: string; +} + +function DetailList({ + title, + rows, + t, +}: { + title: string; + rows: DetailRow[]; + t: T; +}) { + if (rows.length === 0) return null; + const hidden = rows.length - MAX_DETAIL_ROWS; + return ( +
+
{title}
+
    + {rows.slice(0, MAX_DETAIL_ROWS).map((row) => ( +
  • + {row.name} + {row.value} +
  • + ))} + {hidden > 0 && ( +
  • + {t("OpenAIChartMore").replace("{}", String(hidden))} +
  • + )} +
+
+ ); +} + +function DayDetail({ day, t }: { day: OpenAiApiDailyUsageSnapshot; t: T }) { + const stats: Array<[LocaleKey, string]> = [ + ["OpenAIChartMetricCost", formatUsd(day.costUsd)], + ["OpenAIChartRequests", countFormat.format(day.requests)], + ["OpenAIChartInputTokens", countFormat.format(day.inputTokens)], + ["OpenAIChartCachedTokens", countFormat.format(day.cachedInputTokens)], + ["OpenAIChartOutputTokens", countFormat.format(day.outputTokens)], + ]; + const lineItems = day.lineItems.map((item, index) => ({ + key: `${item.name}-${index}`, + name: item.name, + value: formatUsd(item.costUsd), + })); + const models = day.models.map((model, index) => ({ + key: `${model.name}-${index}`, + name: model.name, + value: `${compactFormat.format(model.totalTokens)} · ${countFormat.format(model.requests)} ${t( + "UsageSpendRequests", + )}`, + })); + return ( +
+
{dayLabel(day)}
+
+ {stats.map(([labelKey, value]) => ( +
+
{t(labelKey)}
+
{value}
+
+ ))} +
+ + +
+ ); +} + +export interface OpenAIApiUsageChartProps { + usage: OpenAiApiUsageSnapshot; + animations: boolean; + t: T; + /** Popover hosts resize to content; called after the detail panel height may change. */ + onLayoutChange?: () => void; +} + +export function OpenAIApiUsageChart({ + usage, + animations, + t, + onLayoutChange, +}: OpenAIApiUsageChartProps) { + const [metric, setMetric] = useState("cost"); + // Selection is keyed by bucket start so it survives a refresh; null = latest day. + const [selectedStart, setSelectedStart] = useState(null); + const baseId = useId(); + const tabRefs = useRef>([]); + const days = usage.daily; + const points = useMemo( + () => + days.map((day) => ({ + label: dayLabel(day), + value: metricValue(day, metric), + })), + [days, metric], + ); + + const layoutKey = `${metric}:${selectedStart ?? "latest"}:${days.length}`; + const layoutCallback = useRef(onLayoutChange); + layoutCallback.current = onLayoutChange; + useEffect(() => { + const frame = requestAnimationFrame(() => layoutCallback.current?.()); + return () => cancelAnimationFrame(frame); + }, [layoutKey]); + + if (days.length === 0) { + return ( +
+
{t("DetailChartEmpty")}
+
+ ); + } + + const found = days.findIndex((day) => day.startTime === selectedStart); + const selectedIndex = found >= 0 ? found : days.length - 1; + const total = days.reduce((sum, day) => sum + metricValue(day, metric), 0); + const tabLabel = (m: Metric) => + t(m === "cost" ? "OpenAIChartMetricCost" : "OpenAIChartMetricTokens"); + const tabId = (m: Metric) => `${baseId}-tab-${m}`; + const panelId = `${baseId}-panel`; + + const onTabKeyDown = (e: KeyboardEvent) => { + const current = METRICS.indexOf(metric); + let next: number; + if (e.key === "ArrowLeft") next = (current + METRICS.length - 1) % METRICS.length; + else if (e.key === "ArrowRight") next = (current + 1) % METRICS.length; + else if (e.key === "Home") next = 0; + else if (e.key === "End") next = METRICS.length - 1; + else return; + e.preventDefault(); + setMetric(METRICS[next]); + tabRefs.current[next]?.focus(); + }; + + return ( +
+
+ {METRICS.map((m, index) => ( + + ))} +
+
+ formatMetric(v, metric)} + animations={animations} + emptyMessage={t("DetailChartEmpty")} + scrollable + selection={{ + index: selectedIndex, + onSelect: (index) => setSelectedStart(days[index]?.startTime ?? null), + }} + /> +
{t("OpenAIChartHint")}
+ +
+ {windowLabel(usage.historyDays, t)} + {formatMetric(total, metric)} +
+
+
+ ); +} diff --git a/apps/desktop-tauri/src/components/charts/BarChart.test.tsx b/apps/desktop-tauri/src/components/charts/BarChart.test.tsx index 12934d08e3..cb8e202caa 100644 --- a/apps/desktop-tauri/src/components/charts/BarChart.test.tsx +++ b/apps/desktop-tauri/src/components/charts/BarChart.test.tsx @@ -1,5 +1,5 @@ -import { render } from "@testing-library/react"; -import { describe, expect, it } from "vitest"; +import { fireEvent, render, screen } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; import { BarChart } from "./BarChart"; describe("BarChart calendar slots", () => { @@ -48,3 +48,73 @@ describe("BarChart calendar slots", () => { expect((labels[1] as HTMLElement).style.transform).toBe(""); }); }); + +describe("BarChart controlled selection", () => { + const data = [ + { label: "a", value: 1 }, + { label: "b", value: 2 }, + { label: "c", value: 3 }, + ]; + + function setup(index = 2) { + const onSelect = vi.fn(); + const utils = render( + , + ); + return { onSelect, ...utils }; + } + + it("exposes a listbox of options with a roving tabindex", () => { + setup(1); + expect(screen.getByRole("listbox")).toHaveAttribute("aria-orientation", "horizontal"); + const options = screen.getAllByRole("option"); + expect(options.map((o) => o.getAttribute("aria-selected"))).toEqual(["false", "true", "false"]); + expect(options.map((o) => o.getAttribute("tabindex"))).toEqual(["-1", "0", "-1"]); + expect(options[1]).toHaveAttribute("data-selected", "true"); + expect(options[1]).toHaveAttribute("opacity", "1"); + expect(options[0]).toHaveAttribute("opacity", "0.6"); + }); + + it("reports hover and focus as a selection change", () => { + const { onSelect } = setup(); + const options = screen.getAllByRole("option"); + fireEvent.mouseEnter(options[0]); + expect(onSelect).toHaveBeenLastCalledWith(0); + fireEvent.focus(options[1]); + expect(onSelect).toHaveBeenLastCalledWith(1); + }); + + it("navigates with the arrow, Home and End keys and clamps at the ends", () => { + const { onSelect } = setup(1); + const list = screen.getByRole("listbox"); + fireEvent.keyDown(list, { key: "ArrowLeft" }); + expect(onSelect).toHaveBeenLastCalledWith(0); + fireEvent.keyDown(list, { key: "ArrowRight" }); + expect(onSelect).toHaveBeenLastCalledWith(2); + fireEvent.keyDown(list, { key: "Home" }); + expect(onSelect).toHaveBeenLastCalledWith(0); + fireEvent.keyDown(list, { key: "End" }); + expect(onSelect).toHaveBeenLastCalledWith(2); + onSelect.mockClear(); + fireEvent.keyDown(list, { key: "Tab" }); + expect(onSelect).not.toHaveBeenCalled(); + }); + + it("does not open the hover tooltip in selection mode", () => { + const { container } = setup(); + fireEvent.mouseMove(container.querySelector("svg") as SVGElement, { clientX: 10, clientY: 10 }); + expect(container.querySelector(".chart__tooltip")).toBeNull(); + }); + + it("stays a plain image chart without a selection prop", () => { + const { container } = render(); + expect(screen.queryByRole("listbox")).toBeNull(); + expect(screen.queryAllByRole("option")).toHaveLength(0); + expect(container.querySelector("[data-selected]")).toBeNull(); + }); +}); diff --git a/apps/desktop-tauri/src/components/charts/BarChart.tsx b/apps/desktop-tauri/src/components/charts/BarChart.tsx index 0151811f57..a47c083c41 100644 --- a/apps/desktop-tauri/src/components/charts/BarChart.tsx +++ b/apps/desktop-tauri/src/components/charts/BarChart.tsx @@ -1,10 +1,11 @@ -import { useMemo, useRef, useState } from "react"; +import { type KeyboardEvent, useMemo, useRef, useState } from "react"; import { useChartAnimation } from "./useChartAnimation"; import { WIDTH, getBarCenter, getBarWidth, getBarX, + getScrollableChartWidth, shouldRenderCenterMax, } from "./chartGeometry"; @@ -25,6 +26,16 @@ export interface BarChartPoint { value: number | null; } +/** + * Controlled bar selection. When set, the bars form a roving-tabindex listbox: + * hover or focus selects a bar, Left/Right move the selection, Home/End jump to + * the first/last bar, and the hover tooltip is left to the caller's detail view. + */ +export interface BarChartSelection { + index: number; + onSelect: (index: number) => void; +} + export interface BarChartProps { data: BarChartPoint[]; color?: string; @@ -35,6 +46,9 @@ export interface BarChartProps { animations?: boolean; /** Optional empty-state message rendered when `data.length === 0`. */ emptyMessage?: string; + selection?: BarChartSelection; + /** Keep narrow bars readable by making long series horizontally scrollable. */ + scrollable?: boolean; } const DEFAULT_COLOR = "var(--chart-cost)"; @@ -48,10 +62,13 @@ export function BarChart({ ariaLabel, animations = true, emptyMessage, + selection, + scrollable = false, }: BarChartProps) { const fmt = valueFormatter ?? ((v: number) => v.toFixed(2)); const containerRef = useRef(null); const [hover, setHover] = useState<{ i: number; x: number; y: number } | null>(null); + const barRefs = useRef>([]); const anim = useChartAnimation(data.length, animations, [ data.length, @@ -80,7 +97,11 @@ export function BarChart({ ); } - const barWidth = getBarWidth(data.length); + const chartWidth = scrollable ? getScrollableChartWidth(data.length) : WIDTH; + const chartClass = scrollable + ? "chart chart--bar chart--scrollable" + : "chart chart--bar"; + const barWidth = getBarWidth(data.length, chartWidth); const plotHeight = Math.max(1, height - 4); const onMove = (e: React.MouseEvent, i: number) => { @@ -91,26 +112,52 @@ export function BarChart({ }; const onLeave = () => setHover(null); + const selectedIndex = selection + ? Math.min(Math.max(selection.index, 0), data.length - 1) + : -1; + const onKeyDown = (e: KeyboardEvent) => { + if (!selection) return; + let next: number; + if (e.key === "ArrowLeft") next = selectedIndex - 1; + else if (e.key === "ArrowRight") next = selectedIndex + 1; + else if (e.key === "Home") next = 0; + else if (e.key === "End") next = data.length - 1; + else return; + e.preventDefault(); + next = Math.min(Math.max(next, 0), data.length - 1); + selection.onSelect(next); + barRefs.current[next]?.focus(); + }; + return ( -
+
{data.map((p, i) => { const base = p.value == null ? 1 : p.value === 0 ? 1 : Math.max(3, (p.value / max) * plotHeight); const eased = anim.barProgress(i); const barH = base * eased; - const x = getBarX(i, data.length); + const x = getBarX(i, data.length, chartWidth); const y = height - barH; const isPeak = i === peakIndex && barH > CAP_HEIGHT; const bodyH = isPeak ? Math.max(0, barH - CAP_HEIGHT) : barH; const bodyY = isPeak ? y + CAP_HEIGHT : y; const isHovered = hover?.i === i; + const isSelected = i === selectedIndex; + let opacity = 0.9; + if (p.value == null) opacity = 0; + else if (p.value === 0) opacity = 0.25; + else if (selection) opacity = isSelected ? 1 : 0.6; + else if (isHovered) opacity = 1; return ( @@ -120,11 +167,29 @@ export function BarChart({ width={barWidth} height={bodyH} fill={color} - opacity={p.value == null ? 0 : p.value === 0 ? 0.25 : isHovered ? 1 : 0.9} + opacity={opacity} rx={1} className="chart__bar" - onMouseMove={p.value == null ? undefined : (e) => onMove(e, i)} - onMouseLeave={onLeave} + {...(selection + ? { + ref: (node: SVGRectElement | null) => { + barRefs.current[i] = node; + }, + role: "option", + "aria-selected": isSelected, + "aria-label": p.value == null ? p.label : `${p.label}: ${fmt(p.value)}`, + tabIndex: isSelected ? 0 : -1, + "data-selected": isSelected ? "true" : "false", + onMouseEnter: () => selection.onSelect(i), + onFocus: () => selection.onSelect(i), + } + : { + onMouseMove: + p.value == null + ? undefined + : (e: React.MouseEvent) => onMove(e, i), + onMouseLeave: onLeave, + })} > {p.value == null ? p.label : `${p.label}: ${fmt(p.value)}`} @@ -146,18 +211,26 @@ export function BarChart({ ); })} -
- +
+ {data[0].label} {shouldRenderCenterMax(data.length) && ( - {fmt(max)} + + {fmt(max)} + )} - + {data[data.length - 1].label}
- {hover && !anim.running && ( + {!selection && hover && !anim.running && (
{ it("renders the center max label for three or more points", () => { expect(shouldRenderCenterMax(3)).toBe(true); }); + + it("allocates enough scrollable width to keep long-series bars visible", () => { + const width = getScrollableChartWidth(90); + const lastBarRight = getBarCenter(89, 90, width) + getBarWidth(90, width) / 2; + + expect(width).toBeGreaterThan(WIDTH); + expect(getBarWidth(90, width)).toBeCloseTo(1.5); + expect(lastBarRight).toBeLessThanOrEqual(width - DATE_EDGE_PADDING); + }); }); diff --git a/apps/desktop-tauri/src/components/charts/chartGeometry.ts b/apps/desktop-tauri/src/components/charts/chartGeometry.ts index f3de6d951d..b428908d56 100644 --- a/apps/desktop-tauri/src/components/charts/chartGeometry.ts +++ b/apps/desktop-tauri/src/components/charts/chartGeometry.ts @@ -3,22 +3,30 @@ export const DATE_EDGE_PADDING = 36; export const BAR_GAP = 2; export const PLOT_WIDTH = WIDTH - DATE_EDGE_PADDING * 2; export const AXIS_MAX_X = WIDTH / 2; +const SCROLLABLE_BAR_WIDTH = 1.5; export function shouldRenderCenterMax(count: number): boolean { return count >= 3; } -export function getBarWidth(count: number): number { +export function getScrollableChartWidth(count: number): number { const barCount = Math.max(1, count); - return Math.max(1, (PLOT_WIDTH - (barCount - 1) * BAR_GAP) / barCount); + const plotWidth = barCount * SCROLLABLE_BAR_WIDTH + (barCount - 1) * BAR_GAP; + return Math.max(WIDTH, DATE_EDGE_PADDING * 2 + plotWidth); } -export function getBarX(index: number, count: number): number { - return DATE_EDGE_PADDING + index * (getBarWidth(count) + BAR_GAP); +export function getBarWidth(count: number, width = WIDTH): number { + const barCount = Math.max(1, count); + const plotWidth = width - DATE_EDGE_PADDING * 2; + return Math.max(1, (plotWidth - (barCount - 1) * BAR_GAP) / barCount); +} + +export function getBarX(index: number, count: number, width = WIDTH): number { + return DATE_EDGE_PADDING + index * (getBarWidth(count, width) + BAR_GAP); } -export function getBarCenter(index: number, count: number): number { - return getBarX(index, count) + getBarWidth(count) / 2; +export function getBarCenter(index: number, count: number, width = WIDTH): number { + return getBarX(index, count, width) + getBarWidth(count, width) / 2; } export interface BarGeometry { @@ -28,9 +36,9 @@ export interface BarGeometry { center: (index: number) => number; } -export function getBarGeometry(count: number): BarGeometry { - const barWidth = getBarWidth(count); - const x = (index: number) => getBarX(index, count); +export function getBarGeometry(count: number, width = WIDTH): BarGeometry { + const barWidth = getBarWidth(count, width); + const x = (index: number) => getBarX(index, count, width); return { barWidth, diff --git a/apps/desktop-tauri/src/components/charts/useChartAnimation.test.ts b/apps/desktop-tauri/src/components/charts/useChartAnimation.test.ts new file mode 100644 index 0000000000..2eeabef1f1 --- /dev/null +++ b/apps/desktop-tauri/src/components/charts/useChartAnimation.test.ts @@ -0,0 +1,36 @@ +import { act, renderHook } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { useChartAnimation } from "./useChartAnimation"; + +describe("useChartAnimation long series", () => { + afterEach(() => vi.unstubAllGlobals()); + + it("finishes long-series entrance animation after the bounded stagger", () => { + const frames = new Map(); + let nextFrame = 0; + vi.stubGlobal("requestAnimationFrame", (callback: FrameRequestCallback) => { + const id = ++nextFrame; + frames.set(id, callback); + return id; + }); + vi.stubGlobal("cancelAnimationFrame", (id: number) => frames.delete(id)); + + const { result, unmount } = renderHook(() => useChartAnimation(366, true)); + act(() => { + const first = frames.get(1); + if (!first) throw new Error("expected initial animation frame"); + frames.delete(1); + first(0); + }); + act(() => { + const next = frames.get(2); + if (!next) throw new Error("expected follow-up animation frame"); + frames.delete(2); + next(1_200); + }); + + expect(result.current.running).toBe(false); + expect(result.current.barProgress(365)).toBe(1); + unmount(); + }); +}); diff --git a/apps/desktop-tauri/src/components/charts/useChartAnimation.ts b/apps/desktop-tauri/src/components/charts/useChartAnimation.ts index f1e94ccecf..94417ffac9 100644 --- a/apps/desktop-tauri/src/components/charts/useChartAnimation.ts +++ b/apps/desktop-tauri/src/components/charts/useChartAnimation.ts @@ -19,6 +19,7 @@ import { useEffect, useRef, useState } from "react"; */ export const TOTAL_ANIMATION_MS = 600; export const STAGGER_PER_BAR_MS = 20; +const MAX_STAGGERED_ITEMS = 30; const easeOut = (t: number) => 1 - Math.pow(1 - t, 3); const clamp01 = (v: number) => (v < 0 ? 0 : v > 1 ? 1 : v); @@ -42,6 +43,9 @@ export function useChartAnimation( const rafRef = useRef(null); const prefersReduced = usePrefersReducedMotion(); const skip = !enabled || prefersReduced || count === 0; + // Keep long daily histories animated without making the entrance delay grow + // linearly with every bucket. Items after the first 30 start together. + const staggeredCount = Math.min(count, MAX_STAGGERED_ITEMS); useEffect(() => { if (skip) { @@ -52,7 +56,7 @@ export function useChartAnimation( startRef.current = null; setElapsed(0); - const totalMs = TOTAL_ANIMATION_MS + count * STAGGER_PER_BAR_MS; + const totalMs = TOTAL_ANIMATION_MS + staggeredCount * STAGGER_PER_BAR_MS; const tick = (now: number) => { if (startRef.current == null) startRef.current = now; @@ -74,10 +78,11 @@ export function useChartAnimation( const progress = skip ? 1 : clamp01(elapsed / TOTAL_ANIMATION_MS); const barProgress = (i: number) => { if (skip) return 1; - const barElapsed = Math.max(0, elapsed - i * STAGGER_PER_BAR_MS); + const stagger = Math.min(i, MAX_STAGGERED_ITEMS) * STAGGER_PER_BAR_MS; + const barElapsed = Math.max(0, elapsed - stagger); return easeOut(clamp01(barElapsed / TOTAL_ANIMATION_MS)); }; - const totalMs = TOTAL_ANIMATION_MS + count * STAGGER_PER_BAR_MS; + const totalMs = TOTAL_ANIMATION_MS + staggeredCount * STAGGER_PER_BAR_MS; const running = !skip && elapsed < totalMs; return { progress: easeOut(progress), barProgress, running }; diff --git a/apps/desktop-tauri/src/i18n/keys.ts b/apps/desktop-tauri/src/i18n/keys.ts index 195620e3bf..fd2d9af7a4 100644 --- a/apps/desktop-tauri/src/i18n/keys.ts +++ b/apps/desktop-tauri/src/i18n/keys.ts @@ -632,6 +632,19 @@ export const ALL_LOCALE_KEYS = [ "DetailChartCredits", "DetailChartUsageBreakdown", "DetailChartEmpty", + "OpenAIChartTitle", + "OpenAIChartMetricCost", + "OpenAIChartMetricTokens", + "OpenAIChartHint", + "OpenAIChartRequests", + "OpenAIChartInputTokens", + "OpenAIChartCachedTokens", + "OpenAIChartOutputTokens", + "OpenAIChartLineItems", + "OpenAIChartModels", + "OpenAIChartMore", + "OpenAIChartWindowToday", + "OpenAIChartWindowDays", "DetailUpdatedPrefix", "PanelAllProviders", "PanelAllProvidersShort", diff --git a/apps/desktop-tauri/src/lib/openAiApiIdentity.test.ts b/apps/desktop-tauri/src/lib/openAiApiIdentity.test.ts new file mode 100644 index 0000000000..6160b5b8d6 --- /dev/null +++ b/apps/desktop-tauri/src/lib/openAiApiIdentity.test.ts @@ -0,0 +1,19 @@ +import { describe, expect, it } from "vitest"; +import { hideOpenAiApiProjectId } from "./openAiApiIdentity"; + +describe("hideOpenAiApiProjectId", () => { + it("masks only the project id in OpenAI identity labels", () => { + expect(hideOpenAiApiProjectId("Admin API: proj-private", true)).toBe("Admin API: ••••"); + expect(hideOpenAiApiProjectId("Project: proj-private", true)).toBe("Project: ••••"); + }); + + it("preserves visible identity text when privacy is off", () => { + expect(hideOpenAiApiProjectId("Admin API: proj-private", false)).toBe("Admin API: proj-private"); + }); + + it("does not alter unrelated labels", () => { + expect(hideOpenAiApiProjectId("Admin API", true)).toBe("Admin API"); + expect(hideOpenAiApiProjectId("gpt-5", true)).toBe("gpt-5"); + expect(hideOpenAiApiProjectId(null, true)).toBeNull(); + }); +}); diff --git a/apps/desktop-tauri/src/lib/openAiApiIdentity.ts b/apps/desktop-tauri/src/lib/openAiApiIdentity.ts new file mode 100644 index 0000000000..4e31c20c5b --- /dev/null +++ b/apps/desktop-tauri/src/lib/openAiApiIdentity.ts @@ -0,0 +1,8 @@ +/** Mask OpenAI Admin project ids in generic identity labels when privacy is on. */ +export function hideOpenAiApiProjectId( + value: string | null, + hidePersonalInfo: boolean, +): string | null { + if (!hidePersonalInfo || !value) return value; + return value.replace(/^(Admin API|Project):(\s*).+$/i, "$1:$2••••"); +} diff --git a/apps/desktop-tauri/src/styles.css b/apps/desktop-tauri/src/styles.css index a41405732c..a558b15051 100644 --- a/apps/desktop-tauri/src/styles.css +++ b/apps/desktop-tauri/src/styles.css @@ -2704,6 +2704,21 @@ body:has(.tray-panel-reveal) { height: auto; } +.chart--scrollable { + overflow-x: auto; + overflow-y: hidden; +} + +.chart--scrollable .chart__svg { + flex: 0 0 auto; + width: auto; + max-width: none; +} + +.chart--scrollable .chart__axis { + flex: 0 0 auto; +} + .chart__axis { position: relative; font-size: 0.7rem; @@ -3104,6 +3119,166 @@ body:has(.tray-panel-reveal) { margin-right: 6px; } +/* Selectable bars (OpenAI daily usage): keyboard focus ring on the active bar. */ +.chart__bar[role="option"] { + cursor: pointer; +} + +.chart__bar[role="option"]:focus { + outline: none; +} + +.chart__bar[role="option"]:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 1px; +} + +.chart__bar[data-selected="true"] { + stroke: var(--text-primary); + stroke-width: 1; +} + +/* ── OpenAI Admin API daily usage (tray card + Settings charts) ───── */ + +.openai-usage { + display: flex; + flex-direction: column; + gap: 8px; + min-width: 0; +} + +.openai-usage__tabs { + display: flex; + gap: 4px; +} + +.openai-usage__tab { + background: transparent; + color: var(--text-secondary); + border: 1px solid transparent; + border-radius: 4px; + padding: 3px 8px; + font-size: 11px; + cursor: pointer; +} + +.openai-usage__tab:hover { + color: var(--text-primary); +} + +.openai-usage__tab[data-active="true"] { + color: var(--text-primary); + border-color: var(--panel-border); + background: var(--provider-row-bg); +} + +.openai-usage__tab:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 1px; +} + +.openai-usage__panel { + display: flex; + flex-direction: column; + gap: 8px; + min-width: 0; +} + +.openai-usage__hint { + font-size: 10px; + color: var(--text-muted); +} + +.openai-usage__detail { + display: flex; + flex-direction: column; + gap: 6px; + font-size: 11px; + color: var(--text-primary); +} + +.openai-usage__day { + font-size: 12px; + font-weight: 600; + font-variant-numeric: tabular-nums; +} + +.openai-usage__stats { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(96px, 1fr)); + gap: 6px 16px; + margin: 0; +} + +.openai-usage__stats > div { + display: flex; + flex-direction: column; + gap: 1px; + min-width: 0; +} + +.openai-usage__stats dt { + font-size: 10px; + color: var(--text-secondary); +} + +.openai-usage__stats dd { + margin: 0; + font-variant-numeric: tabular-nums; +} + +.openai-usage__list-title { + font-size: 10px; + color: var(--text-secondary); + margin-bottom: 2px; +} + +.openai-usage__list ul { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; + gap: 2px; +} + +.openai-usage__list li { + display: flex; + justify-content: space-between; + gap: 12px; + min-width: 0; +} + +.openai-usage__name { + min-width: 0; + overflow-wrap: anywhere; +} + +.openai-usage__amount { + flex-shrink: 0; + font-variant-numeric: tabular-nums; + color: var(--text-secondary); +} + +.openai-usage__list li.openai-usage__more { + color: var(--text-muted); +} + +.openai-usage__footer { + display: flex; + justify-content: space-between; + gap: 12px; + padding-top: 6px; + border-top: 1px solid var(--panel-border); + font-size: 11px; + color: var(--text-secondary); +} + +.openai-usage__footer strong { + color: var(--text-primary); + font-variant-numeric: tabular-nums; +} + @media (prefers-reduced-motion: reduce) { .chart__bar, .chart__line, diff --git a/apps/desktop-tauri/src/surfaces/settings/providers/ProviderDetailPane.tsx b/apps/desktop-tauri/src/surfaces/settings/providers/ProviderDetailPane.tsx index 0dadfe492d..12bfe8931a 100644 --- a/apps/desktop-tauri/src/surfaces/settings/providers/ProviderDetailPane.tsx +++ b/apps/desktop-tauri/src/surfaces/settings/providers/ProviderDetailPane.tsx @@ -288,7 +288,12 @@ export function ProviderDetailPane({ return (
- + {detail.id === "codex" && ( @@ -409,6 +414,7 @@ export function ProviderDetailPane({ providerId={detail.id} accountEmail={detail.email} accentColor={providerAccentColors[detail.id]} + openAiApiUsage={detail.openAiApiUsage} t={t} /> diff --git a/apps/desktop-tauri/src/surfaces/settings/providers/sections/IdentitySection.test.tsx b/apps/desktop-tauri/src/surfaces/settings/providers/sections/IdentitySection.test.tsx new file mode 100644 index 0000000000..5631cf95bb --- /dev/null +++ b/apps/desktop-tauri/src/surfaces/settings/providers/sections/IdentitySection.test.tsx @@ -0,0 +1,44 @@ +import { render, screen } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; +import type { LocaleKey } from "../../../../i18n/keys"; +import { IdentitySection } from "./IdentitySection"; + +type IdentityDetails = Parameters[0]["provider"]; + +function detail(overrides: Partial = {}): IdentityDetails { + return { + id: "openaiapi", + displayName: "OpenAI API", + organization: "Project: proj-private", + plan: "Admin API: proj-private", + email: null, + authType: null, + sourceLabel: null, + ...overrides, + }; +} + +const t = (key: LocaleKey) => key; + +describe("IdentitySection OpenAI project privacy", () => { + it("masks project ids in account and plan rows when privacy is enabled", () => { + render( + , + ); + + expect(screen.getByText("Project: ••••")).toBeInTheDocument(); + expect(screen.getByText("Admin API: ••••")).toBeInTheDocument(); + expect(screen.queryByText("proj-private")).toBeNull(); + }); + + it("keeps project ids visible when privacy is disabled", () => { + render(); + + expect(screen.getAllByText(/proj-private/)).toHaveLength(2); + }); +}); diff --git a/apps/desktop-tauri/src/surfaces/settings/providers/sections/IdentitySection.tsx b/apps/desktop-tauri/src/surfaces/settings/providers/sections/IdentitySection.tsx index 624d5d1b07..7fb2b68dbe 100644 --- a/apps/desktop-tauri/src/surfaces/settings/providers/sections/IdentitySection.tsx +++ b/apps/desktop-tauri/src/surfaces/settings/providers/sections/IdentitySection.tsx @@ -1,11 +1,16 @@ import type { ProviderDetail } from "../../../../types/bridge"; import type { LocaleKey } from "../../../../i18n/keys"; import { ProviderIcon } from "../../../../components/providers/ProviderIcon"; +import { hideOpenAiApiProjectId } from "../../../../lib/openAiApiIdentity"; interface Props { - provider: ProviderDetail; + provider: Pick< + ProviderDetail, + "id" | "displayName" | "email" | "organization" | "plan" | "authType" | "sourceLabel" + >; subtitle: string; t: (key: LocaleKey) => string; + hidePersonalInfo?: boolean; } /** @@ -15,10 +20,22 @@ interface Props { * Port of the identity portion of * `rust/src/native_ui/preferences.rs::render_provider_detail_panel` (~4301). */ -export function IdentitySection({ provider, subtitle, t }: Props) { +export function IdentitySection({ + provider, + subtitle, + t, + hidePersonalInfo = false, +}: Props) { + const hideProjectId = provider.id === "openaiapi" && hidePersonalInfo; const rows: { label: string; value: string | null }[] = [ - { label: t("Account"), value: provider.email ?? provider.organization }, - { label: t("Plan"), value: displayIdentityValue(provider.plan, t) }, + { + label: t("Account"), + value: hideOpenAiApiProjectId(provider.email ?? provider.organization, hideProjectId), + }, + { + label: t("Plan"), + value: hideOpenAiApiProjectId(displayIdentityValue(provider.plan, t), hideProjectId), + }, { label: t("AuthType"), value: provider.authType }, { label: t("DataSource"), value: provider.sourceLabel }, ]; diff --git a/apps/desktop-tauri/src/surfaces/settings/providers/sections/charts/ChartsSection.test.tsx b/apps/desktop-tauri/src/surfaces/settings/providers/sections/charts/ChartsSection.test.tsx index ade4cdcd3b..0268b6e616 100644 --- a/apps/desktop-tauri/src/surfaces/settings/providers/sections/charts/ChartsSection.test.tsx +++ b/apps/desktop-tauri/src/surfaces/settings/providers/sections/charts/ChartsSection.test.tsx @@ -2,7 +2,7 @@ import { describe, expect, it, vi } from "vitest"; import { render, screen, waitFor } from "@testing-library/react"; import { ChartsSection } from "./ChartsSection"; import { getProviderChartData } from "../../../../../lib/tauri"; -import type { ProviderChartData } from "../../../../../types/bridge"; +import type { OpenAiApiUsageSnapshot, ProviderChartData } from "../../../../../types/bridge"; vi.mock("../../../../../lib/tauri", () => ({ getProviderChartData: vi.fn(), @@ -93,3 +93,75 @@ describe("ChartsSection tokens mode (upstream 0.50.0 #2930)", () => { expect(screen.queryByText("DetailChartTokens")).toBeNull(); }); }); + +describe("ChartsSection OpenAI API daily usage (upstream 0.66.0)", () => { + const openAiUsage: OpenAiApiUsageSnapshot = { + historyDays: 30, + projectId: null, + daily: [ + { + startTime: 1_788_220_800, + endTime: 1_788_307_200, + costUsd: 2.5, + requests: 12, + inputTokens: 1000, + cachedInputTokens: 100, + outputTokens: 400, + totalTokens: 1400, + lineItems: [{ name: "Text tokens", costUsd: 2.5 }], + models: [], + }, + ], + }; + + it("draws the per-day chart for openaiapi instead of the local-log tabs", async () => { + mockChart.mockResolvedValue(chartData({ providerId: "openaiapi" })); + render( + key} + />, + ); + expect(screen.getByText("OpenAIChartTitle", { selector: ".provider-detail-chart__title" })).toBeTruthy(); + expect(screen.getByRole("listbox")).toBeTruthy(); + expect(screen.getByText("Text tokens")).toBeTruthy(); + await waitFor(() => expect(mockChart).toHaveBeenCalled()); + expect(screen.queryByText("DetailChartCost")).toBeNull(); + }); + + it("renders nothing for openaiapi without usage or with an empty window", () => { + mockChart.mockResolvedValue(chartData({ providerId: "openaiapi" })); + const { container, rerender } = render( + key} />, + ); + expect(container.firstChild).toBeNull(); + rerender( + key} + />, + ); + expect(container.firstChild).toBeNull(); + }); + + it("ignores an OpenAI snapshot handed to another provider", async () => { + mockChart.mockResolvedValue(chartData({ providerId: "claude" })); + render( + key} + />, + ); + await waitFor(() => { + expect(screen.getByRole("tab", { selected: true }).textContent).toBe("DetailChartCost"); + }); + expect(screen.queryByText("OpenAIChartTitle")).toBeNull(); + expect(screen.queryByRole("listbox")).toBeNull(); + }); +}); diff --git a/apps/desktop-tauri/src/surfaces/settings/providers/sections/charts/ChartsSection.tsx b/apps/desktop-tauri/src/surfaces/settings/providers/sections/charts/ChartsSection.tsx index 8257e3069c..30bf0b3a06 100644 --- a/apps/desktop-tauri/src/surfaces/settings/providers/sections/charts/ChartsSection.tsx +++ b/apps/desktop-tauri/src/surfaces/settings/providers/sections/charts/ChartsSection.tsx @@ -1,8 +1,13 @@ import { type CSSProperties, useEffect, useState } from "react"; import { getProviderChartData, getSettingsSnapshot } from "../../../../../lib/tauri"; import { providerSupportsChartData } from "../../../../../lib/providerCharts"; -import type { ProviderChartData, SettingsSnapshot } from "../../../../../types/bridge"; +import type { + OpenAiApiUsageSnapshot, + ProviderChartData, + SettingsSnapshot, +} from "../../../../../types/bridge"; import type { useLocale } from "../../../../../hooks/useLocale"; +import { OpenAIApiUsageChart } from "../../../../../components/OpenAIApiUsageChart"; import { CostHistoryChart } from "./CostHistoryChart"; import { CreditsHistoryChart } from "./CreditsHistoryChart"; import { TokensHistoryChart } from "./TokensHistoryChart"; @@ -15,6 +20,8 @@ interface Props { accountEmail: string | null; /** Per-provider accent color override (hex); applied as CSS --provider-accent. */ accentColor?: string; + /** Per-day OpenAI Admin API history (`openaiapi` only); drawn instead of the local-log tabs. */ + openAiApiUsage?: OpenAiApiUsageSnapshot | null; t: T; } @@ -29,7 +36,13 @@ type TabKey = "tokens" | "cost" | "credits" | "usage"; * Phase 10: fetches the latest settings snapshot so the animation flag feeds * through to each chart component. */ -export function ChartsSection({ providerId, accountEmail, accentColor, t }: Props) { +export function ChartsSection({ + providerId, + accountEmail, + accentColor, + openAiApiUsage, + t, +}: Props) { const [data, setData] = useState(null); const [active, setActive] = useState(null); const [animations, setAnimations] = useState(true); @@ -71,6 +84,19 @@ export function ChartsSection({ providerId, accountEmail, accentColor, t }: Prop }; }, [providerId]); + if (providerId === "openaiapi") { + if (!openAiApiUsage || openAiApiUsage.daily.length === 0) return null; + return ( +
+
{t("OpenAIChartTitle")}
+ +
+ ); + } + if (!data) return null; const hasCost = data.costHistory.length > 0; diff --git a/apps/desktop-tauri/src/types/bridge.ts b/apps/desktop-tauri/src/types/bridge.ts index ee6bdef0f4..01560daed3 100644 --- a/apps/desktop-tauri/src/types/bridge.ts +++ b/apps/desktop-tauri/src/types/bridge.ts @@ -1024,6 +1024,8 @@ export interface ProviderDetail { cost: CostSnapshotBridge | null; pace: PaceSnapshot | null; + /** Per-UTC-day OpenAI Admin API history; only the `openaiapi` Admin path sets it. */ + openAiApiUsage?: OpenAiApiUsageSnapshot | null; lastError: string | null; errorState: ProviderStateKind | null; diff --git a/rust/src/locale.rs b/rust/src/locale.rs index e6ec918ac4..dff8d022c8 100644 --- a/rust/src/locale.rs +++ b/rust/src/locale.rs @@ -906,6 +906,19 @@ locale_keys! { DetailChartCredits, DetailChartUsageBreakdown, DetailChartEmpty, + OpenAIChartTitle, + OpenAIChartMetricCost, + OpenAIChartMetricTokens, + OpenAIChartHint, + OpenAIChartRequests, + OpenAIChartInputTokens, + OpenAIChartCachedTokens, + OpenAIChartOutputTokens, + OpenAIChartLineItems, + OpenAIChartModels, + OpenAIChartMore, + OpenAIChartWindowToday, + OpenAIChartWindowDays, DetailUpdatedPrefix, PanelAllProviders, PanelAllProvidersShort, diff --git a/rust/src/locale/en-US.ftl b/rust/src/locale/en-US.ftl index fb96ecd4b8..b3738e3bb9 100644 --- a/rust/src/locale/en-US.ftl +++ b/rust/src/locale/en-US.ftl @@ -569,6 +569,19 @@ DetailChartRefreshing = Refreshing… DetailChartCredits = Credits used (30 days) DetailChartUsageBreakdown = Usage by service (30 days) DetailChartEmpty = No chart data yet. +OpenAIChartTitle = Daily API usage +OpenAIChartMetricCost = Cost +OpenAIChartMetricTokens = Tokens +OpenAIChartHint = Hover or focus a bar for details +OpenAIChartRequests = Requests +OpenAIChartInputTokens = Input tokens +OpenAIChartCachedTokens = Cached input tokens +OpenAIChartOutputTokens = Output tokens +OpenAIChartLineItems = Cost by line item +OpenAIChartModels = Models +OpenAIChartMore = +{ "{}" } more +OpenAIChartWindowToday = Today +OpenAIChartWindowDays = Last { "{}" } days DetailUpdatedPrefix = Updated PanelAllProviders = All providers PanelAllProvidersShort = All diff --git a/rust/src/locale/es-MX.ftl b/rust/src/locale/es-MX.ftl index 38dc0b05ae..d3b6cb5eb7 100644 --- a/rust/src/locale/es-MX.ftl +++ b/rust/src/locale/es-MX.ftl @@ -525,6 +525,19 @@ DetailChartRefreshing = Actualizando… DetailChartCredits = Créditos usados (30 días) DetailChartUsageBreakdown = Uso por servicio (30 días) DetailChartEmpty = Sin datos de gráfico aún. +OpenAIChartTitle = Uso diario de la API +OpenAIChartMetricCost = Costo +OpenAIChartMetricTokens = Tokens +OpenAIChartHint = Pasa el cursor o enfoca una barra para ver detalles +OpenAIChartRequests = Solicitudes +OpenAIChartInputTokens = Tokens de entrada +OpenAIChartCachedTokens = Tokens de entrada en caché +OpenAIChartOutputTokens = Tokens de salida +OpenAIChartLineItems = Costo por concepto +OpenAIChartModels = Modelos +OpenAIChartMore = +{ "{}" } más +OpenAIChartWindowToday = Hoy +OpenAIChartWindowDays = Últimos { "{}" } días DetailUpdatedPrefix = Actualizado PanelAllProviders = Todos los proveedores PanelAllProvidersShort = Todos diff --git a/rust/src/locale/ja-JP.ftl b/rust/src/locale/ja-JP.ftl index b6c2dc0fc8..b9a86c6fb0 100644 --- a/rust/src/locale/ja-JP.ftl +++ b/rust/src/locale/ja-JP.ftl @@ -507,6 +507,19 @@ DetailChartRefreshing = 更新中… DetailChartCredits = 使用クレジット(30日間) DetailChartUsageBreakdown = サービス別使用量(30日間) DetailChartEmpty = まだチャートデータはありません。 +OpenAIChartTitle = 日別 API 使用量 +OpenAIChartMetricCost = コスト +OpenAIChartMetricTokens = トークン +OpenAIChartHint = バーにカーソルを合わせるかフォーカスすると詳細を表示します +OpenAIChartRequests = リクエスト +OpenAIChartInputTokens = 入力トークン +OpenAIChartCachedTokens = キャッシュされた入力トークン +OpenAIChartOutputTokens = 出力トークン +OpenAIChartLineItems = 項目別コスト +OpenAIChartModels = モデル +OpenAIChartMore = 他 { "{}" } 件 +OpenAIChartWindowToday = 今日 +OpenAIChartWindowDays = 過去 { "{}" } 日間 DetailUpdatedPrefix = 更新 PanelAllProviders = すべてのプロバイダー PanelAllProvidersShort = すべて diff --git a/rust/src/locale/ko-KR.ftl b/rust/src/locale/ko-KR.ftl index 299dcf1a54..f1001fb6f7 100644 --- a/rust/src/locale/ko-KR.ftl +++ b/rust/src/locale/ko-KR.ftl @@ -512,6 +512,19 @@ DetailChartRefreshing = 새로 고치는 중… DetailChartCredits = 사용 크레딧 (30일) DetailChartUsageBreakdown = 서비스별 사용량 (30일) DetailChartEmpty = 아직 차트 데이터가 없습니다. +OpenAIChartTitle = 일별 API 사용량 +OpenAIChartMetricCost = 비용 +OpenAIChartMetricTokens = 토큰 +OpenAIChartHint = 막대에 마우스를 올리거나 포커스하면 세부 정보가 표시됩니다 +OpenAIChartRequests = 요청 +OpenAIChartInputTokens = 입력 토큰 +OpenAIChartCachedTokens = 캐시된 입력 토큰 +OpenAIChartOutputTokens = 출력 토큰 +OpenAIChartLineItems = 항목별 비용 +OpenAIChartModels = 모델 +OpenAIChartMore = +{ "{}" }개 더 +OpenAIChartWindowToday = 오늘 +OpenAIChartWindowDays = 최근 { "{}" }일 DetailUpdatedPrefix = 업데이트됨 PanelAllProviders = 모든 제공자 PanelAllProvidersShort = 전체 diff --git a/rust/src/locale/ru-RU.ftl b/rust/src/locale/ru-RU.ftl index f978d540e7..378349e707 100644 --- a/rust/src/locale/ru-RU.ftl +++ b/rust/src/locale/ru-RU.ftl @@ -491,6 +491,19 @@ DetailChartRefreshing = Обновление… DetailChartCredits = Использовано кредитов (30 дней) DetailChartUsageBreakdown = Использование службой (30 дней) DetailChartEmpty = Данных диаграммы пока нет. +OpenAIChartTitle = Ежедневное использование API +OpenAIChartMetricCost = Стоимость +OpenAIChartMetricTokens = Токены +OpenAIChartHint = Наведите курсор на столбец или выберите его, чтобы увидеть подробности +OpenAIChartRequests = Запросы +OpenAIChartInputTokens = Входные токены +OpenAIChartCachedTokens = Кэшированные входные токены +OpenAIChartOutputTokens = Выходные токены +OpenAIChartLineItems = Стоимость по статьям +OpenAIChartModels = Модели +OpenAIChartMore = ещё { "{}" } +OpenAIChartWindowToday = Сегодня +OpenAIChartWindowDays = Последние { "{}" } дн. DetailUpdatedPrefix = Обновлено PanelAllProviders = Все провайдеры PanelAllProvidersShort = Все diff --git a/rust/src/locale/tr-TR.ftl b/rust/src/locale/tr-TR.ftl index 08de23e2e3..b63298695c 100644 --- a/rust/src/locale/tr-TR.ftl +++ b/rust/src/locale/tr-TR.ftl @@ -526,6 +526,19 @@ DetailChartCost = Maliyet (30 gün) DetailChartCredits = Kullanılan krediler (30 gün) DetailChartUsageBreakdown = Hizmete göre kullanım (30 gün) DetailChartEmpty = Henüz grafik verisi yok. +OpenAIChartTitle = Günlük API kullanımı +OpenAIChartMetricCost = Maliyet +OpenAIChartMetricTokens = Token +OpenAIChartHint = Ayrıntılar için bir çubuğun üzerine gelin veya odaklanın +OpenAIChartRequests = İstekler +OpenAIChartInputTokens = Girdi token'ları +OpenAIChartCachedTokens = Önbelleğe alınan girdi token'ları +OpenAIChartOutputTokens = Çıktı token'ları +OpenAIChartLineItems = Kalem bazında maliyet +OpenAIChartModels = Modeller +OpenAIChartMore = +{ "{}" } daha +OpenAIChartWindowToday = Bugün +OpenAIChartWindowDays = Son { "{}" } gün DetailUpdatedPrefix = Güncellendi PanelAllProviders = Tüm sağlayıcılar PanelAllProvidersShort = Tümü diff --git a/rust/src/locale/zh-CN.ftl b/rust/src/locale/zh-CN.ftl index daf8e3c9af..6032c75474 100644 --- a/rust/src/locale/zh-CN.ftl +++ b/rust/src/locale/zh-CN.ftl @@ -506,6 +506,19 @@ DetailChartRefreshing = 刷新中… DetailChartCredits = 已用额度(30 天) DetailChartUsageBreakdown = 按服务划分的用量(30 天) DetailChartEmpty = 暂无图表数据。 +OpenAIChartTitle = 每日 API 用量 +OpenAIChartMetricCost = 费用 +OpenAIChartMetricTokens = Token +OpenAIChartHint = 悬停或聚焦柱形以查看详情 +OpenAIChartRequests = 请求 +OpenAIChartInputTokens = 输入 Token +OpenAIChartCachedTokens = 缓存输入 Token +OpenAIChartOutputTokens = 输出 Token +OpenAIChartLineItems = 按项目费用 +OpenAIChartModels = 模型 +OpenAIChartMore = 另有 { "{}" } 项 +OpenAIChartWindowToday = 今天 +OpenAIChartWindowDays = 最近 { "{}" } 天 DetailUpdatedPrefix = 更新于 PanelAllProviders = 所有提供者 PanelAllProvidersShort = 全部 diff --git a/rust/src/locale/zh-TW.ftl b/rust/src/locale/zh-TW.ftl index 31bb3bcf02..2af948efb9 100644 --- a/rust/src/locale/zh-TW.ftl +++ b/rust/src/locale/zh-TW.ftl @@ -506,6 +506,19 @@ DetailChartRefreshing = 重新整理中… DetailChartCredits = 已用額度(30 天) DetailChartUsageBreakdown = 按服務劃分的用量(30 天) DetailChartEmpty = 暫無圖表資料。 +OpenAIChartTitle = 每日 API 用量 +OpenAIChartMetricCost = 費用 +OpenAIChartMetricTokens = Token +OpenAIChartHint = 將滑鼠移到或聚焦長條即可查看詳細資料 +OpenAIChartRequests = 請求 +OpenAIChartInputTokens = 輸入 Token +OpenAIChartCachedTokens = 快取輸入 Token +OpenAIChartOutputTokens = 輸出 Token +OpenAIChartLineItems = 依項目費用 +OpenAIChartModels = 模型 +OpenAIChartMore = 另有 { "{}" } 項 +OpenAIChartWindowToday = 今天 +OpenAIChartWindowDays = 最近 { "{}" } 天 DetailUpdatedPrefix = 更新於 PanelAllProviders = 所有提供者 PanelAllProvidersShort = 全部