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

// 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 CostConnectionForbiddenError({ message: denial.message }),
)

export const HttpCloudflareCostLive = HttpApiBuilder.group(MapleInternalApi, "cloudflareCost", (handlers) =>
Effect.gen(function* () {
const connections = yield* CloudflareCostConnectionService

return handlers
.handle("set", ({ payload }) =>
Effect.gen(function* () {
const tenant = yield* CurrentTenant.Context
yield* requireIntegrationsManager(tenant.roles)
const saved = yield* connections.set(tenant.orgId, tenant.userId, payload.apiToken)
yield* recordHttpAudit("cost_source_key.updated", {
metadata: { provider: "cloudflare", accountId: saved.accountId },
})
return new CostConnectionSaved(saved)
}).pipe(Effect.withSpan("HttpCloudflareCost.set")),
)
.handle("remove", () =>
Effect.gen(function* () {
const tenant = yield* CurrentTenant.Context
yield* requireIntegrationsManager(tenant.roles)
yield* connections.remove(tenant.orgId)
yield* recordHttpAudit("cost_source_key.deleted", {
metadata: { provider: "cloudflare" },
})
return new CostConnectionRemoved({ removed: true })
}).pipe(Effect.withSpan("HttpCloudflareCost.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 @@ -11,6 +11,7 @@ import { HttpAiSessionsInternalLive } from "@/routes/internal/ai-sessions.http"
import { HttpMcpInternalLive } from "@/routes/internal/mcp.http"
import { HttpUsersInternalLive } from "@/routes/internal/users.http"
import { HttpAiTriageLive } from "@/routes/internal/ai-triage.http"
import { HttpCloudflareCostLive } from "@/routes/internal/cloudflare-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"
Expand Down Expand Up @@ -165,6 +166,7 @@ const ApiInternalRoutes = HttpApiBuilder.layer(MapleInternalApi).pipe(
HttpDemoLive,
HttpDependencyScanLive,
HttpDigestLive,
HttpCloudflareCostLive,
HttpOpenRouterCostLive,
HttpOpenRouterKeyLive,
HttpServiceMapWorldLive,
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 @@ -66,6 +66,7 @@ import { SupabaseConnectionService } from "@maple/backend/services/integrations/
import { SupabaseInsightsService } from "@maple/backend/services/integrations/SupabaseInsightsService"
import { SupabaseLogsService } from "@maple/backend/services/integrations/SupabaseLogsService"
import { SupabaseOAuthService } from "@maple/backend/services/auth/SupabaseOAuthService"
import { CloudflareCostConnectionService } from "@maple/backend/services/cost/CloudflareCostConnectionService"
import { OpenRouterCostConnectionService } from "@maple/backend/services/cost/OpenRouterCostConnectionService"
import { OrgOpenRouterKeyService } from "@maple/backend/services/org/OrgOpenRouterKeyService"
import { ServiceMapWorldService } from "@maple/backend/services/service-map/ServiceMapWorldService"
Expand Down Expand Up @@ -188,6 +189,7 @@ export const HttpServicesLive = Layer.mergeAll(
AwsIngestService.layer,
OrgOpenRouterKeyService.layer,
OpenRouterCostConnectionService.layer,
CloudflareCostConnectionService.layer,
ServiceMapWorldService.layer,
AuditLogService.layer,
WarehouseQueryService.layer,
Expand Down
12 changes: 6 additions & 6 deletions apps/web/src/lab/cost-fixture.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,30 +8,30 @@ 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, removed: ["openrouter"] }).cost
expect(after).toBeGreaterThan(0)
expect(after).toBeLessThan(before)
expect(
Arr.map(
labBreakdown({ ...window, withoutOpenRouter: true, groupBy: "provider", sortBy: "cost" })
labBreakdown({ ...window, removed: ["openrouter"], groupBy: "provider", sortBy: "cost" })
.rows,
(row) => row.key,
),
).toEqual(["aws"])
).toEqual(["aws", "cloudflare"])

// 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, removed: ["openrouter"] }).cost).toBe(0)
expect(
labBreakdown({
...openRouterOnly,
withoutOpenRouter: true,
removed: ["openrouter"],
groupBy: "cloud_service",
sortBy: "cost",
}).rows,
).toEqual([])
expect(
labTimeseries({ ...openRouterOnly, withoutOpenRouter: true, groupBy: "provider", limit: 8 })
labTimeseries({ ...openRouterOnly, removed: ["openrouter"], groupBy: "provider", limit: 8 })
.series,
).toEqual([])
})
Expand Down
98 changes: 68 additions & 30 deletions apps/web/src/lab/cost-fixture.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
} from "@maple/domain/http"
import { Array as Arr, Number as Num, Order, Record as Rec, Schema, String as Str } from "effect"
import type { CostGroupBy } from "../page/cost/derive"
import type { CredentialProvider } from "../page/cost/providers"

// Synthetic cost for `/lab/cost`: invented services, regions and amounts on one made-up account.
// Nothing here is read by the product.
Expand Down Expand Up @@ -40,8 +41,6 @@ const line = (
drift,
})

/** A saved OpenRouter key, as OpenRouter masks it. */
export const LAB_OPENROUTER_KEY = "sk-or-v1-4f2...c81"
/** The OpenRouter account the lab's source reads: a personal account's user id. */
const LAB_OPENROUTER_ACCOUNT = "user_2labExampleAccount0000000000"

Expand All @@ -56,6 +55,26 @@ const modelLine = (model: string, servedBy: string, daily: number, drift: number
drift,
})

/** The Cloudflare account the lab's source reads. */
const LAB_CLOUDFLARE_ACCOUNT = "1a2b3c4d5e6f1a2b3c4d5e6f1a2b3c4d"

const productLine = (
product: string,
category: string,
metric: string,
daily: number,
drift: number,
): Line => ({
cloud_service: product,
provider: "cloudflare",
service_category: category,
region: "global",
usage_type: metric,
account: LAB_CLOUDFLARE_ACCOUNT,
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 +98,10 @@ 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("Workers", "Compute", "Workers Requests", 9.4, 0.25),
productLine("Durable Objects", "Compute", "Durable Objects Compute Duration", 5.1, 0.12),
productLine("R2", "Storage", "R2 Storage", 3.6, 0.05),
productLine("D1", "Databases", "D1 Rows Read", 1.2, -0.08),
]

/** The day `offset` days before the lab's today, as `YYYY-MM-DD`. */
Expand All @@ -104,15 +127,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 the lab's reader removed: their lines are in no read, whatever the filters say. */
removed?: 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.removed ?? [], entry.provider)
? [
{
entry,
Expand Down Expand Up @@ -248,36 +270,52 @@ const openRouterSource = source({
requestsThisMonth: 0,
})

/** The Cloudflare source: history back to the eleventh month before this one, and no billed requests. */
const cloudflareSource = source({
id: "0190f2b3-0000-7000-8000-00000000c061",
provider: "cloudflare",
accountId: LAB_CLOUDFLARE_ACCOUNT,
backfillDate: "2025-12-01",
lastSyncedAt: LAB_NOW_MS - 2 * 3_600_000,
requestsThisMonth: 0,
})

const LAB_CREDENTIAL_SOURCES = { openrouter: openRouterSource, cloudflare: cloudflareSource } as const

/** What the lab's reader did to each pasted-credential source since the state loaded; null for nothing. */
export type LabSourceChanges = Readonly<Record<CredentialProvider, boolean | null>>

/**
* `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 (true: it waits for its first sync) or removed
* it (false); null leaves it as the state has it.
*/
export const labSourcesWith = (
sources: CostSourcesResponse,
openRouter: 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
}
changes: LabSourceChanges,
): CostSourcesResponse =>
Arr.reduce(Rec.toEntries(changes), sources, (current, [provider, added]) => {
if (added === null) return current
const others = Arr.filter(current.sources, (entry) => entry.provider !== provider)
if (!added) return new CostSourcesResponse({ sources: others })
return others.length === current.sources.length
? decodeSources({
sources: [
...Schema.encodeSync(CostSourcesResponse)(current).sources,
{
...LAB_CREDENTIAL_SOURCES[provider],
status: "pending",
watermarkDate: null,
backfillDate: null,
historyComplete: false,
lastSyncedAt: null,
},
],
})
: current
})

export const LAB_SOURCES = {
synced: decodeSources({ sources: [source({}), openRouterSource] }),
synced: decodeSources({ sources: [source({}), openRouterSource, cloudflareSource] }),
none: decodeSources({ sources: [] }),
pending: decodeSources({
sources: [
Expand Down
Loading
Loading