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
62 changes: 62 additions & 0 deletions apps/api/src/routes/internal/vercel-cost.http.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import { HttpApiBuilder } from "effect/http-api"
import {
CurrentTenant,
IntegrationsForbiddenError,
MapleInternalApi,
VercelCostSourceAdded,
VercelCostSourceRemoved,
VercelCostStatus,
} from "@maple/domain/http"
import { Effect } from "effect"
import * as Policy from "@maple/domain/policy"
import { recordHttpAudit } from "@maple/backend/services/audit/AuditLogService"
import { VercelCostSourceService } from "@maple/backend/services/cost/VercelCostSourceService"

// A cost source is a connection like any other: the role that manages integrations manages it.
const requireIntegrationsManager = (roles: CurrentTenant.TenantSchema["roles"]) =>
Policy.check(
roles,
"integrations:manage",
(denial) => new IntegrationsForbiddenError({ message: denial.message }),
)

export const HttpVercelCostLive = HttpApiBuilder.group(MapleInternalApi, "vercelCost", (handlers) =>
Effect.gen(function* () {
const sources = yield* VercelCostSourceService

return handlers
.handle("status", () =>
Effect.gen(function* () {
const tenant = yield* CurrentTenant.Context
const status = yield* sources.status(tenant.orgId)
return new VercelCostStatus(status)
}).pipe(Effect.withSpan("HttpVercelCost.status")),
)
.handle("add", ({ payload }) =>
Effect.gen(function* () {
const tenant = yield* CurrentTenant.Context
yield* requireIntegrationsManager(tenant.roles)
const added = yield* sources.add(tenant.orgId, payload.teamId)
// Adding what is a source already changed nothing, so it leaves no entry.
if (added.created) {
yield* recordHttpAudit("cost_source.created", {
metadata: { provider: "vercel", account: added.team.slug },
})
}
return new VercelCostSourceAdded({ team: added.team })
}).pipe(Effect.withSpan("HttpVercelCost.add")),
)
.handle("remove", () =>
Effect.gen(function* () {
const tenant = yield* CurrentTenant.Context
yield* requireIntegrationsManager(tenant.roles)
const removed = yield* sources.remove(tenant.orgId)
// Removing what is not there changed nothing, so it leaves no entry.
if (removed.removed) {
yield* recordHttpAudit("cost_source.deleted", { metadata: { provider: "vercel" } })
}
return new VercelCostSourceRemoved(removed)
}).pipe(Effect.withSpan("HttpVercelCost.remove")),
)
}),
)
2 changes: 2 additions & 0 deletions apps/api/src/runtime/http-graph.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import { HttpGithubCostLive } from "@/routes/internal/github-cost.http"
import { HttpOpenRouterCostLive } from "@/routes/internal/openrouter-cost.http"
import { HttpOpenRouterKeyLive } from "@/routes/internal/openrouter-key.http"
import { HttpServiceMapWorldLive } from "@/routes/internal/service-map-world.http"
import { HttpVercelCostLive } from "@/routes/internal/vercel-cost.http"
import { HttpAuthLive, HttpAuthPublicLive, HttpAuthSessionLive } from "@/routes/auth.http"
import { HttpBillingLive } from "@/routes/internal/billing.http"
import { HttpBillingPublicLive } from "@/routes/billing-public.http"
Expand Down Expand Up @@ -170,6 +171,7 @@ const ApiInternalRoutes = HttpApiBuilder.layer(MapleInternalApi).pipe(
HttpOpenRouterCostLive,
HttpOpenRouterKeyLive,
HttpServiceMapWorldLive,
HttpVercelCostLive,
),
),
Layer.provide(
Expand Down
2 changes: 2 additions & 0 deletions apps/api/src/runtime/service-graph.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@ import { SupabaseLogsService } from "@maple/backend/services/integrations/Supaba
import { SupabaseOAuthService } from "@maple/backend/services/auth/SupabaseOAuthService"
import { GithubCostSourceService } from "@maple/backend/services/cost/GithubCostSourceService"
import { OpenRouterCostConnectionService } from "@maple/backend/services/cost/OpenRouterCostConnectionService"
import { VercelCostSourceService } from "@maple/backend/services/cost/VercelCostSourceService"
import { OrgOpenRouterKeyService } from "@maple/backend/services/org/OrgOpenRouterKeyService"
import { ServiceMapWorldService } from "@maple/backend/services/service-map/ServiceMapWorldService"
import { ScrapeTargetsService } from "@maple/backend/services/integrations/ScrapeTargetsService"
Expand Down Expand Up @@ -190,6 +191,7 @@ export const HttpServicesLive = Layer.mergeAll(
OrgOpenRouterKeyService.layer,
OpenRouterCostConnectionService.layer,
GithubCostSourceService.layer,
VercelCostSourceService.layer,
ServiceMapWorldService.layer,
AuditLogService.layer,
WarehouseQueryService.layer,
Expand Down
58 changes: 55 additions & 3 deletions apps/web/src/lab/cost-fixture.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ describe("cost lab fixture", () => {
.rows,
(row) => row.key,
),
).toEqual(["aws", "github"])
).toEqual(["aws", "vercel", "github"])

// Filtered to OpenRouter there is nothing left: AWS cost must not show under that filter.
expect(labTotals(openRouterOnly).cost).toBeGreaterThan(0)
Expand All @@ -46,7 +46,11 @@ describe("cost lab fixture", () => {

test("a GitHub organization is added and removed on its own, and the other keeps its source and cost", () => {
const github = (changes: Readonly<Record<string, boolean>>) => {
const sources = labSourcesWith(LAB_SOURCES.synced, { openRouter: null, github: changes })
const sources = labSourcesWith(LAB_SOURCES.synced, {
openRouter: null,
github: changes,
vercel: null,
})
return {
sources: Arr.map(
Arr.filter(sources.sources, (source) => source.provider === "github"),
Expand All @@ -57,7 +61,7 @@ describe("cost lab fixture", () => {
(installation) => `${installation.accountLogin} ${installation.state}`,
),
costRemoved: Arr.contains(
labRemovedProviders({ openRouter: null, github: changes }),
labRemovedProviders({ openRouter: null, github: changes, vercel: null }),
"github",
),
}
Expand Down Expand Up @@ -89,4 +93,52 @@ describe("cost lab fixture", () => {
})
expect(github({ [org]: true })).toMatchObject({ sources: ["example-org pending"], costRemoved: true })
})
test("a removed Vercel source leaves every read too, and the other providers stay", () => {
expect(labTotals({ ...window, without: ["vercel"] }).cost).toBeLessThan(labTotals(window).cost)
expect(
Arr.map(
labBreakdown({ ...window, without: ["vercel"], groupBy: "provider", sortBy: "cost" }).rows,
(row) => row.key,
),
).toEqual(["aws", "openrouter", "github"])
expect(labTotals({ days: 30, filters: { provider: ["vercel"] }, without: ["vercel"] }).cost).toBe(0)
})

test("a Vercel source added again after its removal is a new one that waits for its first sync", () => {
const after = (vercel: boolean | null) => {
const changes = { openRouter: null, github: {}, vercel }
const sources = labSourcesWith(LAB_SOURCES.synced, changes)
return {
vercel: Arr.map(
Arr.filter(sources.sources, (source) => source.provider === "vercel"),
(source) => `${source.accountName} ${source.status}`,
),
others: Arr.filter(sources.sources, (source) => source.provider !== "vercel").length,
costRemoved: Arr.contains(labRemovedProviders(changes), "vercel"),
}
}
const others = LAB_SOURCES.synced.sources.length - 1
expect(after(null)).toEqual({ vercel: ["example-team ok"], others, costRemoved: false })
expect(after(false)).toEqual({ vercel: [], others, costRemoved: true })
// Not the synced row it started with, and no cost until a sync the lab does not model.
expect(after(true)).toEqual({ vercel: ["example-team pending"], others, costRemoved: true })
})

test("grouped by resource, cost splits by GitHub repository and Vercel project, and the rest has none", () => {
expect(
Arr.map(labBreakdown({ ...window, groupBy: "resource", sortBy: "cost" }).rows, (row) => row.key),
).toEqual(["", "example-web", "example-api", "example-docs", "example-site"])
// Narrowed to a provider, only its own resources are left.
expect(
Arr.map(
labBreakdown({
days: 30,
filters: { provider: ["github"] },
groupBy: "resource",
sortBy: "cost",
}).rows,
(row) => row.key,
),
).toEqual(["example-api", "", "example-site"])
})
})
108 changes: 97 additions & 11 deletions apps/web/src/lab/cost-fixture.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ const line = (
service_category,
region,
usage_type,
resource: "",
account: LAB_ACCOUNT,
daily,
drift,
Expand All @@ -53,6 +54,7 @@ const modelLine = (model: string, servedBy: string, daily: number, drift: number
service_category: "AI and Machine Learning",
region: "global",
usage_type: servedBy,
resource: "",
account: LAB_OPENROUTER_ACCOUNT,
daily,
drift,
Expand All @@ -65,17 +67,52 @@ const LAB_GITHUB_INSTALLATION = "0190f2b3-0000-4000-8000-00000000e061"
const LAB_GITHUB_OTHER_ORG = "example-labs"
const LAB_GITHUB_OTHER_INSTALLATION = "0190f2b3-0000-4000-8000-00000000e062"

const githubLine = (product: string, sku: string, daily: number, drift: number): Line => ({
/** `repository` is the row's resource; a seat product names none. */
const githubLine = (
product: string,
sku: string,
repository: string,
daily: number,
drift: number,
): Line => ({
cloud_service: product,
provider: "github",
service_category: "Developer Tools",
region: "global",
usage_type: sku,
resource: repository,
account: LAB_GITHUB_ORG,
daily,
drift,
})

/** The Vercel team the lab's source reads: its id is the account, its slug what the page shows. */
export const LAB_VERCEL_TEAM = {
id: "team_labExample000000000000",
name: "Example Team",
slug: "example-team",
}

const productLine = (
product: string,
category: string,
region: string,
sku: string,
project: string,
daily: number,
drift: number,
): Line => ({
cloud_service: product,
provider: "vercel",
service_category: category,
region,
usage_type: sku,
resource: project,
account: LAB_VERCEL_TEAM.id,
daily,
drift,
})

/** `daily` is the charge per day today; `drift` how much it grew or shrank over the last 30 days. */
const LINES: ReadonlyArray<Line> = [
line("Example Compute", "Compute", "north-1", "north-1:instance-hours", 61.2, 0.18),
Expand All @@ -99,10 +136,32 @@ const LINES: ReadonlyArray<Line> = [
modelLine("example/large-model", "Example Cloud", 6.2, 0.1),
modelLine("example/fast-model", "Example Inference", 12.9, -0.15),
modelLine("example/embedding-model", "Example Labs", 1.8, 0.0),
githubLine("Actions", "Actions Linux", 9.4, 0.25),
githubLine("Actions", "Actions storage", 0.7, 0.05),
githubLine("Copilot", "Copilot Business", 6.3, 0.0),
githubLine("Packages", "Packages storage", 0.4, -0.1),
githubLine("Actions", "Actions Linux", "example-api", 9.4, 0.25),
githubLine("Actions", "Actions storage", "example-site", 0.7, 0.05),
githubLine("Copilot", "Copilot Business", "", 6.3, 0.0),
githubLine("Packages", "Packages storage", "example-api", 0.4, -0.1),
productLine("Example Function Duration", "Compute", "iad1", "function-duration", "example-web", 9.4, 0.2),
productLine(
"Example Function Duration",
"Compute",
"fra1",
"function-duration",
"example-docs",
3.1,
0.05,
),
productLine("Example Data Transfer", "Networking", "global", "data-transfer", "example-web", 6.7, 0.12),
productLine(
"Example Image Optimization",
"Web",
"global",
"image-optimization",
"example-docs",
2.2,
-0.08,
),
// The plan's monthly fee, as Vercel spreads it over the days: it belongs to no project.
productLine("Example Pro Plan", "Other", "global", "", "", 0.67, 0.0),
]

/** The day `offset` days before the lab's today, as `YYYY-MM-DD`. */
Expand All @@ -118,7 +177,15 @@ const chargeAt = (entry: Line, index: number, offset: number): number => {

const matches = (entry: Line, filters: CostFilters | undefined): boolean =>
Arr.every(
["cloud_service", "provider", "service_category", "region", "usage_type", "account"] as const,
[
"cloud_service",
"provider",
"service_category",
"region",
"usage_type",
"resource",
"account",
] as const,
(dimension) => {
const values = filters?.[dimension] ?? []
return values.length === 0 || Arr.contains(values, entry[dimension])
Expand Down Expand Up @@ -284,6 +351,18 @@ const githubSource = source({
requestsThisMonth: 0,
})

/** The Vercel source: eleven months of history, all one request serves, and no billed requests. */
const vercelSource = source({
id: "0190f2b3-0000-7000-8000-00000000c063",
provider: "vercel",
connectionRef: "0190f2b3-0000-7000-8000-00000000a063",
accountId: LAB_VERCEL_TEAM.id,
accountName: LAB_VERCEL_TEAM.slug,
backfillDate: "2025-12-01",
lastSyncedAt: LAB_NOW_MS - 2 * 3_600_000,
requestsThisMonth: 0,
})

/** A source that was just added: it waits for its first sync. */
const justAdded = (entry: SourceWire): SourceWire => ({
...entry,
Expand Down Expand Up @@ -311,12 +390,13 @@ export const labGithubLogin = (installationId: string): string =>
)

/**
* What the lab's reader did since the state loaded. `openRouter`: added (true), removed (false) or
* neither (null). `github`: an installation's id to whether it is a source now.
* What the lab's reader did since the state loaded. `openRouter` and `vercel`: added (true),
* removed (false) or neither (null). `github`: an installation's id to whether it is a source now.
*/
export type LabSourceChanges = Readonly<{
openRouter: boolean | null
github: Readonly<Record<string, boolean>>
vercel: boolean | null
}>

/**
Expand All @@ -328,7 +408,9 @@ export const labRemovedProviders = (changes: LabSourceChanges): ReadonlyArray<Co
CostProvider.literals,
(provider) =>
(provider === "openrouter" && changes.openRouter === false) ||
(provider === "github" && Rec.has(changes.github, LAB_GITHUB_INSTALLATION)),
(provider === "github" && Rec.has(changes.github, LAB_GITHUB_INSTALLATION)) ||
// A team added again is a new source: it has no cost until a sync the lab does not model.
(provider === "vercel" && changes.vercel !== null),
)

/**
Expand All @@ -342,13 +424,16 @@ export const labSourcesWith = (
const kept = Arr.filter(Schema.encodeSync(CostSourcesResponse)(sources).sources, (entry) =>
entry.provider === "openrouter"
? changes.openRouter !== false
: entry.provider !== "github" || !Rec.has(changes.github, entry.connectionRef),
: entry.provider === "vercel"
? changes.vercel === null
: entry.provider !== "github" || !Rec.has(changes.github, entry.connectionRef),
)
const hasOpenRouter = Arr.some(kept, (entry) => entry.provider === "openrouter")
return decodeSources({
sources: [
...kept,
...(changes.openRouter === true && !hasOpenRouter ? [justAdded(openRouterSource)] : []),
...(changes.vercel === true ? [justAdded(vercelSource)] : []),
...Arr.map(
Arr.filter(LAB_GITHUB_ORGS, (org) => changes.github[org.installationId] === true),
(org) =>
Expand Down Expand Up @@ -399,7 +484,7 @@ export const labGithubInstallations = (sources: CostSourcesResponse): GithubCost
}

export const LAB_SOURCES = {
synced: decodeSources({ sources: [source({}), openRouterSource, githubSource] }),
synced: decodeSources({ sources: [source({}), openRouterSource, githubSource, vercelSource] }),
none: decodeSources({ sources: [] }),
pending: decodeSources({
sources: [
Expand Down Expand Up @@ -437,6 +522,7 @@ export const LAB_SOURCES = {
requestsThisMonth: 0,
}),
openRouterSource,
vercelSource,
],
}),
} as const
Loading
Loading