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
64 changes: 64 additions & 0 deletions apps/api/src/routes/internal/github-cost.http.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import { HttpApiBuilder } from "effect/http-api"
import {
CurrentTenant,
GithubCostForbiddenError,
GithubCostInstallations,
GithubCostSourceAdded,
GithubCostSourceRemoved,
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 { GithubCostSourceService } from "@maple/backend/services/cost/GithubCostSourceService"

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

export const HttpGithubCostLive = HttpApiBuilder.group(MapleInternalApi, "githubCost", (handlers) =>
Effect.gen(function* () {
const sources = yield* GithubCostSourceService

return handlers
.handle("list", () =>
Effect.gen(function* () {
const tenant = yield* CurrentTenant.Context
const installations = yield* sources.list(tenant.orgId)
return new GithubCostInstallations({ installations })
}).pipe(Effect.withSpan("HttpGithubCost.list")),
)
.handle("add", ({ params }) =>
Effect.gen(function* () {
const tenant = yield* CurrentTenant.Context
yield* requireIntegrationsManager(tenant.roles)
const { accountLogin, created } = yield* sources.add(tenant.orgId, params.installationId)
// It was a source already: nothing happened to record.
if (created) {
yield* recordHttpAudit("cost_source.created", {
metadata: { provider: "github", account: accountLogin },
})
}
return new GithubCostSourceAdded({ accountLogin })
}).pipe(Effect.withSpan("HttpGithubCost.add")),
)
.handle("remove", ({ params }) =>
Effect.gen(function* () {
const tenant = yield* CurrentTenant.Context
yield* requireIntegrationsManager(tenant.roles)
const { accountLogin } = yield* sources.remove(tenant.orgId, params.installationId)
// Nothing was there to remove: nothing happened to record.
if (accountLogin !== null) {
yield* recordHttpAudit("cost_source.deleted", {
metadata: { provider: "github", account: accountLogin },
})
}
return new GithubCostSourceRemoved({ removed: accountLogin !== null })
}).pipe(Effect.withSpan("HttpGithubCost.remove")),
)
}),
)
2 changes: 2 additions & 0 deletions apps/api/src/routes/webhooks/vcs.http.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,8 @@ const makeHandler = () => {
fetchWorkflowJobAnnotations: unused,
streamWorkflowJobLog: unused,
canReadWorkflowLogs: unused,
fetchBillingUsage: unused,
installationSettingsUrl: () => "",
}
const web = HttpRouter.toWebHandler(
VcsWebhookRouter.pipe(
Expand Down
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 { 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"
Expand Down Expand Up @@ -165,6 +166,7 @@ const ApiInternalRoutes = HttpApiBuilder.layer(MapleInternalApi).pipe(
HttpDemoLive,
HttpDependencyScanLive,
HttpDigestLive,
HttpGithubCostLive,
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 { GithubCostSourceService } from "@maple/backend/services/cost/GithubCostSourceService"
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,
GithubCostSourceService.layer,
ServiceMapWorldService.layer,
AuditLogService.layer,
WarehouseQueryService.layer,
Expand Down
68 changes: 61 additions & 7 deletions apps/web/src/lab/cost-fixture.test.ts
Original file line number Diff line number Diff line change
@@ -1,38 +1,92 @@
import { Array as Arr } from "effect"
import { describe, expect, test } from "vitest"
import { labBreakdown, labTimeseries, labTotals } from "./cost-fixture"
import {
LAB_SOURCES,
labBreakdown,
labGithubInstallations,
labRemovedProviders,
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", "github"])

// 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 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 })
return {
sources: Arr.map(
Arr.filter(sources.sources, (source) => source.provider === "github"),
(source) => `${source.accountId} ${source.status}`,
),
listed: Arr.map(
labGithubInstallations(sources).installations,
(installation) => `${installation.accountLogin} ${installation.state}`,
),
costRemoved: Arr.contains(
labRemovedProviders({ openRouter: null, github: changes }),
"github",
),
}
}
const ids = Arr.map(
labGithubInstallations(LAB_SOURCES.synced).installations,
(installation) => installation.installationId,
)
const org = ids[0] ?? ""
const labs = ids[1] ?? ""
expect([org, labs]).not.toContain("")

// The second organization added: it waits for its first sync beside the first.
expect(github({ [labs]: true })).toMatchObject({
sources: ["example-org ok", "example-labs pending"],
costRemoved: false,
})
// Removed again: the first organization's source and cost are untouched.
expect(github({ [labs]: false })).toMatchObject({
sources: ["example-org ok"],
listed: expect.arrayContaining(["example-org added", "example-labs available"]),
costRemoved: false,
})
// The first organization removed takes its cost with it, and added back it waits for a sync.
expect(github({ [org]: false })).toMatchObject({
sources: [],
listed: expect.arrayContaining(["example-org available"]),
costRemoved: true,
})
expect(github({ [org]: true })).toMatchObject({ sources: ["example-org pending"], costRemoved: true })
})
})
Loading
Loading