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
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.added", {
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.removed", { 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 @@ -14,6 +14,7 @@ import { HttpAiTriageLive } from "@/routes/internal/ai-triage.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 @@ -168,6 +169,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 @@ -67,6 +67,7 @@ import { SupabaseInsightsService } from "@maple/backend/services/integrations/Su
import { SupabaseLogsService } from "@maple/backend/services/integrations/SupabaseLogsService"
import { SupabaseOAuthService } from "@maple/backend/services/auth/SupabaseOAuthService"
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 @@ -188,6 +189,7 @@ export const HttpServicesLive = Layer.mergeAll(
AwsIngestService.layer,
OrgOpenRouterKeyService.layer,
OpenRouterCostConnectionService.layer,
VercelCostSourceService.layer,
ServiceMapWorldService.layer,
AuditLogService.layer,
WarehouseQueryService.layer,
Expand Down
47 changes: 40 additions & 7 deletions apps/web/src/lab/cost-fixture.test.ts
Original file line number Diff line number Diff line change
@@ -1,38 +1,71 @@
import { Array as Arr } from "effect"
import { describe, expect, test } from "vitest"
import { labBreakdown, labTimeseries, labTotals } from "./cost-fixture"
import { LAB_SOURCES, labBreakdown, labSourcesWith, labTimeseries, labTotals } from "./cost-fixture"

const window = { days: 30, filters: undefined }
const openRouterOnly = { days: 30, filters: { provider: ["openrouter"] } }

describe("cost lab fixture", () => {
test("a removed OpenRouter source leaves every read, also under a filter naming it", () => {
const before = labTotals(window).cost
const after = labTotals({ ...window, withoutOpenRouter: true }).cost
const after = labTotals({ ...window, without: ["openrouter"] }).cost
expect(after).toBeGreaterThan(0)
expect(after).toBeLessThan(before)
expect(
Arr.map(
labBreakdown({ ...window, withoutOpenRouter: true, groupBy: "provider", sortBy: "cost" })
labBreakdown({ ...window, without: ["openrouter"], groupBy: "provider", sortBy: "cost" })
.rows,
(row) => row.key,
),
).toEqual(["aws"])
).toEqual(["aws", "vercel"])

// Filtered to OpenRouter there is nothing left: AWS cost must not show under that filter.
expect(labTotals(openRouterOnly).cost).toBeGreaterThan(0)
expect(labTotals({ ...openRouterOnly, withoutOpenRouter: true }).cost).toBe(0)
expect(labTotals({ ...openRouterOnly, without: ["openrouter"] }).cost).toBe(0)
expect(
labBreakdown({
...openRouterOnly,
withoutOpenRouter: true,
without: ["openrouter"],
groupBy: "cloud_service",
sortBy: "cost",
}).rows,
).toEqual([])
expect(
labTimeseries({ ...openRouterOnly, withoutOpenRouter: true, groupBy: "provider", limit: 8 })
labTimeseries({ ...openRouterOnly, without: ["openrouter"], groupBy: "provider", limit: 8 })
.series,
).toEqual([])
})

test("a removed Vercel source leaves every read too, and the other providers stay", () => {
const removed = { ...window, without: ["vercel"] }
expect(labTotals(removed).cost).toBeLessThan(labTotals(window).cost)
expect(
Arr.map(labBreakdown({ ...removed, groupBy: "provider", sortBy: "cost" }).rows, (row) => row.key),
).toEqual(["aws", "openrouter"])
expect(labTotals({ days: 30, filters: { provider: ["vercel"] }, without: ["vercel"] }).cost).toBe(0)
})

test("a source added again after its removal is a new one that waits for its first sync", () => {
const statusOf = (response: typeof LAB_SOURCES.synced, provider: string) =>
Arr.map(
Arr.filter(response.sources, (source) => source.provider === provider),
(source) => [source.status, source.watermarkDate],
)
// As the state has it, then removed, then added: not the synced row it started with.
expect(statusOf(labSourcesWith(LAB_SOURCES.synced, "vercel", null), "vercel")).toEqual([
["ok", "2026-11-09"],
])
expect(statusOf(labSourcesWith(LAB_SOURCES.synced, "vercel", false), "vercel")).toEqual([])
const readded = labSourcesWith(LAB_SOURCES.synced, "vercel", true)
expect(statusOf(readded, "vercel")).toEqual([["pending", null]])
// The other sources are untouched.
expect(statusOf(readded, "aws")).toEqual([["ok", "2026-11-09"]])
expect(readded.sources).toHaveLength(LAB_SOURCES.synced.sources.length)
})

test("grouped by resource, a Vercel team's cost splits by project and the rest has none", () => {
expect(
Arr.map(labBreakdown({ ...window, groupBy: "resource", sortBy: "cost" }).rows, (row) => row.key),
).toEqual(["", "example-web", "example-docs"])
})
})
127 changes: 100 additions & 27 deletions apps/web/src/lab/cost-fixture.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ const line = (
service_category,
region,
usage_type,
resource: "",
account: LAB_ACCOUNT,
daily,
drift,
Expand All @@ -51,11 +52,39 @@ 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,
})

/** The Vercel team the lab's source reads, by its slug. */
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 @@ -79,6 +108,28 @@ 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),
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 @@ -94,7 +145,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 All @@ -104,15 +163,14 @@ const matches = (entry: Line, filters: CostFilters | undefined): boolean =>
type Request = Readonly<{
days: number
filters: CostFilters | undefined
/** The lab's reader removed OpenRouter: its lines are in no read, whatever the filters say. */
withoutOpenRouter?: boolean
/** Providers whose source the lab's reader removed: their lines are in no read, whatever the filters say. */
without?: ReadonlyArray<string>
}>

/** Each matching line with its charge on every day of the window (oldest first) and of the period before. */
const windowOf = (request: Request) =>
Arr.flatMap(LINES, (entry, index) =>
matches(entry, request.filters) &&
!(request.withoutOpenRouter === true && entry.provider === "openrouter")
matches(entry, request.filters) && !Arr.contains(request.without ?? [], entry.provider)
? [
{
entry,
Expand Down Expand Up @@ -248,36 +306,50 @@ const openRouterSource = 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-00000000c061",
provider: "vercel",
accountId: LAB_VERCEL_TEAM.id,
accountName: LAB_VERCEL_TEAM.slug,
backfillDate: "2025-12-01",
lastSyncedAt: LAB_NOW_MS - 2 * 3_600_000,
requestsThisMonth: 0,
})

/** The sources the lab's reader can add and remove on the page, as they are once synced. */
const ADDABLE = { openrouter: openRouterSource, vercel: vercelSource } as const
type LabAddable = keyof typeof ADDABLE

/**
* `sources` after the lab's reader added OpenRouter (true: it waits for its first sync) or removed
* it (false); null leaves them as the state has them.
* `sources` after the lab's reader added a provider's source (true: a new one, which waits for
* its first sync whatever the state had) or removed it (false); null leaves it as the state has it.
*/
export const labSourcesWith = (
sources: CostSourcesResponse,
openRouter: boolean | null,
provider: LabAddable,
added: boolean | null,
): CostSourcesResponse => {
if (openRouter === null) return sources
const others = Arr.filter(sources.sources, (entry) => entry.provider !== "openrouter")
if (!openRouter) return new CostSourcesResponse({ sources: others })
return others.length === sources.sources.length
? decodeSources({
sources: [
...Schema.encodeSync(CostSourcesResponse)(sources).sources,
{
...openRouterSource,
status: "pending",
watermarkDate: null,
backfillDate: null,
historyComplete: false,
lastSyncedAt: null,
},
],
})
: sources
if (added === null) return sources
const others = Arr.filter(sources.sources, (entry) => entry.provider !== provider)
if (!added) return new CostSourcesResponse({ sources: others })
return decodeSources({
sources: [
...Schema.encodeSync(CostSourcesResponse)(new CostSourcesResponse({ sources: others })).sources,
{
...ADDABLE[provider],
status: "pending",
watermarkDate: null,
backfillDate: null,
historyComplete: false,
lastSyncedAt: null,
},
],
})
}

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