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
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