mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-24 10:13:11 +02:00
## Thinking Path > - Paperclip manages agents and their work. > - Product E2E tests check complete user journeys. > - The current harness prepares self-hosted instances. > - Hosted workspaces need separate authentication, setup, and cleanup. > - The same in-app story should run on either deployment. > - This pull request adds deployment targets and explicit prerequisites. > - The private Cloud adapter owns fleet access and lifecycle operations. ## Linked Issues or Issue Description **Subsystem affected** Product E2E harness, fixture registry, and reports. **Problem or motivation** Self-hosted test setup cannot qualify a deployed Cloud workspace. Copying each story into a second harness would make its assertions diverge. **Proposed solution** Add explicit deployment targets, setup recipes, and ordered campaigns. Reuse a no-model task-properties story on both deployments. Let fresh Cloud onboarding provide verified context for that same story. Keep Cloud lifecycle and credentials behind a private target provider. **Roadmap alignment** This supports Cloud deployment qualification and the existing agent eval system. It adds no application endpoint or scheduler. Related work: #17112. The separate onboarding gateway UI prerequisite is #17253. ## What Changed - Add explicit target configuration and a credential-free preparation preview. Keep historical selectors and the current `--all` matrix unchanged. - Resolve requested fixture prerequisites, including a run-owned backlog task prepared before measurement. Reuse verified onboarding context without counting recipe setup as onboarding coverage. - Add task assignment persistence and the actual onboarding parent/child first-task journey, with an independent artifact oracle and native trace requirement. - Preserve failed boundaries and blocked dependents, deployment identity, costs, phase outcomes, and private evidence in the existing dashboard. - Validate success assertions and accounting before successful cleanup. Bound collection by the campaign deadline and cleanup/evidence hooks by a separate ten-minute grace, with cancellation and stop-only fallback. - Stage dashboard rendering separately so a timed-out renderer cannot overwrite retained failure results. Add evidence finalization hooks so the private provider can save evidence before target deletion and lease release. - Reject private journey bundles in the public history publisher. Browser-only results have no model or provider. ## Verification - The unchanged task-properties story passed on managed self-hosted and a registered testing Cloud workspace. Authentication, setup, journey, collection, and cleanup passed. Model calls: zero. Earlier attempts remain private and retained. - Final journey and report tests: 96 passed in eight files. Catalog compatibility: 39 passed. The wider focused run exposed three stale master catalog expectations; these now match the already shipped case-handoff v4 metadata and four-cell matrix. The default 385-cell selection is unchanged. - Review regressions reproduced premature success for a zero-request receipt and a missing required assertion. Both now fail before success cleanup while retaining cost receipts. The initial lifecycle fixes passed 33 checks. A subsequent review exposed a late renderer write that could erase saved failures; rendering now writes into isolated staging and publishes HTML only after it settles. Combined campaign, fixture-contract, and retained-report checks: 67 passed, including late writes, delayed timer callbacks, stalled collection, release, stop, evidence hooks, and browser close. Harness typecheck passed after the fixes. - A broader harness unit attempt completed 3,601 passing cases, two failures, and two skips. The failures exposed a stale Pi count and an omitted active Copilot fixture in the Daytona image input closure. The focused compatibility fixes passed 83 tests. After all source fixes, the full harness support suite passed: 160 files, 3,621 tests, two skips, plus 128 native-source checks. The earlier failures remain recorded. - Harness typecheck, repository typecheck, repository build, map checks, and Storybook catalog checks passed on the rebased branch. - The required full `pnpm test:run` attempt timed out after 35 minutes. It had 1,195 completed passing cases and one chat integration failure before its first suite completed. That case and nine adjacent cases pass in isolation. The original failure remains unresolved and retained; this is not a full-suite pass. - Current-head remote checks (`39b2a1ed8c49222fabd1ba8e225cac81bff5c6f7`): 53 passed, two skipped, none pending or failing. Greptile completed successfully at 5/5 with no unresolved review threads. - Fresh paid Cloud onboarding, dependent reuse, and disposable cleanup have not been qualified live. They require deployment of the separate UI prerequisite and actual Cloud gateway budget admission. The trusted Cloud workflow remains disabled. - Final live verification at `39b2a1ed8c`: the unchanged shared story passed on managed self-hosted and the registered testing Cloud stack using the prepared backlog-task recipe. Both cleaned up successfully with zero model calls. Independent private S3 checks confirmed final evidence and released QA identity/stack leases. Cloud evidence remains private. ## Risks - A deployed UI or Cloud control-plane change can invalidate selectors or provider contracts. Version drift fails the attempt. - Cloud evidence and lifecycle recovery are private-provider responsibilities. A pending cleanup or evidence upload must keep the target unavailable for reuse. - No release is deployed or pinned by this change. No automatic story retry is added. The initial pilot retains its explicit $10, one disposable workspace, and 30-minute bounds. ## Model Used OpenAI GPT-6 through Codex, with reasoning, repository tools, code execution, and delegated agents. The runtime does not expose a more specific deployment model identifier or context-window size. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [ ] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
435 lines
15 KiB
TypeScript
435 lines
15 KiB
TypeScript
import type {
|
|
RunnerE2EBillingSummary,
|
|
RunnerE2EResult,
|
|
RunnerE2ERuntimeUsage,
|
|
RunnerEnvironmentId,
|
|
} from "./types.js";
|
|
|
|
// Public list prices are deliberately versioned here instead of being treated
|
|
// as provider-reported charges. Credits, discounts, and Daytona's first 5 GiB
|
|
// storage allowance can make the invoice amount lower than this estimate.
|
|
export const DAYTONA_LIST_PRICING = {
|
|
asOf: "2026-08-27",
|
|
url: "https://www.daytona.io/pricing",
|
|
cpuCoreHourUsd: 0.0504,
|
|
memoryGiBHourUsd: 0.0162,
|
|
diskGiBHourUsd: 0.000108,
|
|
} as const;
|
|
|
|
export interface RuntimeLeaseUsageInput {
|
|
acquiredAt?: string | Date | null;
|
|
releasedAt?: string | Date | null;
|
|
updatedAt?: string | Date | null;
|
|
metadata?: Record<string, unknown> | null;
|
|
}
|
|
|
|
export interface AgentRunUsageInput {
|
|
startedAt?: string | Date | null;
|
|
finishedAt?: string | Date | null;
|
|
}
|
|
|
|
export interface CampaignBillingSummary {
|
|
assistant?: import("./types.js").RunnerE2EAggregateBillingSummary["assistant"];
|
|
judge?: import("./types.js").RunnerE2EJudgeBillingSummary;
|
|
testCount: number;
|
|
agentRunDurationMs: number;
|
|
leaseDurationMs: number;
|
|
llm: RunnerE2EBillingSummary["llm"];
|
|
reportedLlmCostUsd: number;
|
|
estimatedRuntimeCostUsd: number;
|
|
observedAndEstimatedCostUsd: number | null;
|
|
testsWithCompleteBilling: number;
|
|
}
|
|
|
|
/** Render observed subtotals without presenting absent measurements as zero. */
|
|
export function billingCoverageLabel(value: string, covered: number, total: number): string {
|
|
if (!Number.isInteger(covered) || !Number.isInteger(total) || covered <= 0 || total <= 0 || covered > total) {
|
|
return "Unavailable";
|
|
}
|
|
return covered < total ? `${value} (partial: ${covered}/${total} runs)` : value;
|
|
}
|
|
|
|
function record(value: unknown): Record<string, unknown> {
|
|
return value && typeof value === "object" && !Array.isArray(value)
|
|
? (value as Record<string, unknown>)
|
|
: {};
|
|
}
|
|
|
|
function finiteNumber(value: unknown): number | undefined {
|
|
return typeof value === "number" && Number.isFinite(value) && value >= 0
|
|
? value
|
|
: undefined;
|
|
}
|
|
|
|
function firstNumber(source: Record<string, unknown>, keys: readonly string[]) {
|
|
for (const key of keys) {
|
|
const value = finiteNumber(source[key]);
|
|
if (value !== undefined) return value;
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
function usageMeasurement(usage: Record<string, unknown>) {
|
|
const candidates = [
|
|
record(usage.runDelta),
|
|
record(usage.total),
|
|
record(usage.cumulative),
|
|
usage,
|
|
];
|
|
return (
|
|
candidates.find(
|
|
(candidate) =>
|
|
firstNumber(candidate, [
|
|
"inputTokens",
|
|
"input",
|
|
"promptTokens",
|
|
"outputTokens",
|
|
"output",
|
|
"completionTokens",
|
|
]) !== undefined,
|
|
) ?? usage
|
|
);
|
|
}
|
|
|
|
function usageCostUsd(usage: Record<string, unknown>) {
|
|
const measurement = usageMeasurement(usage);
|
|
const direct =
|
|
firstNumber(usage, [
|
|
"cacheAdjustedCostUsd",
|
|
"costUsd",
|
|
"providerCostUsd",
|
|
]) ??
|
|
firstNumber(measurement, [
|
|
"cacheAdjustedCostUsd",
|
|
"costUsd",
|
|
"providerCostUsd",
|
|
]);
|
|
if (direct !== undefined) return direct;
|
|
const cost = record(usage.cost);
|
|
const currency =
|
|
typeof cost.currency === "string" ? cost.currency.toUpperCase() : "USD";
|
|
if (currency !== "USD") return undefined;
|
|
return firstNumber(cost, ["amount", "total"]);
|
|
}
|
|
|
|
function usageEntries(
|
|
rawUsage: Record<string, unknown> | null | undefined,
|
|
runCount: number,
|
|
) {
|
|
const runs = Array.isArray(rawUsage?.runs) ? rawUsage.runs : null;
|
|
if (runs) {
|
|
const entries = runs.map((entry) => {
|
|
const candidate = record(entry);
|
|
const usage = candidate.usage;
|
|
return usage && typeof usage === "object" && !Array.isArray(usage)
|
|
? (usage as Record<string, unknown>)
|
|
: null;
|
|
});
|
|
return [
|
|
...entries,
|
|
...Array.from(
|
|
{ length: Math.max(0, runCount - entries.length) },
|
|
() => null,
|
|
),
|
|
];
|
|
}
|
|
return Array.from({ length: Math.max(1, runCount) }, (_, index) =>
|
|
index === 0 && rawUsage ? rawUsage : null,
|
|
);
|
|
}
|
|
|
|
function durationBetween(
|
|
startedAt: string | Date | null | undefined,
|
|
finishedAt: string | Date | null | undefined,
|
|
) {
|
|
const started = startedAt ? new Date(startedAt).getTime() : Number.NaN;
|
|
const finished = finishedAt ? new Date(finishedAt).getTime() : Number.NaN;
|
|
return Number.isFinite(started) && Number.isFinite(finished)
|
|
? Math.max(0, finished - started)
|
|
: 0;
|
|
}
|
|
|
|
function resourceValue(
|
|
metadata: Record<string, unknown> | null | undefined,
|
|
key: "cpu" | "memory" | "disk",
|
|
) {
|
|
return finiteNumber(metadata?.[key]);
|
|
}
|
|
|
|
export function buildRuntimeUsage(input: {
|
|
environmentId: RunnerEnvironmentId;
|
|
runs: readonly AgentRunUsageInput[];
|
|
leases?: readonly RuntimeLeaseUsageInput[];
|
|
fallbackFinishedAt?: string | Date;
|
|
}): RunnerE2ERuntimeUsage {
|
|
const agentRunDurationMs = input.runs.reduce(
|
|
(total, run) => total + durationBetween(run.startedAt, run.finishedAt),
|
|
0,
|
|
);
|
|
if (input.environmentId === "local") {
|
|
return {
|
|
provider: "local",
|
|
agentRunDurationMs,
|
|
leaseDurationMs: null,
|
|
leaseCount: 0,
|
|
costStatus: "not_metered",
|
|
costSource: "local_not_metered",
|
|
};
|
|
}
|
|
|
|
const leases = input.leases ?? [];
|
|
let leaseDurationMs = 0;
|
|
let estimatedListCostUsd = 0;
|
|
let resourcesComplete = leases.length > 0;
|
|
const resourceRows: Array<{ cpu: number; memory: number; disk: number }> = [];
|
|
for (const lease of leases) {
|
|
const finishedAt =
|
|
lease.releasedAt ?? input.fallbackFinishedAt ?? lease.updatedAt ?? null;
|
|
const durationMs = durationBetween(lease.acquiredAt, finishedAt);
|
|
leaseDurationMs += durationMs;
|
|
const cpu = resourceValue(lease.metadata, "cpu");
|
|
const memory = resourceValue(lease.metadata, "memory");
|
|
const disk = resourceValue(lease.metadata, "disk");
|
|
if (cpu === undefined || memory === undefined || disk === undefined) {
|
|
resourcesComplete = false;
|
|
continue;
|
|
}
|
|
resourceRows.push({ cpu, memory, disk });
|
|
const hours = durationMs / 3_600_000;
|
|
estimatedListCostUsd +=
|
|
hours *
|
|
(cpu * DAYTONA_LIST_PRICING.cpuCoreHourUsd +
|
|
memory * DAYTONA_LIST_PRICING.memoryGiBHourUsd +
|
|
disk * DAYTONA_LIST_PRICING.diskGiBHourUsd);
|
|
}
|
|
const commonResource = (key: "cpu" | "memory" | "disk") => {
|
|
const values = new Set(resourceRows.map((row) => row[key]));
|
|
return values.size === 1 ? resourceRows[0]?.[key] : undefined;
|
|
};
|
|
return {
|
|
provider: "daytona",
|
|
agentRunDurationMs,
|
|
leaseDurationMs: leases.length > 0 ? leaseDurationMs : null,
|
|
leaseCount: leases.length,
|
|
...(commonResource("cpu") === undefined
|
|
? {}
|
|
: { cpuCores: commonResource("cpu") }),
|
|
...(commonResource("memory") === undefined
|
|
? {}
|
|
: { memoryGiB: commonResource("memory") }),
|
|
...(commonResource("disk") === undefined
|
|
? {}
|
|
: { diskGiB: commonResource("disk") }),
|
|
...(resourcesComplete ? { estimatedListCostUsd } : {}),
|
|
costStatus: resourcesComplete ? "estimated" : "unavailable",
|
|
costSource: resourcesComplete
|
|
? "daytona_public_list_price"
|
|
: "provider_cost_unavailable",
|
|
...(resourcesComplete
|
|
? {
|
|
pricingAsOf: DAYTONA_LIST_PRICING.asOf,
|
|
pricingUrl: DAYTONA_LIST_PRICING.url,
|
|
}
|
|
: {}),
|
|
};
|
|
}
|
|
|
|
export function fallbackRuntimeUsage(
|
|
result: Pick<
|
|
RunnerE2EResult,
|
|
"environmentId" | "durationMs" | "runtimeUsage"
|
|
>,
|
|
) {
|
|
if (result.runtimeUsage) return result.runtimeUsage;
|
|
return {
|
|
provider: result.environmentId,
|
|
agentRunDurationMs: result.durationMs,
|
|
leaseDurationMs: null,
|
|
leaseCount: 0,
|
|
costStatus:
|
|
result.environmentId === "local" ? "not_metered" : "unavailable",
|
|
costSource:
|
|
result.environmentId === "local"
|
|
? "local_not_metered"
|
|
: "provider_cost_unavailable",
|
|
} satisfies RunnerE2ERuntimeUsage;
|
|
}
|
|
|
|
export function summarizeExecutionBilling(
|
|
result: Pick<
|
|
RunnerE2EResult,
|
|
"runIds" | "usage" | "environmentId" | "durationMs" | "runtimeUsage" | "firstTaskQuality" | "completionQuality" | "publicMcp" | "productJourney"
|
|
> & Partial<Pick<RunnerE2EResult, "runtimeMode">>,
|
|
): RunnerE2EBillingSummary {
|
|
const browserOnly = Boolean(result.productJourney && result.runtimeMode === "none" && !result.runIds?.length);
|
|
const requestedRunCount = browserOnly ? 0 : Math.max(result.runIds?.length ?? 0, 1);
|
|
const entries = browserOnly ? [] : usageEntries(result.usage, requestedRunCount);
|
|
let inputTokens = 0;
|
|
let outputTokens = 0;
|
|
let cachedInputTokens = 0;
|
|
let runsWithTokenUsage = 0;
|
|
let runsWithReportedCost = 0;
|
|
let reportedCostUsd = 0;
|
|
for (const usage of entries) {
|
|
if (!usage) continue;
|
|
const measurement = usageMeasurement(usage);
|
|
const input =
|
|
firstNumber(measurement, ["inputTokens", "input", "promptTokens"]) ?? 0;
|
|
const output =
|
|
firstNumber(measurement, [
|
|
"outputTokens",
|
|
"output",
|
|
"completionTokens",
|
|
]) ?? 0;
|
|
const cached =
|
|
firstNumber(measurement, [
|
|
"cachedInputTokens",
|
|
"cacheReadTokens",
|
|
"cachedReadTokens",
|
|
]) ?? 0;
|
|
inputTokens += input;
|
|
outputTokens += output;
|
|
cachedInputTokens += cached;
|
|
if (input > 0 || output > 0 || cached > 0) runsWithTokenUsage += 1;
|
|
const costUsd = usageCostUsd(usage);
|
|
if (costUsd !== undefined && (costUsd > 0 || input > 0 || output > 0)) {
|
|
runsWithReportedCost += 1;
|
|
reportedCostUsd += costUsd;
|
|
}
|
|
}
|
|
const runCount = Math.max(requestedRunCount, entries.length);
|
|
const costStatus =
|
|
runsWithReportedCost === runCount
|
|
? "reported"
|
|
: runsWithReportedCost > 0
|
|
? "partial"
|
|
: runsWithTokenUsage > 0
|
|
? "unpriced"
|
|
: "unavailable";
|
|
const runtime = { ...fallbackRuntimeUsage(result) };
|
|
if (browserOnly) runtime.agentRunDurationMs = 0;
|
|
const estimatedRuntimeCostUsd = runtime.estimatedListCostUsd ?? 0;
|
|
const judgments = [...(result.firstTaskQuality ? [result.firstTaskQuality] : []), ...(result.completionQuality ?? [])];
|
|
const sumKnown = (key: "inputTokens" | "outputTokens" | "estimatedCostUsd") => judgments.some(q => q[key] === null) ? null : judgments.reduce((sum, q) => sum + (q[key] ?? 0), 0);
|
|
const quality = judgments.length ? { inputTokens: sumKnown("inputTokens"), outputTokens: sumKnown("outputTokens"), estimatedCostUsd: sumKnown("estimatedCostUsd"), reservedCostUsd: judgments.reduce((sum, q) => sum + q.reservedCostUsd, 0) } : undefined;
|
|
// The operations receipt covers setup and agent requests for ONE owning attempt.
|
|
// Descendants reuse the fixture context without copying its receipt or charges.
|
|
const operations = result.productJourney?.operations;
|
|
const modelSubtotal = operations ? operations.modelCostUsd : reportedCostUsd;
|
|
const complete =
|
|
(!quality || quality.estimatedCostUsd !== null) &&
|
|
(!operations || operations.modelCostUsd !== null) &&
|
|
runsWithTokenUsage === runCount &&
|
|
runsWithReportedCost === runCount &&
|
|
runtime.costStatus !== "unavailable";
|
|
return {
|
|
...(result.publicMcp ? { assistant: result.publicMcp } : {}),
|
|
llm: {
|
|
runCount,
|
|
runsWithTokenUsage,
|
|
runsWithReportedCost,
|
|
inputTokens,
|
|
outputTokens,
|
|
cachedInputTokens,
|
|
totalTokens: inputTokens + cachedInputTokens + outputTokens,
|
|
reportedCostUsd,
|
|
costStatus,
|
|
},
|
|
runtime,
|
|
reportedCostUsd,
|
|
estimatedRuntimeCostUsd,
|
|
...(quality ? { judge: { inputTokens: quality.inputTokens, outputTokens: quality.outputTokens, estimatedCostUsd: quality.estimatedCostUsd, reservedCostUsd: quality.reservedCostUsd } } : {}),
|
|
observedAndEstimatedCostUsd: modelSubtotal === null || quality?.estimatedCostUsd === null ? null : modelSubtotal + estimatedRuntimeCostUsd + (quality?.estimatedCostUsd ?? 0) + (result.publicMcp?.estimatedCostUsd ?? 0),
|
|
complete,
|
|
};
|
|
}
|
|
|
|
export function aggregateCampaignBilling(
|
|
results: readonly RunnerE2EResult[],
|
|
): CampaignBillingSummary {
|
|
const summaries = results.map(summarizeExecutionBilling);
|
|
const runCount = summaries.reduce(
|
|
(total, summary) => total + summary.llm.runCount,
|
|
0,
|
|
);
|
|
const runsWithTokenUsage = summaries.reduce(
|
|
(total, summary) => total + summary.llm.runsWithTokenUsage,
|
|
0,
|
|
);
|
|
const runsWithReportedCost = summaries.reduce(
|
|
(total, summary) => total + summary.llm.runsWithReportedCost,
|
|
0,
|
|
);
|
|
const reportedLlmCostUsd = summaries.reduce(
|
|
(total, summary) => total + summary.reportedCostUsd,
|
|
0,
|
|
);
|
|
const estimatedRuntimeCostUsd = summaries.reduce(
|
|
(total, summary) => total + summary.estimatedRuntimeCostUsd,
|
|
0,
|
|
);
|
|
const judges = summaries.flatMap(summary => summary.judge ? [summary.judge] : []);
|
|
const assistants = summaries.flatMap(summary => summary.assistant ? [summary.assistant] : []);
|
|
return {
|
|
...(assistants.length ? { assistant: {
|
|
requests: assistants.reduce((n, u) => n + u.requests, 0),
|
|
inputTokens: assistants.reduce((n, u) => n + u.inputTokens, 0),
|
|
outputTokens: assistants.reduce((n, u) => n + u.outputTokens, 0),
|
|
cachedInputTokens: assistants.reduce((n, u) => n + u.cachedInputTokens, 0),
|
|
estimatedCostUsd: assistants.reduce((n, u) => n + u.estimatedCostUsd, 0),
|
|
} } : {}),
|
|
...(judges.length ? { judge: {
|
|
attempts: judges.length,
|
|
inputTokens: judges.reduce((n, q) => n + (q.inputTokens ?? 0), 0),
|
|
outputTokens: judges.reduce((n, q) => n + (q.outputTokens ?? 0), 0),
|
|
estimatedCostUsd: judges.some(q => q.estimatedCostUsd === null) ? null : judges.reduce((n, q) => n + (q.estimatedCostUsd ?? 0), 0),
|
|
reservedCostUsd: judges.reduce((n, q) => n + q.reservedCostUsd, 0),
|
|
attemptsWithUnknownUsage: judges.filter(q => q.estimatedCostUsd === null).length,
|
|
} } : {}),
|
|
testCount: results.length,
|
|
agentRunDurationMs: summaries.reduce(
|
|
(total, summary) => total + summary.runtime.agentRunDurationMs,
|
|
0,
|
|
),
|
|
leaseDurationMs: summaries.reduce(
|
|
(total, summary) => total + (summary.runtime.leaseDurationMs ?? 0),
|
|
0,
|
|
),
|
|
llm: {
|
|
runCount,
|
|
runsWithTokenUsage,
|
|
runsWithReportedCost,
|
|
inputTokens: summaries.reduce(
|
|
(total, summary) => total + summary.llm.inputTokens,
|
|
0,
|
|
),
|
|
outputTokens: summaries.reduce(
|
|
(total, summary) => total + summary.llm.outputTokens,
|
|
0,
|
|
),
|
|
cachedInputTokens: summaries.reduce(
|
|
(total, summary) => total + summary.llm.cachedInputTokens,
|
|
0,
|
|
),
|
|
totalTokens: summaries.reduce(
|
|
(total, summary) => total + summary.llm.totalTokens,
|
|
0,
|
|
),
|
|
reportedCostUsd: reportedLlmCostUsd,
|
|
costStatus:
|
|
runsWithReportedCost === runCount
|
|
? "reported"
|
|
: runsWithReportedCost > 0
|
|
? "partial"
|
|
: runsWithTokenUsage > 0
|
|
? "unpriced"
|
|
: "unavailable",
|
|
},
|
|
reportedLlmCostUsd,
|
|
estimatedRuntimeCostUsd,
|
|
observedAndEstimatedCostUsd: summaries.some(s => s.observedAndEstimatedCostUsd === null) ? null : summaries.reduce((total, summary) => total + (summary.observedAndEstimatedCostUsd ?? 0), 0),
|
|
testsWithCompleteBilling: summaries.filter((summary) => summary.complete)
|
|
.length,
|
|
};
|
|
}
|