mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-14 08:26:46 +02:00
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - The Apps gateway gives agents governed access to external tools. > - Browser Use Cloud can run browser work, but a tool result alone does not let a person watch or take over. > - A task needs a durable browser session, a visible viewer, and recorded costs. > - This pull request adds a Browser Use Cloud v4 connection and interactive browser tabs on tasks. > - People can follow the work, interact with the page, and retain the browser after the agent finishes. ## Linked Issues or Issue Description **Problem or motivation** Agents need governed access to Browser Use Cloud. People need to see and interact with the same browser from the task. A browser must remain available after a run finishes and appear at the correct point in the task feed. **Proposed solution** Add a native REST connection for the v4 API. Bind each session to its company, task, agent, and credential grant. Open its interactive viewer in the task side panel. Record provider costs as financial events. Use `browser-use-cloud` as the app and connector key. Keep its skill with the connector and deliver it only with authorized connection tools. **Alternatives considered** A v3 MCP connection would expose tools without the v4 lifecycle integration. An external viewer link would leave the task. A fixed viewer size would prevent pages from responding to changes in the task pane. **Roadmap alignment** This extends the governed Apps gateway and Connected Apps roadmap. It uses the existing task, grant, secret, approval, and financial records. The work was requested by the maintainer. A search found no duplicate Browser Use connector PR or issue. ## What Changed - Add the Browser Use Cloud app, brand asset, API-key connection, and profile settings under the `browser-use-cloud` key. - Bundle the `browser-use-cloud` skill with the connector. Keep it out of global `skills/` discovery. Deliver it only with authorized task/run connection tools. Remove retired connector skill keys from runtime overlays and preserve unrelated browser skills. - Expose seven v4 tools through the governed gateway and deliver them to native and CLI agents. - Persist sessions, browsers, runs, event and recovery cursors, shutdown leases, and cumulative cost accounting. Recover uncertain paid starts without replaying them. - Enforce task ownership, credential grants, approvals, revoked access, and budget limits. - Add interactive task browser tabs and compact chronological feed entries. Retain the viewer across tab switches and keep visible idle browsers open. - Add debounced automatic viewport fitting, standard size presets, and a viewer ownership lease. - Add lifecycle, authorization, accounting, viewport, UI, and Storybook coverage. - Add an idempotent database migration after the current master migration. Preserve deployed migration hashes. Migrate pre-release Cloud connection and financial keys without replacing grants, credentials, or browser history. - Document provider behavior, live acceptance results, and the lack of documented passkey forwarding. ## Verification - Full workspace typecheck and production build pass on the updated branch. - Token gates, brand asset validation, module boundaries, and migration ordering pass. - Cloud tests verify global skill exclusion, authorized task/run delivery, unassigned agents, disabled connections, revocation, adapter isolation, and secret exclusion. The existing AgentMail connector assignment test also passes. - Migration replay runs twice against existing browser work and financial records. It preserves the records and avoids duplicate costs. - The focused provider, app catalog, OpenAPI, connection gateway, and migration regression suites pass. Recovery coverage includes lost replies, process crashes, provider rejection, and browser arrival acknowledgement. - All 54 checks pass on `2974b5f03641ad0cea3c941d8c02579316fa8c92`, including the full test matrix, browser E2E shards, build, typecheck, security, and release canary. Two optional Storybook jobs are skipped. - Greptile is 5/5 on the same commit, with zero unresolved review threads. The corrected review uses the actual master-to-head diff. - The local `pnpm test:run` started and was stopped after the full CI matrix passed. It did not complete locally; the full-suite result above comes from CI. - Earlier live acceptance used an isolated company with a capped provider credential. The agent opened paperclip.ing, the embedded viewer accepted navigation, and the same browser stayed available after completion and tab switches. - The local Storybook build passes. Stories cover the panel, footer, feed entries, settings, lifecycle failures, and viewport modes with an offline viewer fixture. ## Risks - Browser Use charges for hosted work. Provider caps and local budget checks reduce exposure; reported costs can arrive after work completes. - Viewer and CDP URLs grant access to the browser. The server validates and restricts them. They are excluded from agent results and durable event data. - Runtime resizing of v4 agent browsers uses a provider option confirmed by live testing but absent from its published agent schema. Resizing during a click may invalidate coordinates. Fixed presets remain available. - Viewport ownership is process-local and resets on restart. The lifecycle and accounting records remain in the database. - The original intermittent embedded-viewer stall has not been fully diagnosed. A bounded reconnect and active-session recovery cover the observed failure paths. - Live tests did not cover every revocation, approval, rate-limit, or restart case. Deterministic integration tests cover those paths. Passkey forwarding is not claimed. - Unknown create outcomes keep the credential available for cleanup. Run-list absence cannot prove a paid POST was rejected, so recovery stays pending until it can identify provider work. ## Model Used OpenAI Codex, GPT-6. Used reasoning, repository search, code execution, browser interaction, and test tools. The exact serving model ID and context-window size were not exposed in this session. ## 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 - [x] 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>
524 lines
22 KiB
TypeScript
524 lines
22 KiB
TypeScript
import { agentAvatarUrl, resolveAgentAppearance } from "@paperclipai/shared";
|
|
import { and, desc, eq, gte, isNotNull, isNull, lt, lte, sql } from "drizzle-orm";
|
|
import { alias } from "drizzle-orm/pg-core";
|
|
import type { Db } from "@paperclipai/db";
|
|
import { activityLog, agents, companies, costEvents, heartbeatRuns, issues, projects } from "@paperclipai/db";
|
|
import { notFound, unprocessable } from "../errors.js";
|
|
import { budgetService, type BudgetServiceHooks } from "./budgets.js";
|
|
import { visibleIssueCondition } from "./issue-visibility.js";
|
|
|
|
export interface CostDateRange {
|
|
from?: Date;
|
|
to?: Date;
|
|
}
|
|
|
|
const METERED_BILLING_TYPE = "metered_api";
|
|
const SUBSCRIPTION_BILLING_TYPES = ["subscription_included", "subscription_overage"] as const;
|
|
|
|
function sumAsNumber(column: typeof costEvents.costCents | typeof costEvents.inputTokens | typeof costEvents.cachedInputTokens | typeof costEvents.outputTokens) {
|
|
return sql<number>`coalesce(sum(${column}), 0)::double precision`;
|
|
}
|
|
|
|
function currentUtcMonthWindow(now = new Date()) {
|
|
const year = now.getUTCFullYear();
|
|
const month = now.getUTCMonth();
|
|
return {
|
|
start: new Date(Date.UTC(year, month, 1, 0, 0, 0, 0)),
|
|
end: new Date(Date.UTC(year, month + 1, 1, 0, 0, 0, 0)),
|
|
};
|
|
}
|
|
|
|
export async function getMonthlySpendTotal(
|
|
db: Db,
|
|
scope: { companyId: string; agentId?: string | null },
|
|
) {
|
|
const { start, end } = currentUtcMonthWindow();
|
|
const conditions = [
|
|
eq(costEvents.companyId, scope.companyId),
|
|
gte(costEvents.occurredAt, start),
|
|
lt(costEvents.occurredAt, end),
|
|
];
|
|
if (scope.agentId) {
|
|
conditions.push(eq(costEvents.agentId, scope.agentId));
|
|
}
|
|
const [row] = await db
|
|
.select({
|
|
total: sumAsNumber(costEvents.costCents),
|
|
})
|
|
.from(costEvents)
|
|
.where(and(...conditions));
|
|
return Number(row?.total ?? 0);
|
|
}
|
|
|
|
export function costService(db: Db, budgetHooks: BudgetServiceHooks = {}) {
|
|
const budgets = budgetService(db, budgetHooks);
|
|
return {
|
|
createEvent: async (companyId: string, data: Omit<typeof costEvents.$inferInsert, "companyId">) => {
|
|
const agent = await db
|
|
.select()
|
|
.from(agents)
|
|
.where(eq(agents.id, data.agentId))
|
|
.then((rows) => rows[0] ?? null);
|
|
|
|
if (!agent) throw notFound("Agent not found");
|
|
if (agent.companyId !== companyId) {
|
|
throw unprocessable("Agent does not belong to company");
|
|
}
|
|
|
|
const event = await db
|
|
.insert(costEvents)
|
|
.values({
|
|
...data,
|
|
companyId,
|
|
biller: data.biller ?? data.provider,
|
|
billingType: data.billingType ?? "unknown",
|
|
cachedInputTokens: data.cachedInputTokens ?? 0,
|
|
})
|
|
.returning()
|
|
.then((rows) => rows[0]);
|
|
|
|
const [agentMonthSpend, companyMonthSpend] = await Promise.all([
|
|
getMonthlySpendTotal(db, { companyId, agentId: event.agentId }),
|
|
getMonthlySpendTotal(db, { companyId }),
|
|
]);
|
|
|
|
await db
|
|
.update(agents)
|
|
.set({
|
|
spentMonthlyCents: agentMonthSpend,
|
|
updatedAt: new Date(),
|
|
})
|
|
.where(eq(agents.id, event.agentId));
|
|
|
|
await db
|
|
.update(companies)
|
|
.set({
|
|
spentMonthlyCents: companyMonthSpend,
|
|
updatedAt: new Date(),
|
|
})
|
|
.where(eq(companies.id, companyId));
|
|
|
|
await budgets.evaluateCostEvent(event);
|
|
|
|
return event;
|
|
},
|
|
|
|
summary: async (companyId: string, range?: CostDateRange) => {
|
|
const company = await db
|
|
.select()
|
|
.from(companies)
|
|
.where(eq(companies.id, companyId))
|
|
.then((rows) => rows[0] ?? null);
|
|
|
|
if (!company) throw notFound("Company not found");
|
|
|
|
const conditions: ReturnType<typeof eq>[] = [eq(costEvents.companyId, companyId)];
|
|
if (range?.from) conditions.push(gte(costEvents.occurredAt, range.from));
|
|
if (range?.to) conditions.push(lte(costEvents.occurredAt, range.to));
|
|
|
|
const [{ total }] = await db
|
|
.select({
|
|
total: sumAsNumber(costEvents.costCents),
|
|
})
|
|
.from(costEvents)
|
|
.where(and(...conditions));
|
|
|
|
const spendCents = Number(total);
|
|
const utilization =
|
|
company.budgetMonthlyCents > 0
|
|
? (spendCents / company.budgetMonthlyCents) * 100
|
|
: 0;
|
|
|
|
return {
|
|
companyId,
|
|
spendCents,
|
|
budgetCents: company.budgetMonthlyCents,
|
|
utilizationPercent: Number(utilization.toFixed(2)),
|
|
};
|
|
},
|
|
|
|
issueTreeSummary: async (
|
|
companyId: string,
|
|
issueId: string,
|
|
options: { excludeRoot?: boolean } = {},
|
|
) => {
|
|
// Callers must resolve and authorize a visible root issue before invoking this.
|
|
// The route does that so zero counts are not mistaken for a missing root.
|
|
const childIssues = alias(issues, "child");
|
|
|
|
// The seed of the recursive CTE: when excludeRoot is true, start from
|
|
// the direct children so the root issue itself is not counted.
|
|
const cteSeed = options.excludeRoot
|
|
? sql`
|
|
SELECT ${issues.id}
|
|
FROM ${issues}
|
|
WHERE ${issues.companyId} = ${companyId}
|
|
AND ${issues.parentId} = ${issueId}
|
|
AND ${issues.hiddenAt} IS NULL
|
|
AND ${issues.harnessKind} IS NULL
|
|
`
|
|
: sql`
|
|
SELECT ${issues.id}
|
|
FROM ${issues}
|
|
WHERE ${issues.companyId} = ${companyId}
|
|
AND ${issues.id} = ${issueId}
|
|
AND ${issues.hiddenAt} IS NULL
|
|
AND ${issues.harnessKind} IS NULL
|
|
`;
|
|
|
|
const cteSeedText = options.excludeRoot
|
|
? sql`
|
|
SELECT (${issues.id})::text AS id
|
|
FROM ${issues}
|
|
WHERE ${issues.companyId} = ${companyId}
|
|
AND ${issues.parentId} = ${issueId}
|
|
AND ${issues.hiddenAt} IS NULL
|
|
AND ${issues.harnessKind} IS NULL
|
|
`
|
|
: sql`
|
|
SELECT (${issues.id})::text AS id
|
|
FROM ${issues}
|
|
WHERE ${issues.companyId} = ${companyId}
|
|
AND ${issues.id} = ${issueId}
|
|
AND ${issues.hiddenAt} IS NULL
|
|
AND ${issues.harnessKind} IS NULL
|
|
`;
|
|
|
|
const issueTreeCondition = sql<boolean>`
|
|
${issues.id} IN (
|
|
WITH RECURSIVE issue_tree(id) AS (
|
|
${cteSeed}
|
|
UNION ALL
|
|
SELECT ${childIssues.id}
|
|
FROM ${issues} ${childIssues}
|
|
JOIN issue_tree ON ${childIssues.parentId} = issue_tree.id
|
|
WHERE ${childIssues.companyId} = ${companyId}
|
|
AND ${childIssues.hiddenAt} IS NULL
|
|
AND ${childIssues.harnessKind} IS NULL
|
|
)
|
|
SELECT id FROM issue_tree
|
|
)
|
|
`;
|
|
|
|
const runSummarySql = sql`
|
|
WITH RECURSIVE issue_tree(id) AS (
|
|
${cteSeedText}
|
|
UNION ALL
|
|
SELECT (${childIssues.id})::text
|
|
FROM ${issues} ${childIssues}
|
|
JOIN issue_tree ON (${childIssues.parentId})::text = issue_tree.id
|
|
WHERE ${childIssues.companyId} = ${companyId}
|
|
AND ${childIssues.hiddenAt} IS NULL
|
|
AND ${childIssues.harnessKind} IS NULL
|
|
)
|
|
SELECT
|
|
count(distinct ${heartbeatRuns.id})::int AS "runCount",
|
|
coalesce(sum(extract(epoch from (coalesce(${heartbeatRuns.finishedAt}, now()) - ${heartbeatRuns.startedAt})) * 1000), 0)::double precision AS "runtimeMs"
|
|
FROM ${heartbeatRuns}
|
|
WHERE ${heartbeatRuns.companyId} = ${companyId}
|
|
AND ${heartbeatRuns.startedAt} IS NOT NULL
|
|
AND (
|
|
${heartbeatRuns.contextSnapshot} ->> 'issueId' IN (SELECT id FROM issue_tree)
|
|
OR EXISTS (
|
|
SELECT 1
|
|
FROM ${activityLog}
|
|
JOIN issue_tree ON ${activityLog.entityId} = issue_tree.id
|
|
WHERE ${activityLog.companyId} = ${companyId}
|
|
AND ${activityLog.entityType} = 'issue'
|
|
AND ${activityLog.runId} = ${heartbeatRuns.id}
|
|
)
|
|
)
|
|
`;
|
|
|
|
// Run cost-event aggregation and run-duration aggregation in parallel.
|
|
// They're separate queries because cost_events fan out per-event and
|
|
// joining heartbeat_runs through them would double-count run durations.
|
|
const [costRowResult, runRowResult] = await Promise.all([
|
|
db
|
|
.select({
|
|
issueCount: sql<number>`count(distinct ${issues.id})::int`,
|
|
costCents: sumAsNumber(costEvents.costCents),
|
|
inputTokens: sumAsNumber(costEvents.inputTokens),
|
|
cachedInputTokens: sumAsNumber(costEvents.cachedInputTokens),
|
|
outputTokens: sumAsNumber(costEvents.outputTokens),
|
|
})
|
|
.from(issues)
|
|
.leftJoin(
|
|
costEvents,
|
|
and(
|
|
eq(costEvents.companyId, companyId),
|
|
eq(costEvents.issueId, issues.id),
|
|
),
|
|
)
|
|
.where(
|
|
and(
|
|
eq(issues.companyId, companyId),
|
|
visibleIssueCondition(),
|
|
issueTreeCondition,
|
|
),
|
|
),
|
|
db.execute(runSummarySql),
|
|
]);
|
|
|
|
const costRow = costRowResult[0];
|
|
const runRow = Array.isArray(runRowResult)
|
|
? (runRowResult[0] as { runCount?: number | string | null; runtimeMs?: number | string | null } | undefined)
|
|
: undefined;
|
|
|
|
return {
|
|
issueId,
|
|
issueCount: Number(costRow?.issueCount ?? 0),
|
|
includeDescendants: true,
|
|
costCents: Number(costRow?.costCents ?? 0),
|
|
inputTokens: Number(costRow?.inputTokens ?? 0),
|
|
cachedInputTokens: Number(costRow?.cachedInputTokens ?? 0),
|
|
outputTokens: Number(costRow?.outputTokens ?? 0),
|
|
runCount: Number(runRow?.runCount ?? 0),
|
|
runtimeMs: Number(runRow?.runtimeMs ?? 0),
|
|
};
|
|
},
|
|
|
|
byAgent: async (companyId: string, range?: CostDateRange) => {
|
|
const conditions: ReturnType<typeof eq>[] = [eq(costEvents.companyId, companyId)];
|
|
if (range?.from) conditions.push(gte(costEvents.occurredAt, range.from));
|
|
if (range?.to) conditions.push(lte(costEvents.occurredAt, range.to));
|
|
|
|
const rows = await db
|
|
.select({
|
|
agentId: costEvents.agentId,
|
|
agentName: agents.name,
|
|
agentAppearance: agents.appearance,
|
|
agentStatus: agents.status,
|
|
costCents: sumAsNumber(costEvents.costCents),
|
|
inputTokens: sumAsNumber(costEvents.inputTokens),
|
|
cachedInputTokens: sumAsNumber(costEvents.cachedInputTokens),
|
|
outputTokens: sumAsNumber(costEvents.outputTokens),
|
|
apiRunCount:
|
|
sql<number>`count(distinct case when ${costEvents.billingType} = ${METERED_BILLING_TYPE} then ${costEvents.heartbeatRunId} end)::int`,
|
|
subscriptionRunCount:
|
|
sql<number>`count(distinct case when ${costEvents.billingType} in (${sql.join(SUBSCRIPTION_BILLING_TYPES.map((value) => sql`${value}`), sql`, `)}) then ${costEvents.heartbeatRunId} end)::int`,
|
|
subscriptionCachedInputTokens:
|
|
sql<number>`coalesce(sum(case when ${costEvents.billingType} in (${sql.join(SUBSCRIPTION_BILLING_TYPES.map((value) => sql`${value}`), sql`, `)}) then ${costEvents.cachedInputTokens} else 0 end), 0)::double precision`,
|
|
subscriptionInputTokens:
|
|
sql<number>`coalesce(sum(case when ${costEvents.billingType} in (${sql.join(SUBSCRIPTION_BILLING_TYPES.map((value) => sql`${value}`), sql`, `)}) then ${costEvents.inputTokens} else 0 end), 0)::double precision`,
|
|
subscriptionOutputTokens:
|
|
sql<number>`coalesce(sum(case when ${costEvents.billingType} in (${sql.join(SUBSCRIPTION_BILLING_TYPES.map((value) => sql`${value}`), sql`, `)}) then ${costEvents.outputTokens} else 0 end), 0)::double precision`,
|
|
})
|
|
.from(costEvents)
|
|
.leftJoin(agents, eq(costEvents.agentId, agents.id))
|
|
.where(and(...conditions))
|
|
.groupBy(costEvents.agentId, agents.name, agents.appearance, agents.status)
|
|
.orderBy(desc(sumAsNumber(costEvents.costCents)));
|
|
return rows.map(row => {
|
|
const appearance = resolveAgentAppearance(row.agentAppearance, row.agentId);
|
|
return { ...row, agentAppearance: appearance, avatarUrl: agentAvatarUrl(appearance, 512) };
|
|
});
|
|
},
|
|
|
|
byProvider: async (companyId: string, range?: CostDateRange) => {
|
|
const conditions: ReturnType<typeof eq>[] = [eq(costEvents.companyId, companyId)];
|
|
if (range?.from) conditions.push(gte(costEvents.occurredAt, range.from));
|
|
if (range?.to) conditions.push(lte(costEvents.occurredAt, range.to));
|
|
|
|
return db
|
|
.select({
|
|
provider: costEvents.provider,
|
|
biller: costEvents.biller,
|
|
billingType: costEvents.billingType,
|
|
model: costEvents.model,
|
|
costCents: sumAsNumber(costEvents.costCents),
|
|
inputTokens: sumAsNumber(costEvents.inputTokens),
|
|
cachedInputTokens: sumAsNumber(costEvents.cachedInputTokens),
|
|
outputTokens: sumAsNumber(costEvents.outputTokens),
|
|
apiRunCount:
|
|
sql<number>`count(distinct case when ${costEvents.billingType} = ${METERED_BILLING_TYPE} then ${costEvents.heartbeatRunId} end)::int`,
|
|
subscriptionRunCount:
|
|
sql<number>`count(distinct case when ${costEvents.billingType} in (${sql.join(SUBSCRIPTION_BILLING_TYPES.map((value) => sql`${value}`), sql`, `)}) then ${costEvents.heartbeatRunId} end)::int`,
|
|
subscriptionCachedInputTokens:
|
|
sql<number>`coalesce(sum(case when ${costEvents.billingType} in (${sql.join(SUBSCRIPTION_BILLING_TYPES.map((value) => sql`${value}`), sql`, `)}) then ${costEvents.cachedInputTokens} else 0 end), 0)::double precision`,
|
|
subscriptionInputTokens:
|
|
sql<number>`coalesce(sum(case when ${costEvents.billingType} in (${sql.join(SUBSCRIPTION_BILLING_TYPES.map((value) => sql`${value}`), sql`, `)}) then ${costEvents.inputTokens} else 0 end), 0)::double precision`,
|
|
subscriptionOutputTokens:
|
|
sql<number>`coalesce(sum(case when ${costEvents.billingType} in (${sql.join(SUBSCRIPTION_BILLING_TYPES.map((value) => sql`${value}`), sql`, `)}) then ${costEvents.outputTokens} else 0 end), 0)::double precision`,
|
|
})
|
|
.from(costEvents)
|
|
.where(and(...conditions))
|
|
.groupBy(costEvents.provider, costEvents.biller, costEvents.billingType, costEvents.model)
|
|
.orderBy(desc(sumAsNumber(costEvents.costCents)));
|
|
},
|
|
|
|
byBiller: async (companyId: string, range?: CostDateRange) => {
|
|
const conditions: ReturnType<typeof eq>[] = [eq(costEvents.companyId, companyId)];
|
|
if (range?.from) conditions.push(gte(costEvents.occurredAt, range.from));
|
|
if (range?.to) conditions.push(lte(costEvents.occurredAt, range.to));
|
|
|
|
return db
|
|
.select({
|
|
biller: costEvents.biller,
|
|
costCents: sumAsNumber(costEvents.costCents),
|
|
inputTokens: sumAsNumber(costEvents.inputTokens),
|
|
cachedInputTokens: sumAsNumber(costEvents.cachedInputTokens),
|
|
outputTokens: sumAsNumber(costEvents.outputTokens),
|
|
apiRunCount:
|
|
sql<number>`count(distinct case when ${costEvents.billingType} = ${METERED_BILLING_TYPE} then ${costEvents.heartbeatRunId} end)::int`,
|
|
subscriptionRunCount:
|
|
sql<number>`count(distinct case when ${costEvents.billingType} in (${sql.join(SUBSCRIPTION_BILLING_TYPES.map((value) => sql`${value}`), sql`, `)}) then ${costEvents.heartbeatRunId} end)::int`,
|
|
subscriptionCachedInputTokens:
|
|
sql<number>`coalesce(sum(case when ${costEvents.billingType} in (${sql.join(SUBSCRIPTION_BILLING_TYPES.map((value) => sql`${value}`), sql`, `)}) then ${costEvents.cachedInputTokens} else 0 end), 0)::double precision`,
|
|
subscriptionInputTokens:
|
|
sql<number>`coalesce(sum(case when ${costEvents.billingType} in (${sql.join(SUBSCRIPTION_BILLING_TYPES.map((value) => sql`${value}`), sql`, `)}) then ${costEvents.inputTokens} else 0 end), 0)::double precision`,
|
|
subscriptionOutputTokens:
|
|
sql<number>`coalesce(sum(case when ${costEvents.billingType} in (${sql.join(SUBSCRIPTION_BILLING_TYPES.map((value) => sql`${value}`), sql`, `)}) then ${costEvents.outputTokens} else 0 end), 0)::double precision`,
|
|
providerCount: sql<number>`count(distinct ${costEvents.provider})::int`,
|
|
modelCount: sql<number>`count(distinct ${costEvents.model})::int`,
|
|
})
|
|
.from(costEvents)
|
|
.where(and(...conditions))
|
|
.groupBy(costEvents.biller)
|
|
.orderBy(desc(sumAsNumber(costEvents.costCents)));
|
|
},
|
|
|
|
/**
|
|
* aggregates cost_events by provider for each of three rolling windows:
|
|
* last 5 hours, last 24 hours, last 7 days.
|
|
* purely internal consumption data, no external rate-limit sources.
|
|
*/
|
|
windowSpend: async (companyId: string) => {
|
|
const windows = [
|
|
{ label: "5h", hours: 5 },
|
|
{ label: "24h", hours: 24 },
|
|
{ label: "7d", hours: 168 },
|
|
] as const;
|
|
|
|
const results = await Promise.all(
|
|
windows.map(async ({ label, hours }) => {
|
|
const since = new Date(Date.now() - hours * 60 * 60 * 1000);
|
|
const rows = await db
|
|
.select({
|
|
provider: costEvents.provider,
|
|
biller: sql<string>`case when count(distinct ${costEvents.biller}) = 1 then min(${costEvents.biller}) else 'mixed' end`,
|
|
costCents: sumAsNumber(costEvents.costCents),
|
|
inputTokens: sumAsNumber(costEvents.inputTokens),
|
|
cachedInputTokens: sumAsNumber(costEvents.cachedInputTokens),
|
|
outputTokens: sumAsNumber(costEvents.outputTokens),
|
|
})
|
|
.from(costEvents)
|
|
.where(
|
|
and(
|
|
eq(costEvents.companyId, companyId),
|
|
gte(costEvents.occurredAt, since),
|
|
),
|
|
)
|
|
.groupBy(costEvents.provider)
|
|
.orderBy(desc(sumAsNumber(costEvents.costCents)));
|
|
|
|
return rows.map((row) => ({
|
|
provider: row.provider,
|
|
biller: row.biller,
|
|
window: label as string,
|
|
windowHours: hours,
|
|
costCents: row.costCents,
|
|
inputTokens: row.inputTokens,
|
|
cachedInputTokens: row.cachedInputTokens,
|
|
outputTokens: row.outputTokens,
|
|
}));
|
|
}),
|
|
);
|
|
|
|
return results.flat();
|
|
},
|
|
|
|
byAgentModel: async (companyId: string, range?: CostDateRange) => {
|
|
const conditions: ReturnType<typeof eq>[] = [eq(costEvents.companyId, companyId)];
|
|
if (range?.from) conditions.push(gte(costEvents.occurredAt, range.from));
|
|
if (range?.to) conditions.push(lte(costEvents.occurredAt, range.to));
|
|
|
|
// single query: group by agent + provider + model.
|
|
// the (companyId, agentId, occurredAt) composite index covers this well.
|
|
// order by provider + model for stable db-level ordering; cost-desc sort
|
|
// within each agent's sub-rows is done client-side in the ui memo.
|
|
const rows = await db
|
|
.select({
|
|
agentId: costEvents.agentId,
|
|
agentName: agents.name,
|
|
agentAppearance: agents.appearance,
|
|
provider: costEvents.provider,
|
|
biller: costEvents.biller,
|
|
billingType: costEvents.billingType,
|
|
model: costEvents.model,
|
|
costCents: sumAsNumber(costEvents.costCents),
|
|
inputTokens: sumAsNumber(costEvents.inputTokens),
|
|
cachedInputTokens: sumAsNumber(costEvents.cachedInputTokens),
|
|
outputTokens: sumAsNumber(costEvents.outputTokens),
|
|
})
|
|
.from(costEvents)
|
|
.leftJoin(agents, eq(costEvents.agentId, agents.id))
|
|
.where(and(...conditions))
|
|
.groupBy(
|
|
costEvents.agentId,
|
|
agents.name,
|
|
agents.appearance,
|
|
costEvents.provider,
|
|
costEvents.biller,
|
|
costEvents.billingType,
|
|
costEvents.model,
|
|
)
|
|
.orderBy(costEvents.provider, costEvents.biller, costEvents.billingType, costEvents.model);
|
|
return rows.map(row => {
|
|
const appearance = resolveAgentAppearance(row.agentAppearance, row.agentId);
|
|
return { ...row, agentAppearance: appearance, avatarUrl: agentAvatarUrl(appearance, 512) };
|
|
});
|
|
},
|
|
|
|
byProject: async (companyId: string, range?: CostDateRange) => {
|
|
const issueIdAsText = sql<string>`${issues.id}::text`;
|
|
const runProjectLinks = db
|
|
.selectDistinctOn([activityLog.runId, issues.projectId], {
|
|
runId: activityLog.runId,
|
|
projectId: issues.projectId,
|
|
})
|
|
.from(activityLog)
|
|
.innerJoin(
|
|
issues,
|
|
and(
|
|
eq(activityLog.entityType, "issue"),
|
|
eq(activityLog.entityId, issueIdAsText),
|
|
),
|
|
)
|
|
.where(
|
|
and(
|
|
eq(activityLog.companyId, companyId),
|
|
eq(issues.companyId, companyId),
|
|
isNotNull(activityLog.runId),
|
|
isNotNull(issues.projectId),
|
|
),
|
|
)
|
|
.orderBy(activityLog.runId, issues.projectId, desc(activityLog.createdAt))
|
|
.as("run_project_links");
|
|
|
|
const effectiveProjectId = sql<string | null>`coalesce(${costEvents.projectId}, ${runProjectLinks.projectId})`;
|
|
const conditions: ReturnType<typeof eq>[] = [eq(costEvents.companyId, companyId)];
|
|
if (range?.from) conditions.push(gte(costEvents.occurredAt, range.from));
|
|
if (range?.to) conditions.push(lte(costEvents.occurredAt, range.to));
|
|
|
|
const costCentsExpr = sumAsNumber(costEvents.costCents);
|
|
|
|
return db
|
|
.select({
|
|
projectId: effectiveProjectId,
|
|
projectName: projects.name,
|
|
costCents: costCentsExpr,
|
|
inputTokens: sumAsNumber(costEvents.inputTokens),
|
|
cachedInputTokens: sumAsNumber(costEvents.cachedInputTokens),
|
|
outputTokens: sumAsNumber(costEvents.outputTokens),
|
|
})
|
|
.from(costEvents)
|
|
.leftJoin(runProjectLinks, eq(costEvents.heartbeatRunId, runProjectLinks.runId))
|
|
.innerJoin(projects, sql`${projects.id} = ${effectiveProjectId}`)
|
|
.where(and(...conditions, sql`${effectiveProjectId} is not null`))
|
|
.groupBy(effectiveProjectId, projects.name)
|
|
.orderBy(desc(costCentsExpr));
|
|
},
|
|
};
|
|
}
|