diff --git a/scripts/repair-direct-openai-cost-events.ts b/scripts/repair-direct-openai-cost-events.ts index 581fadcea1f..354ded5d34d 100644 --- a/scripts/repair-direct-openai-cost-events.ts +++ b/scripts/repair-direct-openai-cost-events.ts @@ -401,6 +401,7 @@ async function applyRepairs(sql: PostgresSql, repairs: Repair[]) { const affectedAgents = new Map(); const affectedCompanies = new Set(); + let updatedEvents = 0; await sql.begin(async (tx) => { for (const repair of repairs) { const result = await tx` @@ -417,6 +418,7 @@ async function applyRepairs(sql: PostgresSql, repairs: Repair[]) { AND (input_tokens + cached_input_tokens + output_tokens) > 0 `; if (result.count > 0) { + updatedEvents += result.count; affectedAgents.set(repair.agentId, repair.companyId); affectedCompanies.add(repair.companyId); } @@ -459,7 +461,7 @@ async function applyRepairs(sql: PostgresSql, repairs: Repair[]) { }); return { - updatedEvents: repairs.length, + updatedEvents, recomputedAgents: affectedAgents.size, recomputedCompanies: affectedCompanies.size, }; diff --git a/server/src/__tests__/openai-cost-estimates.test.ts b/server/src/__tests__/openai-cost-estimates.test.ts index f8e0f7a6e55..ca65467b347 100644 --- a/server/src/__tests__/openai-cost-estimates.test.ts +++ b/server/src/__tests__/openai-cost-estimates.test.ts @@ -28,6 +28,26 @@ describe("resolveCostProvenance", () => { }); }); + it("keeps provider-reported zero costs as reported", () => { + expect( + resolveCostProvenance({ + provider: "openai", + biller: "openai", + model: "gpt-5.5", + billingType: "metered_api", + costUsd: 0, + usage: { inputTokens: 1_000_000, outputTokens: 10_000, cachedInputTokens: 0 }, + costSource: "reported", + }), + ).toEqual({ + costUsd: 0, + costSource: "reported", + costMetadata: { + costUsd: 0, + }, + }); + }); + it("estimates direct OpenAI metered API cost from the maintained rate table", () => { const resolved = resolveCostProvenance({ provider: "openai", @@ -81,6 +101,53 @@ describe("resolveCostProvenance", () => { expect(resolved.costMetadata.rateSource).toBe("test-rate-card"); }); + it("refreshes the configured model rate cache when the env changes in-process", () => { + vi.stubEnv( + "PAPERCLIP_OPENAI_MODEL_RATES_JSON", + JSON.stringify({ + "gpt-cache-test": { + inputUsdPerMillion: 1, + outputUsdPerMillion: 1, + source: "first-rate-card", + }, + }), + ); + + expect( + resolveCostProvenance({ + provider: "openai", + biller: "openai", + model: "gpt-cache-test", + billingType: "metered_api", + costUsd: null, + usage: { inputTokens: 1_000_000, outputTokens: 0 }, + }).costMetadata.rateSource, + ).toBe("first-rate-card"); + + vi.stubEnv( + "PAPERCLIP_OPENAI_MODEL_RATES_JSON", + JSON.stringify({ + "gpt-cache-test": { + inputUsdPerMillion: 2, + outputUsdPerMillion: 1, + source: "second-rate-card", + }, + }), + ); + + const resolved = resolveCostProvenance({ + provider: "openai", + biller: "openai", + model: "gpt-cache-test", + billingType: "metered_api", + costUsd: null, + usage: { inputTokens: 1_000_000, outputTokens: 0 }, + }); + + expect(resolved.costUsd).toBe(2); + expect(resolved.costMetadata.rateSource).toBe("second-rate-card"); + }); + it("marks unpriced metered usage unavailable when no reported cost or rate exists", () => { expect( resolveCostProvenance({ diff --git a/server/src/services/openai-cost-estimates.ts b/server/src/services/openai-cost-estimates.ts index 9a072577645..e8201383666 100644 --- a/server/src/services/openai-cost-estimates.ts +++ b/server/src/services/openai-cost-estimates.ts @@ -28,9 +28,21 @@ const DEFAULT_OPENAI_MODEL_RATES: Record = { "gpt-5.2": { inputUsdPerMillion: 1.75, cachedInputUsdPerMillion: 0.175, outputUsdPerMillion: 14, source: "openai_api_pricing_2026-05-19" }, }; +let configuredRatesCacheRaw: string | undefined; +let configuredRatesCache: Record | null = null; + function readConfiguredOpenAiRates(): Record { const raw = process.env.PAPERCLIP_OPENAI_MODEL_RATES_JSON; - if (!raw?.trim()) return {}; + if (configuredRatesCache !== null && configuredRatesCacheRaw === raw) { + return configuredRatesCache; + } + + configuredRatesCacheRaw = raw; + if (!raw?.trim()) { + configuredRatesCache = {}; + return configuredRatesCache; + } + try { const parsed = JSON.parse(raw) as Record; const rates: Record = {}; @@ -50,9 +62,11 @@ function readConfiguredOpenAiRates(): Record { source: typeof record.source === "string" && record.source.trim() ? record.source.trim() : "env:PAPERCLIP_OPENAI_MODEL_RATES_JSON", }; } - return rates; + configuredRatesCache = rates; + return configuredRatesCache; } catch { - return {}; + configuredRatesCache = {}; + return configuredRatesCache; } } @@ -117,7 +131,7 @@ export function resolveCostProvenance(result: Pick< AdapterExecutionResult, "provider" | "biller" | "model" | "billingType" | "costUsd" | "costSource" | "costMetadata" | "usage" >): ResolvedCostProvenance { - if (typeof result.costUsd === "number" && Number.isFinite(result.costUsd) && result.costUsd > 0) { + if (typeof result.costUsd === "number" && Number.isFinite(result.costUsd) && result.costUsd >= 0) { return { costUsd: result.costUsd, costSource: result.costSource === "estimated" ? "estimated" : "reported", diff --git a/ui/src/components/CostProvenanceNotice.tsx b/ui/src/components/CostProvenanceNotice.tsx index da1a84dce10..7f2e8650fb2 100644 --- a/ui/src/components/CostProvenanceNotice.tsx +++ b/ui/src/components/CostProvenanceNotice.tsx @@ -30,6 +30,7 @@ export function unavailableMeteredTokens(totals: CostProvenanceTotals | null | u export function hasCostProvenanceWarning(totals: CostProvenanceTotals | null | undefined) { return (totals?.estimatedMeteredCostCents ?? 0) > 0 || + (totals?.estimatedMeteredEventCount ?? 0) > 0 || unavailableMeteredTokens(totals) > 0 || (totals?.unavailableMeteredEventCount ?? 0) > 0; } @@ -37,9 +38,12 @@ export function hasCostProvenanceWarning(totals: CostProvenanceTotals | null | u export function formatCostProvenanceSummary(totals: CostProvenanceTotals | null | undefined) { const parts: string[] = []; const estimatedCost = totals?.estimatedMeteredCostCents ?? 0; + const estimatedEvents = totals?.estimatedMeteredEventCount ?? 0; const unpricedTokens = unavailableMeteredTokens(totals); if (estimatedCost > 0) { parts.push(`${formatCents(estimatedCost)} estimated metered API`); + } else if (estimatedEvents > 0) { + parts.push(`${estimatedEvents} estimated metered API event${estimatedEvents === 1 ? "" : "s"}`); } if (unpricedTokens > 0) { parts.push(`${formatTokens(unpricedTokens)} unpriced metered API`); @@ -75,9 +79,9 @@ export function CostProvenanceNotice({ return (
- {estimatedCost > 0 ? ( + {estimatedCost > 0 || estimatedEvents > 0 ? (
- {formatCents(estimatedCost)} is estimated metered API spend + {estimatedCost > 0 ? `${formatCents(estimatedCost)} is estimated metered API spend` : "Metered API spend is estimated"} {estimatedEvents > 0 ? ` across ${estimatedEvents} event${estimatedEvents === 1 ? "" : "s"}` : ""}.
) : null} diff --git a/ui/src/pages/Costs.test.tsx b/ui/src/pages/Costs.test.tsx index 5824f19e7ce..a5f7f6eac89 100644 --- a/ui/src/pages/Costs.test.tsx +++ b/ui/src/pages/Costs.test.tsx @@ -4,6 +4,7 @@ import { act } from "react"; import { createRoot } from "react-dom/client"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { formatCostProvenanceSummary, hasCostProvenanceWarning } from "../components/CostProvenanceNotice"; import { Costs } from "./Costs"; const mockCostsApi = vi.hoisted(() => ({ @@ -188,4 +189,17 @@ describe("Costs", () => { expect(container.textContent).toContain("$2.50 estimated metered API"); expect(container.textContent).toContain("1.3k unpriced metered API"); }); + + it("warns for estimated metered events even when spend rounds to zero cents", () => { + const zeroCentEstimated = { + estimatedMeteredCostCents: 0, + estimatedMeteredInputTokens: 1, + estimatedMeteredCachedInputTokens: 0, + estimatedMeteredOutputTokens: 1, + estimatedMeteredEventCount: 1, + }; + + expect(hasCostProvenanceWarning(zeroCentEstimated)).toBe(true); + expect(formatCostProvenanceSummary(zeroCentEstimated)).toBe("1 estimated metered API event"); + }); });