Skip to content
23 changes: 23 additions & 0 deletions apps/api/src/routes/internal/anthropic-cost.http.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { HttpApiBuilder } from "effect/http-api"
import { MapleInternalApi } from "@maple/domain/http"
import { Effect } from "effect"
import { AnthropicCostConnectionService } from "@maple/backend/services/cost/AnthropicCostConnectionService"
import { removeCostConnection, saveCostConnection } from "./cost-connection-handlers"

export const HttpAnthropicCostLive = HttpApiBuilder.group(MapleInternalApi, "anthropicCost", (handlers) =>
Effect.gen(function* () {
const connections = yield* AnthropicCostConnectionService

return handlers
.handle("set", ({ payload }) =>
saveCostConnection("anthropic", (orgId, userId) =>
connections.set(orgId, userId, payload.apiKey),
).pipe(Effect.withSpan("HttpAnthropicCost.set")),
)
.handle("remove", () =>
removeCostConnection("anthropic", connections.remove).pipe(
Effect.withSpan("HttpAnthropicCost.remove"),
),
)
}),
)
43 changes: 8 additions & 35 deletions apps/api/src/routes/internal/cloudflare-cost.http.ts
Original file line number Diff line number Diff line change
@@ -1,50 +1,23 @@
import { HttpApiBuilder } from "effect/http-api"
import {
CostConnectionForbiddenError,
CostConnectionRemoved,
CostConnectionSaved,
CurrentTenant,
MapleInternalApi,
} from "@maple/domain/http"
import { 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 }),
)
import { removeCostConnection, saveCostConnection } from "./cost-connection-handlers"

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")),
saveCostConnection("cloudflare", (orgId, userId) =>
connections.set(orgId, userId, payload.apiToken),
).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")),
removeCostConnection("cloudflare", connections.remove).pipe(
Effect.withSpan("HttpCloudflareCost.remove"),
),
)
}),
)
52 changes: 52 additions & 0 deletions apps/api/src/routes/internal/cost-connection-handlers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
// The handler bodies every pasted-credential cost source shares (`cost_connections`): the role
// check, the provider's own save or remove, and its audit record.
import {
CostConnectionForbiddenError,
CostConnectionRemoved,
CostConnectionSaved,
CurrentTenant,
} from "@maple/domain/http"
import type { CostProvider } from "@maple/domain/cost"
import type { OrgId, UserId } from "@maple/primitives"
import { Effect } from "effect"
import * as Policy from "@maple/domain/policy"
import { recordHttpAudit } from "@maple/backend/services/audit/AuditLogService"

// 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 }),
)

/** Saves the org's credential for `provider` with `save`, which checks it with the vendor first. */
export const saveCostConnection = <E, R>(
provider: CostProvider,
save: (
orgId: OrgId,
userId: UserId,
) => Effect.Effect<{ readonly accountId: string; readonly accountLabel: string }, E, R>,
) =>
Effect.gen(function* () {
const tenant = yield* CurrentTenant.Context
yield* requireIntegrationsManager(tenant.roles)
const saved = yield* save(tenant.orgId, tenant.userId)
yield* recordHttpAudit("cost_source_key.updated", {
metadata: { provider, accountId: saved.accountId },
})
return new CostConnectionSaved(saved)
})

/** Removes the org's credential for `provider`, and with it the cost source. */
export const removeCostConnection = <E, R>(
provider: CostProvider,
remove: (orgId: OrgId) => Effect.Effect<void, E, R>,
) =>
Effect.gen(function* () {
const tenant = yield* CurrentTenant.Context
yield* requireIntegrationsManager(tenant.roles)
yield* remove(tenant.orgId)
yield* recordHttpAudit("cost_source_key.deleted", { metadata: { provider } })
return new CostConnectionRemoved({ removed: true })
})
23 changes: 23 additions & 0 deletions apps/api/src/routes/internal/openai-cost.http.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { HttpApiBuilder } from "effect/http-api"
import { MapleInternalApi } from "@maple/domain/http"
import { Effect } from "effect"
import { OpenAiCostConnectionService } from "@maple/backend/services/cost/OpenAiCostConnectionService"
import { removeCostConnection, saveCostConnection } from "./cost-connection-handlers"

export const HttpOpenAiCostLive = HttpApiBuilder.group(MapleInternalApi, "openaiCost", (handlers) =>
Effect.gen(function* () {
const connections = yield* OpenAiCostConnectionService

return handlers
.handle("set", ({ payload }) =>
saveCostConnection("openai", (orgId, userId) =>
connections.set(orgId, userId, payload.apiKey),
).pipe(Effect.withSpan("HttpOpenAiCost.set")),
)
.handle("remove", () =>
removeCostConnection("openai", connections.remove).pipe(
Effect.withSpan("HttpOpenAiCost.remove"),
),
)
}),
)
4 changes: 4 additions & 0 deletions apps/api/src/runtime/http-graph.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,9 @@ 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 { HttpAnthropicCostLive } from "@/routes/internal/anthropic-cost.http"
import { HttpCloudflareCostLive } from "@/routes/internal/cloudflare-cost.http"
import { HttpOpenAiCostLive } from "@/routes/internal/openai-cost.http"
import { HttpGithubCostLive } from "@/routes/internal/github-cost.http"
import { HttpOpenRouterCostLive } from "@/routes/internal/openrouter-cost.http"
import { HttpOpenRouterKeyLive } from "@/routes/internal/openrouter-key.http"
Expand Down Expand Up @@ -175,6 +177,8 @@ const ApiInternalRoutes = HttpApiBuilder.layer(MapleInternalApi).pipe(
HttpDependencyScanLive,
HttpDigestLive,
HttpCloudflareCostLive,
HttpAnthropicCostLive,
HttpOpenAiCostLive,
HttpGithubCostLive,
HttpPlanetScaleCostLive,
HttpOpenRouterCostLive,
Expand Down
4 changes: 4 additions & 0 deletions apps/api/src/runtime/service-graph.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,9 @@ 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 { AnthropicCostConnectionService } from "@maple/backend/services/cost/AnthropicCostConnectionService"
import { CloudflareCostConnectionService } from "@maple/backend/services/cost/CloudflareCostConnectionService"
import { OpenAiCostConnectionService } from "@maple/backend/services/cost/OpenAiCostConnectionService"
import { GithubCostSourceService } from "@maple/backend/services/cost/GithubCostSourceService"
import { OpenRouterCostConnectionService } from "@maple/backend/services/cost/OpenRouterCostConnectionService"
import { PlanetScaleCostSourceService } from "@maple/backend/services/cost/PlanetScaleCostSourceService"
Expand Down Expand Up @@ -195,6 +197,8 @@ export const HttpServicesLive = Layer.mergeAll(
OrgOpenRouterKeyService.layer,
OpenRouterCostConnectionService.layer,
CloudflareCostConnectionService.layer,
AnthropicCostConnectionService.layer,
OpenAiCostConnectionService.layer,
GithubCostSourceService.layer,
PlanetScaleCostSourceService.layer,
VercelCostSourceService.layer,
Expand Down
74 changes: 43 additions & 31 deletions apps/web/src/lab/cost-fixture.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
import { Array as Arr } from "effect"
import { CredentialProvider } from "../page/cost/providers"
import { describe, expect, test } from "vitest"
import {
LAB_SOURCES,
type LabCredentialChange,
credentialChangeAfter,
labBreakdown,
labGithubInstallations,
labRemovedProviders,
Expand All @@ -25,7 +28,7 @@ describe("cost lab fixture", () => {
.rows,
(row) => row.key,
),
).toEqual(["aws", "vercel", "cloudflare", "github", "planetscale"])
).toEqual(["aws", "anthropic", "vercel", "openai", "cloudflare", "github", "planetscale"])

// Filtered to OpenRouter there is nothing left: AWS cost must not show under that filter.
expect(labTotals(openRouterOnly).cost).toBeGreaterThan(0)
Expand All @@ -44,36 +47,42 @@ describe("cost lab fixture", () => {
).toEqual([])
})

test("Cloudflare is added waiting for its first sync, and a removed one takes its cost with it", () => {
const cloudflare = (added: boolean | null, from = LAB_SOURCES.synced) => {
const changes = {
openRouter: null,
cloudflare: added,
github: {},
vercel: null,
planetScale: null,
}
return {
sources: Arr.map(
Arr.filter(
labSourcesWith(from, changes).sources,
(source) => source.provider === "cloudflare",
test.each(CredentialProvider.literals)(
"%s is added waiting for its first sync, and a removed one takes its cost with it",
(provider) => {
const outcome = (change: LabCredentialChange | null, from = LAB_SOURCES.synced) => {
const changes = {
credentials: change === null ? {} : { [provider]: change },
github: {},
vercel: null,
planetScale: null,
}
return {
sources: Arr.map(
Arr.filter(
labSourcesWith(from, changes).sources,
(source) => source.provider === provider,
),
(source) => source.status,
),
(source) => source.status,
),
costRemoved: Arr.contains(labRemovedProviders(changes), "cloudflare"),
costRemoved: Arr.contains(labRemovedProviders(changes), provider),
}
}
}
expect(cloudflare(null)).toEqual({ sources: ["ok"], costRemoved: false })
expect(cloudflare(false)).toEqual({ sources: [], costRemoved: true })
expect(cloudflare(true, LAB_SOURCES.none)).toEqual({ sources: ["pending"], costRemoved: false })
})
expect(outcome(null)).toEqual({ sources: ["ok"], costRemoved: false })
expect(outcome("removed")).toEqual({ sources: [], costRemoved: true })
expect(outcome("added", LAB_SOURCES.none)).toEqual({ sources: ["pending"], costRemoved: false })
// A replaced credential keeps the source and its cost.
expect(outcome("added")).toEqual({ sources: ["ok"], costRemoved: false })
// Added again after a removal: a new source waiting for its sync, without the old cost.
expect(credentialChangeAfter(credentialChangeAfter(undefined, false), true)).toBe("readded")
expect(outcome("readded")).toEqual({ sources: ["pending"], costRemoved: true })
},
)

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,
cloudflare: null,
credentials: {},
github: changes,
vercel: null,
planetScale: null,
Expand All @@ -89,8 +98,7 @@ describe("cost lab fixture", () => {
),
costRemoved: Arr.contains(
labRemovedProviders({
openRouter: null,
cloudflare: null,
credentials: {},
github: changes,
vercel: null,
planetScale: null,
Expand Down Expand Up @@ -133,13 +141,13 @@ describe("cost lab fixture", () => {
labBreakdown({ ...window, without: ["vercel"], groupBy: "provider", sortBy: "cost" }).rows,
(row) => row.key,
),
).toEqual(["aws", "openrouter", "cloudflare", "github", "planetscale"])
).toEqual(["aws", "openrouter", "anthropic", "openai", "cloudflare", "github", "planetscale"])
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, cloudflare: null, github: {}, vercel, planetScale: null }
const changes = { credentials: {}, github: {}, vercel, planetScale: null }
const sources = labSourcesWith(LAB_SOURCES.synced, changes)
return {
vercel: Arr.map(
Expand All @@ -159,7 +167,7 @@ describe("cost lab fixture", () => {

test("a PlanetScale source added again after its removal is a new one that waits for its first sync", () => {
const after = (planetScale: boolean | null) => {
const changes = { openRouter: null, cloudflare: null, github: {}, vercel: null, planetScale }
const changes = { credentials: {}, github: {}, vercel: null, planetScale }
const sources = labSourcesWith(LAB_SOURCES.synced, changes)
return {
planetScale: Arr.map(
Expand All @@ -181,17 +189,21 @@ describe("cost lab fixture", () => {
).toBe(0)
})

test("grouped by resource, cost splits by GitHub repository, Vercel project and PlanetScale branch, and the rest has none", () => {
test("grouped by resource, cost splits by GitHub repository, Vercel project, PlanetScale branch, Anthropic workspace and OpenAI project, and the rest has none", () => {
expect(
Arr.map(labBreakdown({ ...window, groupBy: "resource", sortBy: "cost" }).rows, (row) => row.key),
).toEqual([
"",
"wrkspc_example_agents",
"proj_ExampleSupport",
"example-web",
"example-orders/main",
"example-api",
"example-docs",
"default",
"example-search/main",
"example-orders/staging",
"proj_ExampleSearch",
"example-site",
])
// Narrowed to a provider, only its own resources are left.
Expand Down
Loading
Loading