diff --git a/app/api/pymthouse/account-requests/route.ts b/app/api/pymthouse/account-requests/route.ts index f79d2150..da90ab9b 100644 --- a/app/api/pymthouse/account-requests/route.ts +++ b/app/api/pymthouse/account-requests/route.ts @@ -53,6 +53,7 @@ export async function GET(request: NextRequest) { email: session.email, cursor: next, limit, + ...(includeCorrelated ? { recentWindow: true } : {}), }); if ( payload.externalUserId !== session.externalUserId || diff --git a/components/console/CallsSection.tsx b/components/console/CallsSection.tsx index d4bc8c7d..e94d26bf 100644 --- a/components/console/CallsSection.tsx +++ b/components/console/CallsSection.tsx @@ -6,6 +6,7 @@ import SectionHeader from "@/components/console/SectionHeader"; import CallsTable from "@/components/console/CallsTable"; import CallDetailDrawer from "@/components/console/CallDetailDrawer"; import { useAuth } from "@/components/console/AuthContext"; +import { matchRunTicketFees } from "@/lib/console/activity-output-match"; import { useAccountRequests } from "@/lib/console/useAccountRequests"; import { useRunDetail, useRunHistory } from "@/lib/console/useRunHistory"; import { runToActivity } from "@/lib/console/run-activity"; @@ -28,23 +29,50 @@ export default function CallsSection({ }, ownerKey ); + const requestId = useSearchParams().get("request"); + const detail = useRunDetail( + "/api/console/runs", + requestId, + ownerKey, + isConnected + ); // Correlate billing receipts with saved runs; billing is not a second history feed. const billing = useAccountRequests(isConnected, ownerKey, true); const billingRows = billing.status === "ready" ? billing.rows : null; const feeByGateway = useMemo(() => { - const fees = new Map(); - if (!billingRows) return fees; - for (const row of billingRows) { - if (!row.gatewayRequestId || row.costDisplay === "—") continue; - fees.set(row.gatewayRequestId, { - costDisplay: row.costDisplay, - ...(row.costExact ? { costExact: row.costExact } : {}), - }); - } - return fees; - }, [billingRows]); + if (!billingRows) return new Map(); + const tickets = billingRows.flatMap((row) => + row.gatewayRequestId && row.costDisplay !== "—" + ? [ + { + gatewayRequestId: row.gatewayRequestId, + modelId: row.capabilityId ?? "", + time: row.timestamp, + costDisplay: row.costDisplay, + ...(row.costExact ? { costExact: row.costExact } : {}), + }, + ] + : [] + ); + const runs = [ + ...(history.page?.items ?? []).map((run) => ({ + gatewayRequestId: run.gatewayRequestId, + capability: run.modelId ?? run.capability, + createdAt: run.createdAt, + })), + ...(detail.detail + ? [ + { + gatewayRequestId: detail.detail.gatewayRequestId, + capability: detail.detail.modelId ?? detail.detail.capability, + createdAt: detail.detail.createdAt, + }, + ] + : []), + ]; + return matchRunTicketFees(runs, tickets); + }, [billingRows, history.page, detail.detail]); const router = useRouter(); - const requestId = useSearchParams().get("request"); const recorded = useMemo( () => history.page?.items.map((run) => @@ -56,12 +84,6 @@ export default function CallsSection({ const found = rows.find( (row) => row.id === requestId || row.gatewayRequestId === requestId ); - const detail = useRunDetail( - "/api/console/runs", - requestId, - ownerKey, - isConnected - ); const openRow = detail.detail && (detail.detail.id === requestId || diff --git a/lib/console/activity-output-match.test.ts b/lib/console/activity-output-match.test.ts index 0988dc35..ad7dd93e 100644 --- a/lib/console/activity-output-match.test.ts +++ b/lib/console/activity-output-match.test.ts @@ -1,7 +1,7 @@ import assert from "node:assert/strict"; import { test } from "node:test"; -import { matchTicketOutputs } from "./activity-output-match"; +import { matchRunTicketFees, matchTicketOutputs } from "./activity-output-match"; import type { SignedTicketRequestRow } from "./account-usage"; function ticket( @@ -163,3 +163,79 @@ test("job_* tickets without an exact id match do not fuzzy-join another job", () ]); assert.equal(matched.has("job_abc"), false); }); + +test("exact ticket id still prices a run", () => { + const fees = matchRunTicketFees( + [ + { + gatewayRequestId: "job_saved", + capability: "livepeer-example/fal-flux-schnell", + createdAt: "2026-09-09T21:09:30.000Z", + }, + ], + [ + { + gatewayRequestId: "job_saved", + modelId: "livepeer-example/fal-flux-schnell", + time: "2026-09-09T21:09:30.000Z", + costDisplay: "$0.0030", + costExact: "$0.002999", + }, + ] + ); + assert.equal(fees.get("job_saved")?.costDisplay, "$0.0030"); +}); + +test("orchestrator 8-hex tickets price MCP job_* runs by capability and nearest time", () => { + const fees = matchRunTicketFees( + [ + { + gatewayRequestId: "job_b6edf64e2a2442da", + capability: "livepeer-example/fal-flux-schnell", + createdAt: "2026-09-09T21:09:28.000Z", + }, + { + gatewayRequestId: "job_later", + capability: "livepeer-example/fal-flux-schnell", + createdAt: "2026-09-09T21:15:16.000Z", + }, + ], + [ + { + gatewayRequestId: "5b66062c", + modelId: "livepeer-example/fal-flux-schnell", + time: "2026-09-09T21:09:30.000Z", + costDisplay: "$0.0030", + }, + { + gatewayRequestId: "55d0075d", + modelId: "livepeer-example/fal-flux-schnell", + time: "2026-09-09T21:15:16.000Z", + costDisplay: "$0.0030", + }, + ] + ); + assert.equal(fees.get("job_b6edf64e2a2442da")?.costDisplay, "$0.0030"); + assert.equal(fees.get("job_later")?.costDisplay, "$0.0030"); +}); + +test("8-hex tickets do not price a different capability", () => { + const fees = matchRunTicketFees( + [ + { + gatewayRequestId: "job_video", + capability: "livepeer-example/fal-ltx-25-t2v-fast", + createdAt: "2026-09-09T21:09:30.000Z", + }, + ], + [ + { + gatewayRequestId: "5b66062c", + modelId: "livepeer-example/fal-flux-schnell", + time: "2026-09-09T21:09:30.000Z", + costDisplay: "$0.0030", + }, + ] + ); + assert.equal(fees.has("job_video"), false); +}); diff --git a/lib/console/activity-output-match.ts b/lib/console/activity-output-match.ts index 96a3c160..584b586f 100644 --- a/lib/console/activity-output-match.ts +++ b/lib/console/activity-output-match.ts @@ -91,3 +91,72 @@ export function matchTicketOutputs( return out; } + +export type FeeTicket = { + gatewayRequestId: string; + modelId: string; + time: string; + costDisplay: string; + costExact?: string; +}; + +export type FeeRun = { + gatewayRequestId: string; + capability: string; + createdAt: string; +}; + +/** + * Join PymtHouse tickets onto console runs. + * Production tickets are orchestrator 8-hex CloudEvent ids; MCP runs store + * `job_*`. Exact id wins; leftover 8-hex tickets assign greedily to the + * nearest unused same-capability run in the asset-match window. + */ +export function matchRunTicketFees( + runs: FeeRun[], + tickets: FeeTicket[] +): Map { + const out = new Map(); + const used = new Set(); + const feeOf = (ticket: FeeTicket) => ({ + costDisplay: ticket.costDisplay, + ...(ticket.costExact ? { costExact: ticket.costExact } : {}), + }); + + for (const run of runs) { + const exact = tickets.find( + (ticket) => + ticket.gatewayRequestId === run.gatewayRequestId && + !used.has(ticket.gatewayRequestId) + ); + if (!exact || exact.costDisplay === "—") continue; + used.add(exact.gatewayRequestId); + out.set(run.gatewayRequestId, feeOf(exact)); + } + + const remaining = [...runs] + .filter((run) => !out.has(run.gatewayRequestId)) + .sort((a, b) => a.createdAt.localeCompare(b.createdAt)); + + for (const run of remaining) { + const runTime = Date.parse(run.createdAt); + if (!Number.isFinite(runTime) || !run.capability.trim()) continue; + let best: { ticket: FeeTicket; delta: number } | null = null; + for (const ticket of tickets) { + if (used.has(ticket.gatewayRequestId)) continue; + if (!isOrchestratorTicketId(ticket.gatewayRequestId)) continue; + if (ticket.modelId !== run.capability) continue; + if (ticket.costDisplay === "—") continue; + const ticketTime = Date.parse(ticket.time); + if (!Number.isFinite(ticketTime)) continue; + const delta = Math.abs(ticketTime - runTime); + if (delta > TICKET_ASSET_MATCH_WINDOW_MS) continue; + if (!best || delta < best.delta) best = { ticket, delta }; + } + if (!best) continue; + used.add(best.ticket.gatewayRequestId); + out.set(run.gatewayRequestId, feeOf(best.ticket)); + } + + return out; +} diff --git a/lib/console/pymthouse-bff.ts b/lib/console/pymthouse-bff.ts index a96350da..2fea64f4 100644 --- a/lib/console/pymthouse-bff.ts +++ b/lib/console/pymthouse-bff.ts @@ -254,6 +254,12 @@ export async function fetchAccountRequestsForExternalUser(input: { email?: string; cursor?: string | null; limit?: number; + /** + * Cost lookups must not send a 365-day window. OpenMeter lists cap at 100 + * events, so a year-long range drops the ticket Home is trying to price. + * Omitting from/to uses the current UTC month on `/me/usage/requests`. + */ + recentWindow?: boolean; }): Promise { const publicClientId = readPublicClientId(); const minted = await mintEndUserAccessToken( @@ -263,9 +269,11 @@ export async function fetchAccountRequestsForExternalUser(input: { const accessToken = minted.access_token; const url = new URL(`${issuerOriginFromConfig()}/api/v1/user/usage/requests`); - const range = historyRange(); - url.searchParams.set("from", range.from); - url.searchParams.set("to", range.to); + if (!input.recentWindow) { + const range = historyRange(); + url.searchParams.set("from", range.from); + url.searchParams.set("to", range.to); + } if (input.cursor) url.searchParams.set("cursor", input.cursor); if (input.limit != null) url.searchParams.set("limit", String(input.limit)); diff --git a/lib/console/run-activity.test.ts b/lib/console/run-activity.test.ts index 87800005..d49bb7b1 100644 --- a/lib/console/run-activity.test.ts +++ b/lib/console/run-activity.test.ts @@ -46,7 +46,7 @@ test("run history uses the signed-ticket fee mapper", () => { assert.equal(row.costExact, "$0.001"); }); -test("run detail reads the latest billing_usage event", () => { +test("run detail does not take Cost from Postgres run events", () => { const detail = { ...summary(), submittedArguments: null, @@ -73,5 +73,5 @@ test("run detail reads the latest billing_usage event", () => { const fields = feeFieldsFromRunEvents(detail.events); assert.equal(fields?.networkFeeUsdMicros, "2500"); const row = runToActivity(detail); - assert.equal(row.costDisplay, "$0.0025"); + assert.equal(row.costDisplay, "—"); }); diff --git a/lib/console/run-activity.ts b/lib/console/run-activity.ts index e9a4d850..0516b227 100644 --- a/lib/console/run-activity.ts +++ b/lib/console/run-activity.ts @@ -65,11 +65,7 @@ export function runToActivity( run.startedAt && run.completedAt ? Date.parse(run.completedAt) - Date.parse(run.startedAt) : null; - const cost = - costFromFee(fee) ?? - costFromFee( - feeFieldsFromRunEvents("events" in run ? run.events : undefined) - ); + const cost = costFromFee(fee); return { id: run.id, recordKind: "run", diff --git a/lib/console/signed-ticket-activity.ts b/lib/console/signed-ticket-activity.ts index 8ad3eb30..a24b40f3 100644 --- a/lib/console/signed-ticket-activity.ts +++ b/lib/console/signed-ticket-activity.ts @@ -31,6 +31,7 @@ export function mapSignedTicketToActivityRow( return { id: `usage:${row.eventId}`, gatewayRequestId: row.gatewayRequestId, + capabilityId: row.modelId, recordKind: "usage", environmentId: "env-production", timestamp: row.time, diff --git a/lib/console/types.ts b/lib/console/types.ts index cc40ec9f..c487fd6c 100644 --- a/lib/console/types.ts +++ b/lib/console/types.ts @@ -414,6 +414,8 @@ export type AccountActivityStatus = export interface AccountActivityRow { recordKind?: "run" | "usage"; gatewayRequestId?: string; + /** Raw capability id (`livepeer-example/fal-flux-schnell`). Used to join Cost. */ + capabilityId?: string; id: string; /** Environment this request ran under. Scopes Jobs + Home runs by env. */ environmentId: string; diff --git a/tests/contracts/account-history-route.test.ts b/tests/contracts/account-history-route.test.ts index 3c751c27..59dc302c 100644 --- a/tests/contracts/account-history-route.test.ts +++ b/tests/contracts/account-history-route.test.ts @@ -139,6 +139,13 @@ it("returns scoped matched receipts when Home explicitly requests correlation", expect(result.items).toEqual([row("owned")]); expect(result.nextCursor).toBe("next"); expect(recordRunUsage).toHaveBeenCalledTimes(1); + expect(fetchAccountRequestsForExternalUser).toHaveBeenCalledWith({ + externalUserId: "eu_fixture", + email: "fixture@example.invalid", + cursor: undefined, + limit: 50, + recentWindow: true, + }); expect(JSON.stringify(vi.mocked(recordRunUsage).mock.calls)).not.toContain( "event-other" ); diff --git a/tests/contracts/account-history.test.ts b/tests/contracts/account-history.test.ts index 95a6a75b..8b14e325 100644 --- a/tests/contracts/account-history.test.ts +++ b/tests/contracts/account-history.test.ts @@ -63,6 +63,25 @@ it("keeps old history and pagination even when its media is unavailable", async expect(url.searchParams.get("limit")).toBe("50"); }); +it("omits the year window for Cost so the current-month ticket feed is kept", async () => { + const fetch = vi.fn(async () => + Response.json({ + items: [], + nextCursor: null, + openMeterConfigured: true, + }) + ); + vi.stubGlobal("fetch", fetch); + await fetchAccountRequestsForExternalUser({ + externalUserId: "test-user", + limit: 50, + recentWindow: true, + }); + const url = new URL(fetch.mock.calls[0][0] as string); + expect(url.searchParams.get("from")).toBeNull(); + expect(url.searchParams.get("to")).toBeNull(); +}); + it("does not label History with media expiry or a seven-day limit", () => { const source = readFileSync("components/console/CallsSection.tsx", "utf8"); expect(source).toContain('title="History"'); diff --git a/tests/contracts/home-history-surface.test.tsx b/tests/contracts/home-history-surface.test.tsx index 2733e4a7..856bdceb 100644 --- a/tests/contracts/home-history-surface.test.tsx +++ b/tests/contracts/home-history-surface.test.tsx @@ -119,10 +119,74 @@ it("shows Postgres rows even when billing is unavailable", async () => { expect(screen.queryByRole("alert")).toBeNull(); expect(screen.queryByText("Usage-only history")).toBeNull(); await waitFor(() => - expect(screen.getByTestId("detail-cost").textContent).toBe("$0.0025") + expect(screen.getByTestId("detail-cost").textContent).toBe("—") ); }); +it("joins an orchestrator 8-hex ticket onto an MCP job_* run", async () => { + const run = { + id: "28d04c8a-7edd-487e-a0c4-5f95ed637a4b", + principalId: "external", + userId: "user", + externalAccountId: "account", + gatewayRequestId: "job_fe3e40004a49442c", + providerRequestId: null, + provider: null, + source: "mcp", + capability: "livepeer-example/fal-ideogram-v4", + modelId: "livepeer-example/fal-ideogram-v4", + endpoint: null, + status: "succeeded" as const, + captureVersion: 1, + errorCode: null, + errorMessage: null, + version: 2, + createdAt: "2026-09-09T21:42:18.000Z", + updatedAt: "2026-09-09T21:42:19.000Z", + startedAt: "2026-09-09T21:42:18.000Z", + completedAt: "2026-09-09T21:42:19.000Z", + email: null, + }; + records.push(run); + navigation.search = "request=28d04c8a-7edd-487e-a0c4-5f95ed637a4b"; + fetcher.mockImplementation(async (input: string) => { + if (input === "/api/console/runs/28d04c8a-7edd-487e-a0c4-5f95ed637a4b") { + return Response.json({ + ...run, + submittedArguments: null, + result: null, + captureRedactedPaths: [], + assets: [], + events: [], + }); + } + if (String(input).startsWith("/api/console/runs")) + return Response.json({ items: records, nextCursor: null }); + return Response.json({ + items: [ + { + eventId: "c9a1fae7", + gatewayRequestId: "c9a1fae7", + time: "2026-09-09T21:42:19.000Z", + clientId: "app_test", + externalUserId: "eu_test", + pipeline: "text-to-image", + modelId: "livepeer-example/fal-ideogram-v4", + networkFeeUsdMicros: "9984.675492933755", + feeWei: "4048746912830", + }, + ], + nextCursor: null, + openMeterConfigured: true, + }); + }); + render(); + await waitFor(() => + expect(screen.getByTestId("detail-cost").textContent).not.toBe("—") + ); + expect(screen.getByTestId("detail-cost").textContent).toMatch(/^\$0\.0/); +}); + it("joins a correlated ticket fee onto the saved run, without adding a billing row", async () => { records.push({ id: "saved-run", @@ -179,7 +243,7 @@ it("joins a correlated ticket fee onto the saved run, without adding a billing r }); render(); await screen.findByRole("button", { name: "Inspect saved-run" }); - expect(screen.getByText("$0.0010")).toBeTruthy(); + await waitFor(() => expect(screen.getByText("$0.0010")).toBeTruthy()); expect( fetcher.mock.calls.some(([url]) => String(url).includes("includeCorrelated=1")