diff --git a/src/main/services/snapshot-service.ts b/src/main/services/snapshot-service.ts index ab09e6e8..74ec0f6e 100644 --- a/src/main/services/snapshot-service.ts +++ b/src/main/services/snapshot-service.ts @@ -8,6 +8,7 @@ type Row = Record; type ProviderUsageWindowRow = Pick< AppSnapshot["providerRateLimits"][number], "orgId" | "providerId" | "windowStart" | "windowEnd" | "requestCount" | "inputCharCount" | "outputCharCount" + | "costCents" | "inputTokens" | "outputTokens" | "requestsWithoutUsage" | "unpricedRequests" >; function all(statement: StatementSync): T[] { @@ -224,6 +225,11 @@ function buildProviderRateLimits( outputCharCount, remainingRequestCount, exhausted: remainingRequestCount === 0, + costCents: usage?.costCents ?? 0, + inputTokens: usage?.inputTokens ?? 0, + outputTokens: usage?.outputTokens ?? 0, + requestsWithoutUsage: usage?.requestsWithoutUsage ?? 0, + unpricedRequests: usage?.unpricedRequests ?? 0, }; }); } diff --git a/src/renderer/src/i18n/zh.ts b/src/renderer/src/i18n/zh.ts index 0ccf3b34..f1233d5f 100644 --- a/src/renderer/src/i18n/zh.ts +++ b/src/renderer/src/i18n/zh.ts @@ -32,6 +32,10 @@ export const zh: Record = { "superseded": "已被取代", "stalled": "已停滞", "Resume": "恢复", + "Spent this month": "本月已花费", + "${spent} of ${allocated} allocated to agents": "已花 ${spent} / 分配给 Agent 的 ${allocated}", + "{count} calls not priced": "{count} 次调用未计价", + "Allocations use {percent}% of department caps": "分配额度占部门上限的 {percent}%", "Spent": "已花费", "Tokens": "Token", "not reported": "未报告", diff --git a/src/renderer/src/pages/SettingsPage.tsx b/src/renderer/src/pages/SettingsPage.tsx index 7643a1cd..0e46ff31 100644 --- a/src/renderer/src/pages/SettingsPage.tsx +++ b/src/renderer/src/pages/SettingsPage.tsx @@ -67,6 +67,7 @@ export function SettingsPage({ snapshot, actions }: PageProps) { catalog={snapshot.providerCatalog} workspaces={workspaces} providerRequests={providerRequests} + providerRateLimits={providerRateLimits} hiringPolicies={hiringPolicies} actions={actions} /> diff --git a/src/renderer/src/pages/settings/AdvancedPanel.tsx b/src/renderer/src/pages/settings/AdvancedPanel.tsx index a15a8c7c..21579360 100644 --- a/src/renderer/src/pages/settings/AdvancedPanel.tsx +++ b/src/renderer/src/pages/settings/AdvancedPanel.tsx @@ -1,3 +1,4 @@ +import type { ProviderRateLimitStateRecord } from "../../../../shared/provider-runtime-domain"; import type { AgentRecord, HiringPolicyRecord, @@ -19,6 +20,7 @@ export function AdvancedPanel({ catalog, workspaces, providerRequests, + providerRateLimits, hiringPolicies, actions, }: { @@ -28,6 +30,7 @@ export function AdvancedPanel({ catalog: ProviderCatalogEntry[]; workspaces: WorkspaceRecord[]; providerRequests: ProviderRequestRecord[]; + providerRateLimits: ProviderRateLimitStateRecord[]; hiringPolicies: HiringPolicyRecord[]; actions: SnapshotActions; }) { @@ -50,7 +53,7 @@ export function AdvancedPanel({ )}
- +
); diff --git a/src/renderer/src/pages/settings/BillingPanel.tsx b/src/renderer/src/pages/settings/BillingPanel.tsx index 349181f4..24188551 100644 --- a/src/renderer/src/pages/settings/BillingPanel.tsx +++ b/src/renderer/src/pages/settings/BillingPanel.tsx @@ -2,19 +2,25 @@ import { Avatar, Pill } from "../../shared/ui"; import type { AgentRecord, HiringPolicyRecord, ProviderRecord, ProviderRequestRecord } from "../../../../shared/domain"; import { money } from "../types"; import { buildAgentSpend } from "./billing-helpers"; +import type { ProviderRateLimitStateRecord } from "../../../../shared/provider-runtime-domain"; import { t } from "../../i18n/i18n"; export function BillingPanel({ agents, providers, providerRequests, + providerRateLimits, hiringPolicies, }: { agents: AgentRecord[]; providers: ProviderRecord[]; providerRequests: ProviderRequestRecord[]; + providerRateLimits: ProviderRateLimitStateRecord[]; hiringPolicies: HiringPolicyRecord[]; }) { + // Month to date, from the provider totals: every call counts, not only the ones loaded into the snapshot. + const spentCents = providerRateLimits.reduce((s, usage) => s + usage.costCents, 0); + const unpricedCalls = providerRateLimits.reduce((s, usage) => s + usage.unpricedRequests + usage.requestsWithoutUsage, 0); const totalBudget = agents.reduce((sum, a) => sum + a.budgetCents, 0); const totalCap = hiringPolicies.reduce((sum, p) => sum + p.monthlyBudgetLimitCents, 0); const monthRequests = providerRequests.filter((r) => withinDays(r.createdAt, 30)); @@ -50,12 +56,16 @@ export function BillingPanel({
-
{t("Budget utilization")}
+
{t("Spent this month")}
-
0 ? Math.min(100, Math.round((totalBudget / totalCap) * 100)) : 0}%`, height: "100%", background: "var(--accent)" }} /> +
0 ? Math.min(100, Math.round((spentCents / totalBudget) * 100)) : 0}%`, height: "100%", background: "var(--accent)" }} />
- {totalCap > 0 ? t("{percent}% of cap", { percent: Math.round((totalBudget / totalCap) * 100) }) : t("No budget cap set")} + {t("${spent} of ${allocated} allocated to agents", { spent: (spentCents / 100).toFixed(2), allocated: (totalBudget / 100).toFixed(2) })} + {unpricedCalls > 0 && <> · {t("{count} calls not priced", { count: unpricedCalls })}} +
+
+ {totalCap > 0 ? t("Allocations use {percent}% of department caps", { percent: Math.round((totalBudget / totalCap) * 100) }) : t("No budget cap set")}
diff --git a/src/shared/provider-runtime-domain.ts b/src/shared/provider-runtime-domain.ts index 1f2bb2c7..73857561 100644 --- a/src/shared/provider-runtime-domain.ts +++ b/src/shared/provider-runtime-domain.ts @@ -10,6 +10,12 @@ export interface ProviderRateLimitStateRecord { outputCharCount: number; remainingRequestCount: number; exhausted: boolean; + /** This month's provider-reported spend and tokens; calls without usage or prices are counted, not guessed. */ + costCents: number; + inputTokens: number; + outputTokens: number; + requestsWithoutUsage: number; + unpricedRequests: number; } export interface ProviderObservedRateLimitState { diff --git a/tests/unit/usage-settlement.test.ts b/tests/unit/usage-settlement.test.ts index be73aecc..7f253ef6 100644 --- a/tests/unit/usage-settlement.test.ts +++ b/tests/unit/usage-settlement.test.ts @@ -5,6 +5,7 @@ import { describe, expect, test } from "vitest"; import { DatabaseConnection } from "../../src/main/database/connection"; import { OrganizationService } from "../../src/main/services/organization-service"; import { ProviderRequestStore } from "../../src/main/services/provider-request-store"; +import { SnapshotService } from "../../src/main/services/snapshot-service"; import { PROVIDER_EXECUTION_MODE } from "../../src/shared/domain"; describe("usage settlement", () => { @@ -39,6 +40,9 @@ describe("usage settlement", () => { `).get(provider.id)).toEqual({ request_count: 2, input_tokens: 1_000_000, output_tokens: 100_000, cached_input_tokens: 400_000, cost_cents: 342, requests_without_usage: 1, unpriced_requests: 0, }); + // The month's spend reaches the renderer from these totals, not from the capped list of recent requests. + const limits = new SnapshotService(connection.db).load().providerRateLimits.find((item) => item.providerId === provider.id); + expect(limits).toMatchObject({ costCents: 342, inputTokens: 1_000_000, outputTokens: 100_000, requestsWithoutUsage: 1, unpricedRequests: 0 }); connection.close(); }); });