Skip to content
Merged
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
3 changes: 3 additions & 0 deletions .github/workflows/check-ecosystem-urls.yml
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,9 @@ on:
# Manual trigger
workflow_dispatch:

permissions:
contents: read

jobs:
check-urls:
runs-on: ubuntu-latest
Expand Down
9 changes: 9 additions & 0 deletions app/api/pymthouse/account-requests/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,9 @@ export const runtime = "nodejs";
export const dynamic = "force-dynamic";

export async function GET(request: NextRequest) {
// Home joins receipts onto durable runs; legacy consumers still omit matches.
const includeCorrelated =
request.nextUrl.searchParams.get("includeCorrelated") === "1";
const cursor =
request.nextUrl.searchParams.get("cursor")?.trim() || undefined;
const limitRaw = request.nextUrl.searchParams.get("limit");
Expand Down Expand Up @@ -96,6 +99,12 @@ export async function GET(request: NextRequest) {
ticket.gatewayRequestId.length <= 512
)
);
if (includeCorrelated) {
return NextResponse.json(
{ ...payload, items: scoped },
{ headers: PYMTHOUSE_NO_STORE_HEADERS }
);
}
const correlated = new Set(
await existingRunGatewayIds(
owner,
Expand Down
10 changes: 8 additions & 2 deletions components/admin/RunsPreview.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -38,9 +38,15 @@ export default function RunsPreview() {
);
const detail = useRunDetail("/api/admin/runs", selected, ownerKey, enabled);
const rows = useMemo(
() => history.page?.items.map(runToActivity) ?? [],
() => history.page?.items.map((run) => runToActivity(run)) ?? [],
[history.page]
);
const selectedRow = useMemo(() => {
if (detail.detail && detail.detail.id === selected) {
return runToActivity(detail.detail);
}
return rows.find((row) => row.id === selected) ?? null;
}, [detail.detail, rows, selected]);
const counts = history.page?.counts;
const summary = [
{ label: "Total runs", value: counts?.total },
Expand Down Expand Up @@ -173,7 +179,7 @@ export default function RunsPreview() {
</section>
</div>
<CallDetailDrawer
row={rows.find((row) => row.id === selected) ?? null}
row={selectedRow}
rows={rows}
open={!!selected}
onClose={() => setSelected(null)}
Expand Down
31 changes: 27 additions & 4 deletions components/console/CallsSection.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,12 +29,28 @@ export default function CallsSection({
ownerKey
);
// Correlate billing receipts with saved runs; billing is not a second history feed.
useAccountRequests(isConnected, ownerKey);
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]);
const router = useRouter();
const requestId = useSearchParams().get("request");
const recorded = useMemo(
() => history.page?.items.map(runToActivity) ?? [],
[history.page]
() =>
history.page?.items.map((run) =>
runToActivity(run, feeByGateway.get(run.gatewayRequestId))
) ?? [],
[history.page, feeByGateway]
);
const rows = recorded;
const found = rows.find(
Expand All @@ -47,7 +63,14 @@ export default function CallsSection({
isConnected
);
const openRow =
found ?? (detail.detail ? runToActivity(detail.detail) : null);
detail.detail &&
(detail.detail.id === requestId ||
detail.detail.gatewayRequestId === requestId)
? runToActivity(
detail.detail,
feeByGateway.get(detail.detail.gatewayRequestId)
)
: (found ?? null);
const select = (row: AccountActivityRow) =>
router.push("/home?request=" + encodeURIComponent(row.id), {
scroll: false,
Expand Down
77 changes: 77 additions & 0 deletions lib/console/run-activity.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import assert from "node:assert/strict";
import { test } from "node:test";

import {
feeFieldsFromRunEvents,
runToActivity,
} from "./run-activity";
import type { RunDetail, RunSummary } from "@/lib/runs/types";

function summary(overrides: Partial<RunSummary> = {}): RunSummary {
return {
id: "run_1",
principalId: "external",
userId: "user",
externalAccountId: "account",
gatewayRequestId: "job_abc",
providerRequestId: null,
provider: null,
source: "mcp",
capability: "livepeer-example/fal-ideogram-v4",
modelId: "livepeer-example/fal-ideogram-v4",
endpoint: null,
status: "succeeded",
captureVersion: 1,
errorCode: null,
errorMessage: null,
version: 1,
createdAt: "2026-09-08T18:00:00Z",
updatedAt: "2026-09-08T18:00:01Z",
startedAt: "2026-09-08T18:00:00Z",
completedAt: "2026-09-08T18:00:01Z",
email: null,
...overrides,
};
}

test("run history stays em-dash when no billing receipt is joined", () => {
const row = runToActivity(summary());
assert.equal(row.costDisplay, "—");
assert.equal(row.costExact, undefined);
});

test("run history uses the signed-ticket fee mapper", () => {
const row = runToActivity(summary(), { networkFeeUsdMicros: "1000" });
assert.equal(row.costDisplay, "$0.0010");
assert.equal(row.costExact, "$0.001");
});

test("run detail reads the latest billing_usage event", () => {
const detail = {
...summary(),
submittedArguments: null,
result: null,
captureRedactedPaths: [],
assets: [],
events: [
{
id: "evt_old",
eventKey: "usage:old",
status: "succeeded" as const,
createdAt: "2026-09-08T18:00:00Z",
metadata: { kind: "billing_usage", networkFeeUsdMicros: "500" },
},
{
id: "evt_new",
eventKey: "usage:new",
status: "succeeded" as const,
createdAt: "2026-09-08T18:00:02Z",
metadata: { kind: "billing_usage", networkFeeUsdMicros: "2500" },
},
],
} satisfies RunDetail;
const fields = feeFieldsFromRunEvents(detail.events);
assert.equal(fields?.networkFeeUsdMicros, "2500");
const row = runToActivity(detail);
assert.equal(row.costDisplay, "$0.0025");
});
65 changes: 62 additions & 3 deletions lib/console/run-activity.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,62 @@
import type { RunSummary } from "@/lib/runs/types";
import type { JsonValue, RunDetail, RunSummary } from "@/lib/runs/types";
import type { AccountActivityRow } from "./types";
import { resolveActivityCapability } from "./capability-modality";
import {
requestFeeDisplay,
type RequestFeeFields,
} from "./request-fee-display";
import { humanizePipelineModel } from "./usage-capability-display";

export function runToActivity(run: RunSummary): AccountActivityRow {
export type RunActivityFee = {
costDisplay: string;
costExact?: string;
};

function isRequestFeeFields(
fee: RequestFeeFields | RunActivityFee
): fee is RequestFeeFields {
return "networkFeeUsdMicros" in fee;
}

function costFromFee(
fee: RequestFeeFields | RunActivityFee | null | undefined
): RunActivityFee | null {
if (!fee || typeof fee !== "object") return null;
if (isRequestFeeFields(fee)) {
const { display, exact } = requestFeeDisplay(fee);
return { costDisplay: display, costExact: exact };
}
return fee.costDisplay ? fee : null;
}

/** Latest correlated billing receipt on a run, if any. */
export function feeFieldsFromRunEvents(
events:
| {
metadata: Record<string, JsonValue>;
}[]
| undefined
): RequestFeeFields | undefined {
if (!events) return undefined;
for (let i = events.length - 1; i >= 0; i--) {
const meta = events[i]?.metadata;
if (!meta || meta.kind !== "billing_usage") continue;
if (typeof meta.networkFeeUsdMicros !== "string") continue;
return {
networkFeeUsdMicros: meta.networkFeeUsdMicros,
...(typeof meta.feeWei === "string" ? { feeWei: meta.feeWei } : {}),
...(typeof meta.ethUsdPrice === "string"
? { ethUsdPrice: meta.ethUsdPrice }
: {}),
};
}
return undefined;
}

export function runToActivity(
run: RunSummary | RunDetail,
fee?: RequestFeeFields | RunActivityFee | null
): AccountActivityRow {
const capability = resolveActivityCapability({
pipeline: run.capability,
capabilityId: run.modelId ?? run.capability,
Expand All @@ -12,6 +65,11 @@ export function runToActivity(run: RunSummary): AccountActivityRow {
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)
);
return {
id: run.id,
recordKind: "run",
Expand All @@ -32,7 +90,8 @@ export function runToActivity(run: RunSummary): AccountActivityRow {
signerLabel: run.source === "mcp" ? "MCP" : run.source,
tokenId: "",
tokenName: "",
costDisplay: "—",
costDisplay: cost?.costDisplay ?? "—",
...(cost?.costExact ? { costExact: cost.costExact } : {}),
providerRequestId: run.providerRequestId ?? undefined,
};
}
26 changes: 19 additions & 7 deletions lib/console/useAccountRequests.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,9 +20,11 @@ type AccountRequestsState =

async function fetchRequestsPage(
cursor: string | null,
signal: AbortSignal
signal: AbortSignal,
includeCorrelated = false
): Promise<ReadyState> {
const params = new URLSearchParams({ limit: "50" });
if (includeCorrelated) params.set("includeCorrelated", "1");
if (cursor) params.set("cursor", cursor);
const response = await fetch(`/api/pymthouse/account-requests?${params}`, {
cache: "no-store",
Expand All @@ -44,8 +46,14 @@ async function fetchRequestsPage(

/** Private history is instance-local, never a global cross-account cache.
* ownerKey invalidates in-flight work even when both old/new accounts are enabled. */
export function useAccountRequests(enabled: boolean, ownerKey?: string) {
const scope = enabled ? (ownerKey ?? "authenticated-instance") : "disabled";
export function useAccountRequests(
enabled: boolean,
ownerKey?: string,
includeCorrelated = false
) {
const scope = enabled
? JSON.stringify([ownerKey ?? "authenticated-instance", includeCorrelated])
: "disabled";
const [stored, setStored] = useState<{
scope: string;
state: AccountRequestsState;
Expand All @@ -62,7 +70,7 @@ export function useAccountRequests(enabled: boolean, ownerKey?: string) {
appendBusy.current = false;
setStored({ scope, state: { status: enabled ? "loading" : "idle" } });
if (enabled)
void fetchRequestsPage(null, controller.signal)
void fetchRequestsPage(null, controller.signal, includeCorrelated)
.then((page) => {
if (generation.current === id) setStored({ scope, state: page });
})
Expand All @@ -84,7 +92,7 @@ export function useAccountRequests(enabled: boolean, ownerKey?: string) {
controller.abort();
appendController.current?.abort();
};
}, [scope, enabled, refresh]);
}, [scope, enabled, refresh, includeCorrelated]);

const state = useMemo<AccountRequestsState>(
() =>
Expand All @@ -104,7 +112,11 @@ export function useAccountRequests(enabled: boolean, ownerKey?: string) {
appendController.current = controller;
appendBusy.current = true;
try {
const page = await fetchRequestsPage(state.nextCursor, controller.signal);
const page = await fetchRequestsPage(
state.nextCursor,
controller.signal,
includeCorrelated
);
if (generation.current !== id) return;
setStored((previous) => {
if (previous.scope !== scope || previous.state.status !== "ready")
Expand Down Expand Up @@ -143,7 +155,7 @@ export function useAccountRequests(enabled: boolean, ownerKey?: string) {
} finally {
if (generation.current === id) appendBusy.current = false;
}
}, [enabled, state, scope]);
}, [enabled, state, scope, includeCorrelated]);
const reload = useCallback(() => setRefresh((value) => value + 1), []);
return { ...state, reload, loadMore };
}
23 changes: 23 additions & 0 deletions tests/contracts/account-history-route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,29 @@ it("suppresses owned run tickets, persists fee-only evidence, and joins assets o
]);
});

it("returns scoped matched receipts when Home explicitly requests correlation", async () => {
vi.mocked(fetchAccountRequestsForExternalUser).mockResolvedValue(
payload(
[row("owned"), { ...row("other"), externalUserId: "eu_other" }],
"next"
)
);
vi.mocked(existingRunGatewayIds).mockResolvedValue(["owned"]);
const response = await GET(
new NextRequest(
"http://localhost/api/pymthouse/account-requests?includeCorrelated=1"
)
);
expect(response.status).toBe(200);
const result = await response.json();
expect(result.items).toEqual([row("owned")]);
expect(result.nextCursor).toBe("next");
expect(recordRunUsage).toHaveBeenCalledTimes(1);
expect(JSON.stringify(vi.mocked(recordRunUsage).mock.calls)).not.toContain(
"event-other"
);
});

it("walks entirely matched pages until legacy results using actual upstream continuation", async () => {
vi.mocked(fetchAccountRequestsForExternalUser)
.mockResolvedValueOnce(payload([row("owned-1")], "second"))
Expand Down
Loading
Loading