Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions app/api/pymthouse/account-requests/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ export async function GET(request: NextRequest) {
email: session.email,
cursor: next,
limit,
...(includeCorrelated ? { recentWindow: true } : {}),
});
if (
payload.externalUserId !== session.externalUserId ||
Expand Down
58 changes: 40 additions & 18 deletions components/console/CallsSection.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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<string, { costDisplay: string; costExact?: string }>();
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) =>
Expand All @@ -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 ||
Expand Down
78 changes: 77 additions & 1 deletion lib/console/activity-output-match.test.ts
Original file line number Diff line number Diff line change
@@ -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(
Expand Down Expand Up @@ -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);
});
69 changes: 69 additions & 0 deletions lib/console/activity-output-match.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, { costDisplay: string; costExact?: string }> {
const out = new Map<string, { costDisplay: string; costExact?: string }>();
const used = new Set<string>();
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;
}
14 changes: 11 additions & 3 deletions lib/console/pymthouse-bff.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<AccountRequestsPayload> {
const publicClientId = readPublicClientId();
const minted = await mintEndUserAccessToken(
Expand All @@ -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));

Expand Down
4 changes: 2 additions & 2 deletions lib/console/run-activity.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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, "");
});
6 changes: 1 addition & 5 deletions lib/console/run-activity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
1 change: 1 addition & 0 deletions lib/console/signed-ticket-activity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
2 changes: 2 additions & 0 deletions lib/console/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
7 changes: 7 additions & 0 deletions tests/contracts/account-history-route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
);
Expand Down
19 changes: 19 additions & 0 deletions tests/contracts/account-history.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof globalThis.fetch>(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"');
Expand Down
Loading