From 0da3b0098f3a382fd447845b85190141c7e812b9 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Fri, 13 Nov 2026 21:35:41 +0100 Subject: [PATCH 1/6] feat(cost): Anthropic and OpenAI as cost sources, read with a pasted Admin API key An org pastes an Anthropic or OpenAI Admin API key on the Cost page. Maple checks it with the vendor, keeps it in cost_connections, and the daily sync pulls the organization's API spend into cost_daily. Anthropic: /v1/organizations/me names the organization, the cost report is read by day, workspace and cost line. A model's lines are anthropic/. OpenAI: the costs endpoint is read by day, project and line item. No endpoint names an admin key's organization, so it comes off the key's own cost results, and a key whose organization spent nothing in 180 days is refused. Neither vendor has a read-only admin key; the dialog says what the key can do. The cost lab's credential changes are a record by provider, so a new pasted-credential provider needs no field of its own. --- .../routes/internal/anthropic-cost.http.ts | 50 ++++ .../src/routes/internal/openai-cost.http.ts | 50 ++++ apps/api/src/runtime/http-graph.ts | 4 + apps/api/src/runtime/service-graph.ts | 4 + apps/web/src/lab/cost-fixture.test.ts | 67 +++-- apps/web/src/lab/cost-fixture.ts | 141 +++++++-- apps/web/src/lab/cost-lab.ts | 73 +++-- apps/web/src/page/cost/command.ts | 44 +++ apps/web/src/page/cost/cost.scene.test.ts | 61 +++- apps/web/src/page/cost/credential-sources.ts | 86 +++++- apps/web/src/page/cost/marks.test.ts | 23 ++ apps/web/src/page/cost/marks.ts | 31 +- apps/web/src/page/cost/providers.ts | 16 +- apps/web/src/page/cost/update.ts | 2 + apps/web/src/page/cost/view-add-source.ts | 2 + apps/web/src/page/cost/view-sources.ts | 4 +- apps/web/src/page/cost/view.ts | 10 +- docs/cloud-cost-plan.md | 44 ++- .../AnthropicCostConnectionService.test.ts | 270 ++++++++++++++++++ .../cost/AnthropicCostConnectionService.ts | 143 ++++++++++ .../src/services/cost/CostSyncService.test.ts | 4 + .../src/services/cost/CostSyncService.ts | 6 + .../cost/OpenAiCostConnectionService.test.ts | 265 +++++++++++++++++ .../cost/OpenAiCostConnectionService.ts | 172 +++++++++++ .../services/cost/cost-connection-sources.ts | 6 +- .../anthropic/AnthropicCostProvider.test.ts | 187 ++++++++++++ .../anthropic/AnthropicCostProvider.ts | 130 +++++++++ .../providers/anthropic/anthropic-cost-api.ts | 162 +++++++++++ .../providers/anthropic/fake-anthropic.ts | 130 +++++++++ .../providers/anthropic/normalize.test.ts | 132 +++++++++ .../cost/providers/anthropic/normalize.ts | 112 ++++++++ .../openai/OpenAiCostProvider.test.ts | 192 +++++++++++++ .../providers/openai/OpenAiCostProvider.ts | 117 ++++++++ .../cost/providers/openai/fake-openai.ts | 151 ++++++++++ .../cost/providers/openai/normalize.test.ts | 155 ++++++++++ .../cost/providers/openai/normalize.ts | 104 +++++++ .../cost/providers/openai/openai-costs-api.ts | 172 +++++++++++ .../backend/src/services/cost/sync-plan.ts | 7 + packages/domain/src/cost.ts | 2 + packages/domain/src/http/anthropic-cost.ts | 44 +++ packages/domain/src/http/index.ts | 2 + packages/domain/src/http/internal-api.ts | 4 + packages/domain/src/http/openai-cost.ts | 42 +++ 43 files changed, 3334 insertions(+), 89 deletions(-) create mode 100644 apps/api/src/routes/internal/anthropic-cost.http.ts create mode 100644 apps/api/src/routes/internal/openai-cost.http.ts create mode 100644 packages/backend/src/services/cost/AnthropicCostConnectionService.test.ts create mode 100644 packages/backend/src/services/cost/AnthropicCostConnectionService.ts create mode 100644 packages/backend/src/services/cost/OpenAiCostConnectionService.test.ts create mode 100644 packages/backend/src/services/cost/OpenAiCostConnectionService.ts create mode 100644 packages/backend/src/services/cost/providers/anthropic/AnthropicCostProvider.test.ts create mode 100644 packages/backend/src/services/cost/providers/anthropic/AnthropicCostProvider.ts create mode 100644 packages/backend/src/services/cost/providers/anthropic/anthropic-cost-api.ts create mode 100644 packages/backend/src/services/cost/providers/anthropic/fake-anthropic.ts create mode 100644 packages/backend/src/services/cost/providers/anthropic/normalize.test.ts create mode 100644 packages/backend/src/services/cost/providers/anthropic/normalize.ts create mode 100644 packages/backend/src/services/cost/providers/openai/OpenAiCostProvider.test.ts create mode 100644 packages/backend/src/services/cost/providers/openai/OpenAiCostProvider.ts create mode 100644 packages/backend/src/services/cost/providers/openai/fake-openai.ts create mode 100644 packages/backend/src/services/cost/providers/openai/normalize.test.ts create mode 100644 packages/backend/src/services/cost/providers/openai/normalize.ts create mode 100644 packages/backend/src/services/cost/providers/openai/openai-costs-api.ts create mode 100644 packages/domain/src/http/anthropic-cost.ts create mode 100644 packages/domain/src/http/openai-cost.ts diff --git a/apps/api/src/routes/internal/anthropic-cost.http.ts b/apps/api/src/routes/internal/anthropic-cost.http.ts new file mode 100644 index 0000000000..82667f85de --- /dev/null +++ b/apps/api/src/routes/internal/anthropic-cost.http.ts @@ -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 { AnthropicCostConnectionService } from "@maple/backend/services/cost/AnthropicCostConnectionService" + +// 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 HttpAnthropicCostLive = HttpApiBuilder.group(MapleInternalApi, "anthropicCost", (handlers) => + Effect.gen(function* () { + const connections = yield* AnthropicCostConnectionService + + 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.apiKey) + yield* recordHttpAudit("cost_source_key.updated", { + metadata: { provider: "anthropic", accountId: saved.accountId }, + }) + return new CostConnectionSaved(saved) + }).pipe(Effect.withSpan("HttpAnthropicCost.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: "anthropic" }, + }) + return new CostConnectionRemoved({ removed: true }) + }).pipe(Effect.withSpan("HttpAnthropicCost.remove")), + ) + }), +) diff --git a/apps/api/src/routes/internal/openai-cost.http.ts b/apps/api/src/routes/internal/openai-cost.http.ts new file mode 100644 index 0000000000..52f1091746 --- /dev/null +++ b/apps/api/src/routes/internal/openai-cost.http.ts @@ -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 { OpenAiCostConnectionService } from "@maple/backend/services/cost/OpenAiCostConnectionService" + +// 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 HttpOpenAiCostLive = HttpApiBuilder.group(MapleInternalApi, "openaiCost", (handlers) => + Effect.gen(function* () { + const connections = yield* OpenAiCostConnectionService + + 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.apiKey) + yield* recordHttpAudit("cost_source_key.updated", { + metadata: { provider: "openai", accountId: saved.accountId }, + }) + return new CostConnectionSaved(saved) + }).pipe(Effect.withSpan("HttpOpenAiCost.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: "openai" }, + }) + return new CostConnectionRemoved({ removed: true }) + }).pipe(Effect.withSpan("HttpOpenAiCost.remove")), + ) + }), +) diff --git a/apps/api/src/runtime/http-graph.ts b/apps/api/src/runtime/http-graph.ts index 760fb357c6..575eac2a31 100644 --- a/apps/api/src/runtime/http-graph.ts +++ b/apps/api/src/runtime/http-graph.ts @@ -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" @@ -175,6 +177,8 @@ const ApiInternalRoutes = HttpApiBuilder.layer(MapleInternalApi).pipe( HttpDependencyScanLive, HttpDigestLive, HttpCloudflareCostLive, + HttpAnthropicCostLive, + HttpOpenAiCostLive, HttpGithubCostLive, HttpPlanetScaleCostLive, HttpOpenRouterCostLive, diff --git a/apps/api/src/runtime/service-graph.ts b/apps/api/src/runtime/service-graph.ts index 9aeea3add1..c5c7d9aec5 100644 --- a/apps/api/src/runtime/service-graph.ts +++ b/apps/api/src/runtime/service-graph.ts @@ -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" @@ -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, diff --git a/apps/web/src/lab/cost-fixture.test.ts b/apps/web/src/lab/cost-fixture.test.ts index 2c4df6e800..ac7339b597 100644 --- a/apps/web/src/lab/cost-fixture.test.ts +++ b/apps/web/src/lab/cost-fixture.test.ts @@ -1,4 +1,5 @@ import { Array as Arr } from "effect" +import { CredentialProvider } from "../page/cost/providers" import { describe, expect, test } from "vitest" import { LAB_SOURCES, @@ -25,7 +26,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) @@ -44,36 +45,37 @@ 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 = (added: boolean | null, from = LAB_SOURCES.synced) => { + const changes = { + credentials: added === null ? {} : { [provider]: added }, + 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(false)).toEqual({ sources: [], costRemoved: true }) + expect(outcome(true, LAB_SOURCES.none)).toEqual({ sources: ["pending"], costRemoved: false }) + }, + ) test("a GitHub organization is added and removed on its own, and the other keeps its source and cost", () => { const github = (changes: Readonly>) => { const sources = labSourcesWith(LAB_SOURCES.synced, { - openRouter: null, - cloudflare: null, + credentials: {}, github: changes, vercel: null, planetScale: null, @@ -89,8 +91,7 @@ describe("cost lab fixture", () => { ), costRemoved: Arr.contains( labRemovedProviders({ - openRouter: null, - cloudflare: null, + credentials: {}, github: changes, vercel: null, planetScale: null, @@ -133,13 +134,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( @@ -159,7 +160,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( @@ -181,17 +182,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. diff --git a/apps/web/src/lab/cost-fixture.ts b/apps/web/src/lab/cost-fixture.ts index e839ff2de4..90c6d054e0 100644 --- a/apps/web/src/lab/cost-fixture.ts +++ b/apps/web/src/lab/cost-fixture.ts @@ -10,6 +10,7 @@ import { } from "@maple/domain/http" import { Array as Arr, Number as Num, Option, Order, Record as Rec, Schema, String as Str } from "effect" import type { CostGroupBy } from "../page/cost/derive" +import { CredentialProvider, isCredentialProvider } 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. @@ -132,6 +133,50 @@ const cloudflareLine = ( drift, }) +/** The Anthropic organization the lab's source reads: rows carry its id, the page shows its name. */ +const LAB_ANTHROPIC_ORGANIZATION = { id: "0b1c2d3e-0000-4000-8000-00000000a070", name: "Example Labs" } + +/** A model's or tool's daily Anthropic spend in one workspace, named the way the sync names it. */ +const anthropicLine = ( + service: string, + usage: string, + workspace: string, + daily: number, + drift: number, +): Line => ({ + cloud_service: service, + provider: "anthropic", + service_category: "AI and Machine Learning", + region: "global", + usage_type: usage, + resource: workspace, + account: LAB_ANTHROPIC_ORGANIZATION.id, + daily, + drift, +}) + +/** The OpenAI organization the lab's source reads: OpenAI names it by its id alone. */ +const LAB_OPENAI_ORGANIZATION = "org-LabExampleOrganization00" + +/** A model's daily OpenAI spend in one project. */ +const openAiLine = ( + model: string, + lineItem: string, + project: string, + daily: number, + drift: number, +): Line => ({ + cloud_service: `openai/${model}`, + provider: "openai", + service_category: "AI and Machine Learning", + region: "global", + usage_type: lineItem, + resource: project, + account: LAB_OPENAI_ORGANIZATION, + daily, + drift, +}) + /** The PlanetScale organization the lab's source reads. */ export const LAB_PLANETSCALE_ORGANIZATION = "example-data" @@ -182,6 +227,31 @@ const LINES: ReadonlyArray = [ cloudflareLine("Durable Objects", "Compute", "Durable Objects Compute Duration", 5.1, 0.12), cloudflareLine("R2", "Storage", "R2 Storage", 3.6, 0.05), cloudflareLine("D1", "Databases", "D1 Rows Read", 1.2, -0.08), + anthropicLine( + "anthropic/claude-opus-5-5", + "Claude Opus 5.5 Usage - Output Tokens", + "wrkspc_example_agents", + 24.6, + 0.35, + ), + anthropicLine( + "anthropic/claude-opus-5-5", + "Claude Opus 5.5 Usage - Input Tokens", + "wrkspc_example_agents", + 8.1, + 0.2, + ), + anthropicLine( + "anthropic/claude-haiku-5-5", + "Claude Haiku 5.5 Usage - Input Tokens", + "default", + 1.9, + -0.05, + ), + anthropicLine("Web search", "Web search", "wrkspc_example_agents", 0.8, 0.1), + openAiLine("gpt-5", "gpt-5, output", "proj_ExampleSupport", 14.2, 0.12), + openAiLine("gpt-5", "gpt-5, input", "proj_ExampleSupport", 5.3, 0.08), + openAiLine("text-embedding-3-large", "text-embedding-3-large, input", "proj_ExampleSearch", 0.9, 0.0), productLine("Example Function Duration", "Compute", "iad1", "function-duration", "example-web", 9.4, 0.2), productLine( "Example Function Duration", @@ -421,6 +491,38 @@ const cloudflareSource = source({ requestsThisMonth: 0, }) +/** The Anthropic source: this month and the three before it, and no billed requests. */ +const anthropicSource = source({ + id: "0190f2b3-0000-7000-8000-00000000c066", + provider: "anthropic", + connectionRef: "0190f2b3-0000-7000-8000-00000000a066", + accountId: LAB_ANTHROPIC_ORGANIZATION.id, + accountName: LAB_ANTHROPIC_ORGANIZATION.name, + backfillDate: "2026-08-01", + lastSyncedAt: LAB_NOW_MS - 70 * 60_000, + requestsThisMonth: 0, +}) + +/** The OpenAI source: the same history, named by its organization id. */ +const openAiSource = source({ + id: "0190f2b3-0000-7000-8000-00000000c067", + provider: "openai", + connectionRef: "0190f2b3-0000-7000-8000-00000000a067", + accountId: LAB_OPENAI_ORGANIZATION, + accountName: LAB_OPENAI_ORGANIZATION, + backfillDate: "2026-08-01", + lastSyncedAt: LAB_NOW_MS - 80 * 60_000, + requestsThisMonth: 0, +}) + +/** The source each credential provider's add brings in. */ +const CREDENTIAL_SOURCE_OF = { + openrouter: openRouterSource, + cloudflare: cloudflareSource, + anthropic: anthropicSource, + openai: openAiSource, +} as const satisfies Record + /** The PlanetScale source: invoices back to the twelfth month before this one, and no billed requests. */ const planetScaleSource = source({ id: "0190f2b3-0000-7000-8000-00000000c065", @@ -459,13 +561,12 @@ export const labGithubLogin = (installationId: string): string => ) /** - * What the lab's reader did since the state loaded. `openRouter`, `cloudflare`, `vercel` and - * `planetScale`: added (true), removed (false) or neither (null). `github`: an installation's id - * to whether it is a source now. + * What the lab's reader did since the state loaded. `credentials`: a pasted-credential provider to + * whether it was added (true) or removed (false), absent for neither; `vercel` and `planetScale` + * the same, null for neither. `github`: an installation's id to whether it is a source now. */ export type LabSourceChanges = Readonly<{ - openRouter: boolean | null - cloudflare: boolean | null + credentials: Readonly>> github: Readonly> vercel: boolean | null planetScale: boolean | null @@ -479,8 +580,7 @@ export const labRemovedProviders = (changes: LabSourceChanges): ReadonlyArray - (provider === "openrouter" && changes.openRouter === false) || - (provider === "cloudflare" && changes.cloudflare === false) || + (isCredentialProvider(provider) && changes.credentials[provider] === false) || (provider === "github" && Rec.has(changes.github, LAB_GITHUB_INSTALLATION)) || // A team added again is a new source: it has no cost until a sync the lab does not model. (provider === "vercel" && changes.vercel !== null) || @@ -497,22 +597,25 @@ export const labSourcesWith = ( changes: LabSourceChanges, ): CostSourcesResponse => { const kept = Arr.filter(Schema.encodeSync(CostSourcesResponse)(sources).sources, (entry) => - entry.provider === "openrouter" - ? changes.openRouter !== false - : entry.provider === "cloudflare" - ? changes.cloudflare !== false - : entry.provider === "vercel" - ? changes.vercel === null - : entry.provider === "planetscale" - ? changes.planetScale === null - : entry.provider !== "github" || !Rec.has(changes.github, entry.connectionRef), + isCredentialProvider(entry.provider) + ? changes.credentials[entry.provider] !== false + : entry.provider === "vercel" + ? changes.vercel === null + : entry.provider === "planetscale" + ? changes.planetScale === null + : entry.provider !== "github" || !Rec.has(changes.github, entry.connectionRef), ) const has = (provider: CostProvider) => Arr.some(kept, (entry) => entry.provider === provider) return decodeSources({ sources: [ ...kept, - ...(changes.openRouter === true && !has("openrouter") ? [justAdded(openRouterSource)] : []), - ...(changes.cloudflare === true && !has("cloudflare") ? [justAdded(cloudflareSource)] : []), + ...Arr.map( + Arr.filter( + CredentialProvider.literals, + (provider) => changes.credentials[provider] === true && !has(provider), + ), + (provider) => justAdded(CREDENTIAL_SOURCE_OF[provider]), + ), ...(changes.vercel === true ? [justAdded(vercelSource)] : []), ...(changes.planetScale === true ? [justAdded(planetScaleSource)] : []), ...Arr.map( @@ -573,6 +676,8 @@ export const LAB_SOURCES = { vercelSource, cloudflareSource, planetScaleSource, + anthropicSource, + openAiSource, ], }), none: decodeSources({ sources: [] }), diff --git a/apps/web/src/lab/cost-lab.ts b/apps/web/src/lab/cost-lab.ts index 691add7991..41b5f01e8d 100644 --- a/apps/web/src/lab/cost-lab.ts +++ b/apps/web/src/lab/cost-lab.ts @@ -35,12 +35,16 @@ import { FetchVercelCostStatus, ReadCostGrants, RemoveGithubCostSource, + RemoveAnthropicCostKey, RemoveCloudflareCostToken, + RemoveOpenAiCostKey, RemoveOpenRouterCostKey, RemovePlanetScaleCostSource, RemoveVercelCostSource, ResolveWindow, + SaveAnthropicCostKey, SaveCloudflareCostToken, + SaveOpenAiCostKey, SaveOpenRouterCostKey, TimeseriesInput, WindowInput, @@ -96,6 +100,10 @@ export const LabState = Schema.Literals([ "token-too-broad", "replace-token", "remove-cloudflare", + "model-vendors", + "add-anthropic", + "add-openai", + "openai-no-spend", "add-github", "github-refused", "github-none", @@ -128,10 +136,8 @@ export const flags: Effect.Effect = Effect.sync(() => ({ export const Model = Schema.Struct({ state: LabState, - /** OpenRouter added (true) or removed (false) since the state loaded; null before either. */ - openRouter: Schema.NullOr(Schema.Boolean), - /** The same for Cloudflare. */ - cloudflare: Schema.NullOr(Schema.Boolean), + /** Pasted-credential providers added (true) or removed (false) since the state loaded. */ + credentials: Schema.Record(Schema.String, Schema.Boolean), /** GitHub installations added (true) or removed (false) since the state loaded, by id. */ github: Schema.Record(Schema.String, Schema.Boolean), /** Vercel added (true) or removed (false) since the state loaded; null before either. */ @@ -159,7 +165,9 @@ const searchFor = (state: LabState): Search => ? { groupBy: "resource" } : state === "cloudflare" ? { provider: ["cloudflare"] } - : {} + : state === "model-vendors" + ? { provider: ["anthropic", "openai"], groupBy: "cloud_service" } + : {} /** The states that start with nothing connected, so the dialog offers every provider. */ const startsEmpty = (state: LabState): boolean => @@ -171,6 +179,9 @@ const startsEmpty = (state: LabState): boolean => state === "add-cloudflare" || state === "token-refused" || state === "token-too-broad" || + state === "add-anthropic" || + state === "add-openai" || + state === "openai-no-spend" || state === "github-none" || state === "github-reading" || state === "github-unread" || @@ -204,6 +215,12 @@ const openingOf = (state: LabState): ReadonlyArray => { return [CostMessage.ClickedReplaceCredential({ provider: "cloudflare" })] case "remove-cloudflare": return removalOf("cloudflare") + case "add-anthropic": + return [CostMessage.ClickedAddSource(), CostMessage.PickedProvider({ provider: "anthropic" })] + case "add-openai": + return [CostMessage.ClickedAddSource(), CostMessage.PickedProvider({ provider: "openai" })] + case "openai-no-spend": + return submissionOf("openai", "sk-admin-0123456789abcdefghij0123456789") case "add-github": case "github-none": case "github-reading": @@ -308,6 +325,12 @@ const tooBroadToken = { "This token can do more than read billing: it can also read account members and Workers scripts. Create one with only the Billing: Read permission.", } +const noSpendKey = { + ...toLoadFailure("refused"), + message: + "This OpenAI organization has no API spend in the last 180 days, so there is nothing to read yet.", +} + /** The days a preset spans, for the fixture's window; a custom range reads as the default. */ const DAYS = new Map([ ["1w", 7], @@ -485,6 +508,24 @@ const answerOf = (world: World, search: Search, command: CostCommand): Option.Op : Result.succeed(undefined), }), ) + case SaveAnthropicCostKey.name: + return Option.some( + CostMessage.CompletedSaveCredential({ + provider: "anthropic", + result: Result.succeed(undefined), + }), + ) + case SaveOpenAiCostKey.name: + return Option.some( + CostMessage.CompletedSaveCredential({ + provider: "openai", + result: state === "openai-no-spend" ? Result.fail(noSpendKey) : Result.succeed(undefined), + }), + ) + case RemoveAnthropicCostKey.name: + return Option.some(removedAnswer("anthropic")) + case RemoveOpenAiCostKey.name: + return Option.some(removedAnswer("openai")) case RemoveOpenRouterCostKey.name: return Option.some(removedAnswer("openrouter")) case RemoveCloudflareCostToken.name: @@ -579,15 +620,8 @@ const removedAnswer = (provider: CredentialProvider) => CostMessage.CompletedRemoveCredential({ provider, result: Result.succeed(undefined) }) /** `changes` alone, without whatever else the value carries. */ -const changesOf = ({ - openRouter, - cloudflare, - github, - vercel, - planetScale, -}: LabSourceChanges): LabSourceChanges => ({ - openRouter, - cloudflare, +const changesOf = ({ credentials, github, vercel, planetScale }: LabSourceChanges): LabSourceChanges => ({ + credentials, github, vercel, planetScale, @@ -605,10 +639,10 @@ const changesAfter = (changes: LabSourceChanges, answered: ReadonlyArray - provider === "openrouter" - ? { ...current, openRouter: isSource } - : { ...current, cloudflare: isSource } + const withCredential = (provider: CredentialProvider, isSource: boolean): LabSourceChanges => ({ + ...current, + credentials: { ...current.credentials, [provider]: isSource }, + }) switch (answer._tag) { case "CompletedSaveCredential": return Result.isSuccess(answer.result) ? withCredential(answer.provider, true) : current @@ -680,8 +714,7 @@ export const init = (flags: Flags): Return => { const entered = Cost.init({ search: searchFor(flags.state) }, LAB_CONTEXT) const world: World = { state: flags.state, - openRouter: null, - cloudflare: null, + credentials: {}, github: {}, vercel: null, planetScale: null, diff --git a/apps/web/src/page/cost/command.ts b/apps/web/src/page/cost/command.ts index 1747d7f2fb..671acf6a67 100644 --- a/apps/web/src/page/cost/command.ts +++ b/apps/web/src/page/cost/command.ts @@ -1,7 +1,9 @@ import { + AnthropicCostKeyRequest, CloudflareCostTokenRequest, CostBreakdownSortBy, CostFilters, + OpenAiCostKeyRequest, OpenRouterKeyUpdateRequest, PlanetScaleCostAddRequest, VercelCostAddRequest, @@ -180,6 +182,48 @@ export const RemoveCloudflareCostToken = Command.define("RemoveCloudflareCostTok ), }) +/** Saves the org's Anthropic Admin API key, which makes (or keeps) its cost source. */ +export const SaveAnthropicCostKey = Command.define("SaveAnthropicCostKey", { + args: { apiKey: Schema.String }, + messages: [Message.CompletedSaveCredential], + execute: ({ apiKey }) => + Effect.flatMap(MapleInternalApiClient, (client) => + client.anthropicCost.set({ payload: new AnthropicCostKeyRequest({ apiKey }) }), + ).pipe( + Effect.asVoid, + settleTo((result) => Message.CompletedSaveCredential({ provider: "anthropic", result })), + ), +}) + +export const RemoveAnthropicCostKey = Command.define("RemoveAnthropicCostKey", { + messages: [Message.CompletedRemoveCredential], + execute: Effect.flatMap(MapleInternalApiClient, (client) => client.anthropicCost.remove()).pipe( + Effect.asVoid, + settleTo((result) => Message.CompletedRemoveCredential({ provider: "anthropic", result })), + ), +}) + +/** Saves the org's OpenAI Admin API key, which makes (or keeps) its cost source. */ +export const SaveOpenAiCostKey = Command.define("SaveOpenAiCostKey", { + args: { apiKey: Schema.String }, + messages: [Message.CompletedSaveCredential], + execute: ({ apiKey }) => + Effect.flatMap(MapleInternalApiClient, (client) => + client.openaiCost.set({ payload: new OpenAiCostKeyRequest({ apiKey }) }), + ).pipe( + Effect.asVoid, + settleTo((result) => Message.CompletedSaveCredential({ provider: "openai", result })), + ), +}) + +export const RemoveOpenAiCostKey = Command.define("RemoveOpenAiCostKey", { + messages: [Message.CompletedRemoveCredential], + execute: Effect.flatMap(MapleInternalApiClient, (client) => client.openaiCost.remove()).pipe( + Effect.asVoid, + settleTo((result) => Message.CompletedRemoveCredential({ provider: "openai", result })), + ), +}) + /** The org's GitHub installations, with which can supply cost. */ export const FetchGithubCostInstallations = Command.define("FetchGithubCostInstallations", { args: { request: Schema.Number }, diff --git a/apps/web/src/page/cost/cost.scene.test.ts b/apps/web/src/page/cost/cost.scene.test.ts index 289e4fe7dd..b93e76f542 100644 --- a/apps/web/src/page/cost/cost.scene.test.ts +++ b/apps/web/src/page/cost/cost.scene.test.ts @@ -255,7 +255,9 @@ describe("cost page", () => { expectView(role("link", { name: /^AWS/ })).toHaveAttr("href", "/sources/new?integration=aws"), expectView(text(/AWS bills USD.0\.01 for each request Maple makes/)).toExist(), expectView( - text(/OpenRouter, GitHub, Vercel, Cloudflare and PlanetScale requests are free/), + text( + /OpenRouter, GitHub, Vercel, Cloudflare, PlanetScale, Anthropic and OpenAI requests are free/, + ), ).toExist(), expectView(role("button", { name: /^Vercel/ })).toExist(), // An account already connected turns its Cost module on from the AWS integration. @@ -602,6 +604,59 @@ describe("cost page", () => { ) }) + test.each([ + { + provider: "anthropic", + name: "Anthropic", + meta: "Daily API spend by model and workspace", + href: "https://platform.claude.com/settings/admin-keys", + key: "sk-ant-admin01-0123456789abcdef", + command: "SaveAnthropicCostKey", + }, + { + provider: "openai", + name: "OpenAI", + meta: "Daily API spend by model and project", + href: "https://platform.openai.com/settings/organization/admin-keys", + key: "sk-admin-0123456789abcdef", + command: "SaveOpenAiCostKey", + }, + ] as const)( + "$name is added by pasting an Admin API key, and the dialog says what that key can do", + ({ provider, name, meta, href, key, command }) => { + const opened = pageUpdate(entered({}, []).model, Message.ClickedAddSource()).model + scene( + { update: pageUpdate, view }, + given(opened), + resolveTheme, + dialogMounted, + expectView(role("button", { name: new RegExp(`^${name}`) })).toExist(), + expectView(text(meta)).toExist(), + ) + const form = pageUpdate(opened, Message.PickedProvider({ provider })).model + scene( + { update: pageUpdate, view }, + given(form), + resolveTheme, + dialogMounted, + expectView(text(`Add ${name}`)).toExist(), + expectView(text("Admin API key")).toExist(), + expectView(role("link", { name: "Admin keys" })).toHaveAttr("href", href), + // Neither vendor has a read-only admin key, so the reader is told before pasting one. + expectView(text(/this one can also manage your organization/)).toExist(), + ) + const typed = pageUpdate( + form, + Message.GotCredentialDraftMessage({ + message: Field.Message.ChangedValue({ value: ` ${key} ` }), + }), + ).model + expect(pageUpdate(typed, Message.SubmittedCredential()).commands).toMatchObject([ + { name: command, args: { apiKey: key } }, + ]) + }, + ) + test("a Cloudflare source has its token replaced or is removed, and the OpenRouter source beside it stays", () => { const openRouter = source({ id: "0190f2b3-0000-7000-8000-00000000c060", @@ -717,7 +772,9 @@ describe("cost page", () => { dialogMounted, expectView(role("button", { name: /^GitHub/ })).toExist(), expectView( - text(/OpenRouter, GitHub, Vercel, Cloudflare and PlanetScale requests are free/), + text( + /OpenRouter, GitHub, Vercel, Cloudflare, PlanetScale, Anthropic and OpenAI requests are free/, + ), ).toExist(), ) const reading = pageUpdate(opened, Message.ClickedAddGithub()) diff --git a/apps/web/src/page/cost/credential-sources.ts b/apps/web/src/page/cost/credential-sources.ts index 8fb31949fb..7dfba88c78 100644 --- a/apps/web/src/page/cost/credential-sources.ts +++ b/apps/web/src/page/cost/credential-sources.ts @@ -1,10 +1,19 @@ -import { CLOUDFLARE_COST_TOKEN_MAX_LENGTH, OPENROUTER_KEY_MAX_LENGTH } from "@maple/domain/http" +import { + ANTHROPIC_COST_KEY_MAX_LENGTH, + CLOUDFLARE_COST_TOKEN_MAX_LENGTH, + OPENAI_COST_KEY_MAX_LENGTH, + OPENROUTER_KEY_MAX_LENGTH, +} from "@maple/domain/http" import type { Command } from "foldkit" import type { Resources } from "../../platform/resources" import { + RemoveAnthropicCostKey, RemoveCloudflareCostToken, + RemoveOpenAiCostKey, RemoveOpenRouterCostKey, + SaveAnthropicCostKey, SaveCloudflareCostToken, + SaveOpenAiCostKey, SaveOpenRouterCostKey, } from "./command" import type { Message } from "./message" @@ -122,4 +131,79 @@ export const CREDENTIAL_SOURCES = { save: (apiToken) => SaveCloudflareCostToken({ apiToken }), delete: () => RemoveCloudflareCostToken(), }, + // Neither vendor offers a key that reads only cost, so the help says what the key can do. + anthropic: { + pick: { + meta: "Daily API spend by model and workspace", + addedMeta: "Replace its key in the Sources panel", + }, + field: { + label: "Admin API key", + placeholder: "sk-ant-admin...", + maxLength: ANTHROPIC_COST_KEY_MAX_LENGTH, + tooLong: "That is too long to be an Anthropic Admin API key.", + help: { + before: "Create one under ", + link: "Admin keys", + href: "https://platform.claude.com/settings/admin-keys", + after: " in the Claude Console. Anthropic has no read-only admin key, so this one can also manage your organization: Maple only reads its cost report and name with it, and stores it encrypted. Make one just for Maple so you can revoke it on its own.", + }, + }, + add: { + title: "Add Anthropic", + description: + "Maple reads this month and the last at once, then the two months before, and every day from here on.", + toast: "Anthropic added", + }, + replace: { + title: "Replace the Anthropic key", + description: "The source and its history stay. Use a key from the same Anthropic organization.", + label: "Replace key", + toast: "Anthropic key replaced", + }, + remove: { + title: "Remove the Anthropic source?", + description: + "Maple deletes the key and stops reading the organization's spend, and its cost leaves this page. Revoke the key in the Claude Console too if nothing else uses it.", + }, + save: (apiKey) => SaveAnthropicCostKey({ apiKey }), + delete: () => RemoveAnthropicCostKey(), + }, + openai: { + pick: { + meta: "Daily API spend by model and project", + addedMeta: "Replace its key in the Sources panel", + }, + field: { + label: "Admin API key", + placeholder: "sk-admin-...", + maxLength: OPENAI_COST_KEY_MAX_LENGTH, + tooLong: "That is too long to be an OpenAI Admin API key.", + help: { + before: "Create one under ", + link: "Admin keys", + href: "https://platform.openai.com/settings/organization/admin-keys", + after: " in OpenAI. A project API key cannot read spend, and OpenAI has no read-only admin key, so this one can also manage your organization: Maple only reads its costs with it, and stores it encrypted. Make one just for Maple so you can revoke it on its own.", + }, + }, + add: { + title: "Add OpenAI", + description: + "Maple reads this month and the last at once, then the two months before, and every day from here on.", + toast: "OpenAI added", + }, + replace: { + title: "Replace the OpenAI key", + description: "The source and its history stay. Use a key from the same OpenAI organization.", + label: "Replace key", + toast: "OpenAI key replaced", + }, + remove: { + title: "Remove the OpenAI source?", + description: + "Maple deletes the key and stops reading the organization's spend, and its cost leaves this page. Revoke the key in OpenAI too if nothing else uses it.", + }, + save: (apiKey) => SaveOpenAiCostKey({ apiKey }), + delete: () => RemoveOpenAiCostKey(), + }, } satisfies Record diff --git a/apps/web/src/page/cost/marks.test.ts b/apps/web/src/page/cost/marks.test.ts index e0e34a296d..d8cc1c0960 100644 --- a/apps/web/src/page/cost/marks.test.ts +++ b/apps/web/src/page/cost/marks.test.ts @@ -48,6 +48,29 @@ describe("cost key marks", () => { ) }) + test("Anthropic and OpenAI spend wears its model's or vendor's mark, never a platform's", () => { + const vendors = [source("anthropic", "org-uuid-1"), source("openai", "org-AbC123")] + const anthropic = keyMark("provider", "anthropic", [])?.icon + const openai = keyMark("provider", "openai", [])?.icon + // A Claude model wears its family's mark, as it does when OpenRouter bills it. + expect(keyMark("cloud_service", "anthropic/claude-opus-5-5", vendors)?.icon).toBe("ClaudeIcon") + expect(keyMark("cloud_service", "Web search", vendors)?.icon).toBe(anthropic) + expect(keyMark("cloud_service", "openai/gpt-5", vendors)?.icon).toBe(openai) + expect(keyMark("account", "org-AbC123", vendors)?.icon).toBe(openai) + expect(keyLabel("account", "org-AbC123", vendors)).toBe("OpenAI") + }) + + test("a model vendor beside one platform leaves that platform the owner of a name it does not know", () => { + const withVendors = [ + source("cloudflare", "cf-account"), + source("anthropic", "org-uuid-1"), + source("openai", "org-AbC123"), + ] + expect(keyMark("cloud_service", "Example Product", withVendors)?.icon).toBe( + keyMark("provider", "cloudflare", [])?.icon, + ) + }) + test("two OpenRouter accounts keep their ids so their series stay apart", () => { const two = [...sources, source("openrouter", "org_second", "0195f1c2-7a3b-7c4d-8e5f-6a7b8c9d0e20")] expect(keyLabel("account", "org_second", two)).toBe("OpenRouter org_second") diff --git a/apps/web/src/page/cost/marks.ts b/apps/web/src/page/cost/marks.ts index 8a172e4d92..9c24a6daf6 100644 --- a/apps/web/src/page/cost/marks.ts +++ b/apps/web/src/page/cost/marks.ts @@ -29,12 +29,19 @@ const isAwsServiceName = (key: string): boolean => /^(Amazon|AWS|EC2|Elastic|Clo /** A Vercel region is an airport code and a digit: `iad1`, `fra1`. */ const isVercelRegionId = (key: string): boolean => /^[a-z]{3}\d$/u.test(key) -/** The org's providers that bill services and regions of their own. OpenRouter's are model slugs. */ +/** Providers whose services are model slugs, not products and regions of their own. */ +const MODEL_PROVIDERS: ReadonlySet = new Set([ + "openrouter", + "anthropic", + "openai", +]) + +/** The org's providers that bill services and regions of their own. */ const platformsOf = (sources: ReadonlyArray): ReadonlyArray => Arr.dedupe( Arr.filter( Arr.map(sources, (source) => source.provider), - (provider) => provider !== "openrouter", + (provider) => !MODEL_PROVIDERS.has(provider), ), ) @@ -92,6 +99,14 @@ const CLOUDFLARE_SERVICES: ReadonlySet = new Set([ "Subscriptions", ]) +/** What Anthropic files a charge with no model under (its tools, Managed Agents, anything else). */ +const ANTHROPIC_SERVICES: ReadonlySet = new Set([ + "Web search", + "Code execution", + "Managed Agents sessions", + "Anthropic API", +]) + /** What a PlanetScale charge that names no database and has no description is filed under. */ const PLANETSCALE_UNNAMED = "PlanetScale" @@ -104,11 +119,13 @@ const namedServiceOwner = (key: string): CostProvider | undefined => ? "github" : CLOUDFLARE_SERVICES.has(key) ? "cloudflare" - : isAwsServiceName(key) - ? "aws" - : key === PLANETSCALE_UNNAMED - ? "planetscale" - : undefined + : ANTHROPIC_SERVICES.has(key) + ? "anthropic" + : isAwsServiceName(key) + ? "aws" + : key === PLANETSCALE_UNNAMED + ? "planetscale" + : undefined /** * PlanetScale's service is the database, named in lowercase letters, digits, hyphens and diff --git a/apps/web/src/page/cost/providers.ts b/apps/web/src/page/cost/providers.ts index 4fe0f7e647..b27d1b549a 100644 --- a/apps/web/src/page/cost/providers.ts +++ b/apps/web/src/page/cost/providers.ts @@ -16,7 +16,8 @@ export type CostProviderEntry = Readonly<{ sourceNote?: string }> -// Not every cost provider is an integration: OpenRouter supplies cost only, so it is named here. +// Not every cost provider is an integration: OpenRouter, Anthropic and OpenAI supply cost only, so +// they are named here. // Cloudflare is one, but its cost source is a token of its own, not the integration's connection. // Vercel's requests are free, like OpenRouter's. // PlanetScale's source reads through its integration's connection, and is added and removed here. @@ -58,13 +59,24 @@ export const COST_PROVIDERS = { sourceNote: "Billed by the month: each day shows an even share of the month's invoice for each branch, and can change until the invoice closes.", }, + anthropic: { + name: "Anthropic", + icon: "AnthropicIcon", + requestUsd: null, + }, + openai: { + name: "OpenAI", + icon: "OpenAiIcon", + iconClassName: "text-foreground", + requestUsd: null, + }, } as const satisfies Record /** * The providers added by pasting a credential on the Cost page, which can also replace it and * remove the source. Each one's form and wording are in `credential-sources.ts`. */ -export const CredentialProvider = Schema.Literals(["openrouter", "cloudflare"]) +export const CredentialProvider = Schema.Literals(["openrouter", "cloudflare", "anthropic", "openai"]) export type CredentialProvider = typeof CredentialProvider.Type /** Whether a dialog step or a provider is one added by pasting a credential. */ diff --git a/apps/web/src/page/cost/update.ts b/apps/web/src/page/cost/update.ts index 56a8f7e853..2cd2363fa1 100644 --- a/apps/web/src/page/cost/update.ts +++ b/apps/web/src/page/cost/update.ts @@ -364,6 +364,8 @@ const removeCommand = (source: CostSourceSummary): ReadonlyArray => { switch (source.provider) { case "openrouter": case "cloudflare": + case "anthropic": + case "openai": return [CREDENTIAL_SOURCES[source.provider].delete()] case "github": return Arr.map(Option.toArray(decodeInstallationId(source.connectionRef)), (installationId) => diff --git a/apps/web/src/page/cost/view-add-source.ts b/apps/web/src/page/cost/view-add-source.ts index a692fe0b56..3af14b7f90 100644 --- a/apps/web/src/page/cost/view-add-source.ts +++ b/apps/web/src/page/cost/view-add-source.ts @@ -429,6 +429,8 @@ const removeCopy = (target: CostSourceSummary | null): Readonly<{ title: string; return PLANETSCALE_REMOVE case "openrouter": case "cloudflare": + case "anthropic": + case "openai": return CREDENTIAL_SOURCES[target.provider].remove // An AWS source has no Remove: it leaves with its connection's Cost module. The confirm is closed then. default: diff --git a/apps/web/src/page/cost/view-sources.ts b/apps/web/src/page/cost/view-sources.ts index f0877586f4..23f8faf476 100644 --- a/apps/web/src/page/cost/view-sources.ts +++ b/apps/web/src/page/cost/view-sources.ts @@ -86,7 +86,9 @@ const actions = ( const manage = (href: string) => connectionLink(h, named(`Manage ${name} connection`), href) switch (source.provider) { case "openrouter": - case "cloudflare": { + case "cloudflare": + case "anthropic": + case "openai": { const { label } = CREDENTIAL_SOURCES[source.provider].replace return [ rowButton( diff --git a/apps/web/src/page/cost/view.ts b/apps/web/src/page/cost/view.ts index de8264dc6f..2604f34688 100644 --- a/apps/web/src/page/cost/view.ts +++ b/apps/web/src/page/cost/view.ts @@ -66,7 +66,7 @@ const onRamp = ( emptyMedia(h, { variant: "icon" }, [icon(h, "CircleDollarIcon")]), emptyTitle(h, ["No cost source yet"]), emptyDescription(h, [ - "Add a source and Maple reads what it bills each day: an AWS account by service, region and account, an OpenRouter account by model, a GitHub organization by product and repository, a Vercel team by product and project, a Cloudflare account by product, a PlanetScale organization by database and branch.", + "Add a source and Maple reads what it bills each day: an AWS account by service, region and account, an OpenRouter account by model, a GitHub organization by product and repository, a Vercel team by product and project, a Cloudflare account by product, a PlanetScale organization by database and branch, an Anthropic or OpenAI organization by model and workspace or project.", ]), // A member's save would be refused: say who can, and offer no form. ...(!options.cannotManage @@ -133,6 +133,14 @@ const FIRST_SYNC = { alone: "It brings in this month's invoice and the last, and the year before fills in after that.", named: "PlanetScale brings in this month's invoice and the last, and the year before fills in after that.", }, + anthropic: { + alone: "It brings in this month and the last, then the two months before.", + named: "Anthropic brings in this month and the last, then the two months before.", + }, + openai: { + alone: "It brings in this month and the last, then the two months before.", + named: "OpenAI brings in this month and the last, then the two months before.", + }, } as const satisfies Record> /** What the first syncs bring in, by the providers that have no cost yet (waiting or failing alike). */ diff --git a/docs/cloud-cost-plan.md b/docs/cloud-cost-plan.md index d7fb7a6796..78e6a7c97a 100644 --- a/docs/cloud-cost-plan.md +++ b/docs/cloud-cost-plan.md @@ -2,7 +2,7 @@ Status: **phase 1 built, 2026-11-11, behind the `cloudCost` org flag.** AWS spend is synced daily into `cost_daily` and shown on `/cost`. OpenRouter, GitHub, Vercel and Cloudflare followed on -2026-11-12, PlanetScale on 2026-11-13. +2026-11-12, PlanetScale, Anthropic and OpenAI on 2026-11-13. Phase 0 checked the provider APIs against docs, public schemas and one real AWS account. Recommendations (phase 2) and the other providers are not built. @@ -166,15 +166,17 @@ next read. row and regroups by the next dimension (a service under a region goes on to its resources). The resource section and that step show only where some cost names a resource: a GitHub repository, a Vercel project, a PlanetScale database's branch. Sources are added from the page: - AWS through its connection (which has a Cost switch), OpenRouter by pasting a management key and - Cloudflare by pasting an API token, which the page can also replace or remove, GitHub by picking + AWS through its connection (which has a Cost switch), OpenRouter by pasting a management key, + Cloudflare by pasting an API token and Anthropic and OpenAI by pasting an Admin API key, which + the page can also replace or remove, GitHub by picking one of the organizations the GitHub App is installed on, and Vercel and PlanetScale by adding the team or organization the org has connected, all three of which the page can remove again. The pasted-credential step is one form described by data (`credential-sources.ts`). Recommendations (list and detail with the evidence chart) come with phase 2. - API: four internal endpoints in the query-engine group (`cost-totals`, `cost-timeseries`, `cost-breakdown`, `cost-sources`), `PUT` / `DELETE` on `/internal/cost/openrouter` and - `/internal/cost/cloudflare` for the pasted credentials, `GET /internal/cost/github` with `PUT` / + `/internal/cost/cloudflare`, `/internal/cost/anthropic` and `/internal/cost/openai` for the + pasted credentials, `GET /internal/cost/github` with `PUT` / `DELETE /internal/cost/github/{installationId}` for GitHub sources, and `GET` / `PUT` / `DELETE` on `/internal/cost/vercel` and `/internal/cost/planetscale` for the Vercel and PlanetScale sources. A public `/v2/cost/*` is not built. @@ -482,6 +484,38 @@ The billing API facts, which hold for either credential: - Not built: enterprise contracts, zone-level analytics, the restricted "Version 2" endpoints, invoices and credits. +### Anthropic (billed, Claude API only) + +- Read with an Admin API key (`sk-ant-admin...`) the org pastes, kept in `cost_connections`. + `GET /v1/organizations/me` names the organization (its id is the account, its name the label). +- `GET /v1/organizations/cost_report`, daily buckets grouped by `workspace_id` and `description`, + 31 buckets a page, followed by `next_page`. `amount` is a decimal string in cents. The docs call + it legacy beside `/v1/organizations/analytics/cost`, which Console organizations only get as an + early-access preview, so the legacy report is the one every org can read. +- Rows: a model's lines are `anthropic/` (the Cost page shows the model's mark), web search, + code execution and Managed Agents sessions their own names, anything else `Anthropic API`. The + description is the usage type, the workspace the resource (`default` for the default + workspace), the inference geography the region. +- Not covered: Claude subscriptions billed through claude.ai, and Claude on Bedrock, Vertex AI or + Foundry, which bill through those clouds. + +### OpenAI (billed, API only) + +- Read with an Admin API key (`sk-admin-...`), kept in `cost_connections`. `GET + /v1/organization/costs`, daily buckets grouped by `project_id` and `line_item`, 180 a page. +- No endpoint names the organization an admin key belongs to. The org id comes from + `organization_id` on the key's own cost results, so a save reads the last 180 days and refuses + an organization with no spend in them (nothing to show yet) or results of two organizations. + The label is the id: OpenAI reports no name. +- Rows: a line item `, ` is `openai/`, any other `openai/`. The + line item is the usage type, the project the resource, the quantity and its unit kept. + +Neither vendor offers an admin key that can only read cost: the key can also manage members and +keys. The dialog says so and suggests a key made for Maple alone; Maple only calls the endpoints +above. Retention of either report is not documented, so history starts three months back. Open: +check both against a real account (restated days, how far back each serves, Anthropic answering +an `ending_at` of tomorrow). + ### Railway (estimated per service, billed per workspace) - Schema: `usage(workspaceId, startDate, endDate, groupBy, measurements)` returns quantities @@ -583,7 +617,7 @@ Each phase is one PR stack. Plan imports. First Maple rules, each backed by a metric already ingested: load balancers with no requests, databases with idle connections and low CPU, container services reserving far more than they use. -3. **More providers.** OpenRouter, GitHub, Vercel, Cloudflare and PlanetScale are built. Railway, +3. **More providers.** OpenRouter, GitHub, Vercel, Cloudflare, PlanetScale, Anthropic and OpenAI are built. Railway, GCP, Supabase, one PR each. Each provider adds a literal to `CostProvider`, an adapter and a sync profile. One read with a pasted credential also adds itself to `COST_CONNECTION_PROVIDERS`, a connection service that checks the credential with the vendor, diff --git a/packages/backend/src/services/cost/AnthropicCostConnectionService.test.ts b/packages/backend/src/services/cost/AnthropicCostConnectionService.test.ts new file mode 100644 index 0000000000..ebf4943302 --- /dev/null +++ b/packages/backend/src/services/cost/AnthropicCostConnectionService.test.ts @@ -0,0 +1,270 @@ +import { afterEach, assert, describe, it } from "@effect/vitest" +import { OrgId, UserId } from "@maple/domain/http" +import { DateTime, Effect, Layer, Schema, Array as Arr, String as Str } from "effect" +import { TestClock } from "effect/testing" +import { cleanupTestDbs, createTestDb, queryFirstRow, type TestDb } from "@maple/backend/testing/test-pglite" +import { AnthropicCostConnectionService } from "./AnthropicCostConnectionService" +import { + FAKE_ORGANIZATION, + type FakeAnthropic, + fakeAnthropicDeps, + makeFakeAnthropic, +} from "./providers/anthropic/fake-anthropic" + +const trackedDbs: TestDb[] = [] +afterEach(() => cleanupTestDbs(trackedDbs)) + +const ORG = Schema.decodeSync(OrgId)("org_cost") +const USER = Schema.decodeSync(UserId)("user_cost") +const KEY = "sk-ant-admin01-0123456789abcdefghijABCDEFGHIJ0123456789-AbCdEfAA" +const OTHER_KEY = "sk-ant-admin01-9876543210zyxwvutsrqZYXWVUTSRQ9876543210-ZyXwVuAA" +const NOW = DateTime.toEpochMillis(DateTime.makeUnsafe("2026-11-11T06:00:00Z")) +const VALIDATION = "@maple/http/cost-connections/CostConnectionValidationError" +const PERSISTENCE = "@maple/http/cost-connections/CostConnectionPersistenceError" + +const run = ( + testDb: TestDb, + world: FakeAnthropic, + effect: Effect.Effect, +) => + Effect.andThen(TestClock.setTime(NOW), effect).pipe( + Effect.provide( + Layer.effect(AnthropicCostConnectionService, AnthropicCostConnectionService.make).pipe( + Layer.provide(fakeAnthropicDeps(testDb, world)), + ), + ), + ) + +const set = (key: string) => AnthropicCostConnectionService.use((service) => service.set(ORG, USER, key)) +const remove = AnthropicCostConnectionService.use((service) => service.remove(ORG)) + +interface Stored { + readonly id: string + readonly provider: string + readonly secret_ciphertext: string + readonly account_id: string + readonly account_label: string +} + +const storedKey = (testDb: TestDb) => + Effect.promise(() => + queryFirstRow( + testDb, + "SELECT id, provider, secret_ciphertext, account_id, account_label FROM cost_connections WHERE org_id = 'org_cost'", + ), + ) + +const source = (testDb: TestDb) => + Effect.promise(() => + queryFirstRow<{ + readonly connection_ref: string + readonly account_id: string + readonly enabled: boolean + }>( + testDb, + "SELECT connection_ref, account_id, enabled FROM cost_sources WHERE org_id = 'org_cost' AND provider = 'anthropic'", + ), + ) + +describe("AnthropicCostConnectionService", () => { + it.effect("checks a key with Anthropic, seals it and makes the cost source of its organization", () => { + const testDb = createTestDb(trackedDbs) + const world = makeFakeAnthropic() + return run( + testDb, + world, + Effect.gen(function* () { + const saved = yield* set(` ${KEY}\n`) + assert.deepStrictEqual(saved, { accountId: FAKE_ORGANIZATION.id, accountLabel: "Acme" }) + // Which organization, then proof the key reads its cost report for yesterday and today. + assert.strictEqual(world.urls[0], "/v1/organizations/me") + const report = new URL(`https://api.anthropic.com${world.urls[1] ?? ""}`) + assert.strictEqual(report.pathname, "/v1/organizations/cost_report") + assert.deepStrictEqual(Arr.fromIterable(report.searchParams), [ + ["starting_at", "2026-11-10T00:00:00Z"], + ["ending_at", "2026-11-12T00:00:00Z"], + ["bucket_width", "1d"], + ["group_by[]", "workspace_id"], + ["group_by[]", "description"], + ["limit", "31"], + ]) + assert.deepStrictEqual(world.headers, Arr.replicate([KEY, "2023-06-01"], 2)) + + const stored = yield* storedKey(testDb) + assert.isFalse(Str.includes(KEY)(stored?.secret_ciphertext ?? KEY)) + assert.deepStrictEqual( + [stored?.provider, stored?.account_id, stored?.account_label], + ["anthropic", FAKE_ORGANIZATION.id, "Acme"], + ) + const made = yield* source(testDb) + assert.deepStrictEqual( + [made?.connection_ref, made?.account_id, made?.enabled], + [stored?.id, FAKE_ORGANIZATION.id, true], + ) + }), + ) + }) + + it.effect( + "never sends text that is not an Admin API key, nor any key of an org outside the rollout", + () => { + const testDb = createTestDb(trackedDbs) + const world = makeFakeAnthropic() + return run( + testDb, + world, + Effect.gen(function* () { + const refusals = yield* Effect.forEach( + [ + "", + // A regular API key, which cannot read costs. + "sk-ant-api03-0123456789abcdefghij", + "sk-ant-admin", + "sk-ant-admin01 two words", + `sk-ant-admin01-${Str.repeat(600)("a")}`, + ], + (text) => Effect.map(Effect.flip(set(text)), (error) => [error._tag, error.message]), + ) + assert.deepStrictEqual( + refusals, + Arr.replicate( + [ + VALIDATION, + "That is not an Anthropic Admin API key. They start with sk-ant-admin.", + ], + 5, + ), + ) + + world.cloudCost = false + const outside = yield* Effect.flip(set(KEY)) + assert.strictEqual(outside._tag, "@maple/http/errors/CloudCostDisabledError") + assert.deepStrictEqual(world.urls, []) + assert.isUndefined(yield* storedKey(testDb)) + }), + ) + }, + ) + + it.effect("refuses a key Anthropic rejects, and one that cannot read the cost report", () => { + const testDb = createTestDb(trackedDbs) + const world = makeFakeAnthropic({ meStatus: 401 }) + return run( + testDb, + world, + Effect.gen(function* () { + const rejected = yield* Effect.flip(set(KEY)) + assert.deepStrictEqual( + [rejected._tag, rejected.message], + [ + VALIDATION, + "Anthropic rejected this key. Use an Admin API key from the Claude Console.", + ], + ) + world.meStatus = 403 + assert.strictEqual( + (yield* Effect.flip(set(KEY))).message, + "Anthropic rejected this key. Use an Admin API key from the Claude Console.", + ) + // Neither asked for costs. + assert.deepStrictEqual(world.urls, Arr.replicate("/v1/organizations/me", 2)) + + world.meStatus = 200 + world.costStatus = 403 + const noCosts = yield* Effect.flip(set(KEY)) + assert.deepStrictEqual( + [noCosts._tag, noCosts.message], + [VALIDATION, "This key cannot read the organization's costs. Use an Admin API key."], + ) + assert.isUndefined(yield* storedKey(testDb)) + }), + ) + }) + + it.effect("reports an Anthropic outage as a retryable failure, not as a bad key", () => { + const testDb = createTestDb(trackedDbs) + const world = makeFakeAnthropic({ meStatus: 529 }) + return run( + testDb, + world, + Effect.gen(function* () { + assert.strictEqual((yield* Effect.flip(set(KEY)))._tag, PERSISTENCE) + world.meStatus = 200 + world.costStatus = 500 + const failed = yield* Effect.flip(set(KEY)) + assert.deepStrictEqual( + [failed._tag, failed.message], + [PERSISTENCE, "Could not check this key with Anthropic. Try again."], + ) + world.costStatus = 200 + world.buckets = "maintenance" + assert.strictEqual((yield* Effect.flip(set(KEY)))._tag, PERSISTENCE) + assert.isUndefined(yield* storedKey(testDb)) + }), + ) + }) + + it.effect("keeps the connection and its source when the organization's key is replaced", () => { + const testDb = createTestDb(trackedDbs) + const world = makeFakeAnthropic() + return run( + testDb, + world, + Effect.gen(function* () { + yield* set(KEY) + const first = yield* storedKey(testDb) + const firstSource = yield* source(testDb) + + // The organization was renamed since: the label follows, the id is what is compared. + world.organization = { id: FAKE_ORGANIZATION.id, name: "Acme Inc" } + const replaced = yield* set(OTHER_KEY) + assert.strictEqual(replaced.accountLabel, "Acme Inc") + const second = yield* storedKey(testDb) + assert.strictEqual(second?.id, first?.id) + assert.strictEqual(second?.account_label, "Acme Inc") + assert.notStrictEqual(second?.secret_ciphertext, first?.secret_ciphertext) + assert.deepStrictEqual(yield* source(testDb), firstSource) + }), + ) + }) + + it.effect("refuses a key of another organization while a source exists, and keeps the saved one", () => { + const testDb = createTestDb(trackedDbs) + const world = makeFakeAnthropic() + return run( + testDb, + world, + Effect.gen(function* () { + yield* set(KEY) + const first = yield* storedKey(testDb) + + world.organization = { id: "9a8b7c6d-0000-4000-8000-000000000000", name: "Other Org" } + const refused = yield* Effect.flip(set(OTHER_KEY)) + assert.deepStrictEqual( + [refused._tag, refused.message], + [ + VALIDATION, + "This key belongs to another Anthropic organization than the source does. Remove the source first to switch organizations.", + ], + ) + assert.deepStrictEqual(yield* storedKey(testDb), first) + }), + ) + }) + + it.effect("removes the key and its source together, without needing the rollout", () => { + const testDb = createTestDb(trackedDbs) + const world = makeFakeAnthropic() + return run( + testDb, + world, + Effect.gen(function* () { + yield* set(KEY) + world.cloudCost = false + yield* remove + assert.isUndefined(yield* storedKey(testDb)) + assert.isUndefined(yield* source(testDb)) + }), + ) + }) +}) diff --git a/packages/backend/src/services/cost/AnthropicCostConnectionService.ts b/packages/backend/src/services/cost/AnthropicCostConnectionService.ts new file mode 100644 index 0000000000..97d72b7ea1 --- /dev/null +++ b/packages/backend/src/services/cost/AnthropicCostConnectionService.ts @@ -0,0 +1,143 @@ +// The Anthropic Admin API key an org's Anthropic API spend is read with: checked with Anthropic, +// then kept in the shared cost connection store. Anthropic has no admin key limited to cost +// reads, so any Admin API key is accepted and the Cost page warns about its reach. +import { + ANTHROPIC_COST_KEY_MAX_LENGTH, + CloudCostDisabledError, + CostConnectionPersistenceError, + CostConnectionValidationError, + type OrgId, + type UserId, +} from "@maple/domain/http" +import { Clock, Context, Duration, Effect, Layer, Redacted, String as Str } from "effect" +import { FetchHttpClient, HttpClient } from "effect/http" +import { OrganizationFeatureFlagsService } from "@maple/backend/services/org/OrganizationFeatureFlagsService" +import { CostConnectionStore, type CostConnectionStoreError } from "./CostConnectionStore" +import * as AnthropicCost from "./providers/anthropic/anthropic-cost-api" +import { addDays, utcToday } from "./sync-plan" + +const VALIDATE_TIMEOUT = Duration.seconds(20) + +const UNCHECKED = "Could not check this key with Anthropic. Try again." + +const refused = (message: string) => new CostConnectionValidationError({ message }) + +/** Which call of the check failed decides what a refusal means: the key itself, or its access. */ +const toKeyError = + (step: "organization" | "costs") => + ( + error: AnthropicCost.AnthropicCostApiError, + ): CostConnectionValidationError | CostConnectionPersistenceError => { + const rejected = + error.kind === "unauthorized" || error.kind === "forbidden" || error.kind === "bad_request" + if (step === "organization" && rejected) { + return refused("Anthropic rejected this key. Use an Admin API key from the Claude Console.") + } + if (step === "costs" && error.kind === "forbidden") { + return refused("This key cannot read the organization's costs. Use an Admin API key.") + } + return new CostConnectionPersistenceError({ message: UNCHECKED }) + } + +const toPersistenceError = (error: CostConnectionStoreError) => + new CostConnectionPersistenceError({ message: error.message }) + +/** Shaped like an Admin API key: anything else is never sent to Anthropic. */ +const couldBeAdminKey = (key: string): boolean => + key.length <= ANTHROPIC_COST_KEY_MAX_LENGTH && /^sk-ant-admin[\x21-\x7e]+$/.test(key) + +export interface AnthropicCostConnectionServiceApi { + /** Saves the key and answers the Anthropic organization it reads. */ + readonly set: ( + orgId: OrgId, + userId: UserId, + apiKey: string, + ) => Effect.Effect< + { readonly accountId: string; readonly accountLabel: string }, + CostConnectionPersistenceError | CostConnectionValidationError | CloudCostDisabledError + > + readonly remove: (orgId: OrgId) => Effect.Effect +} + +export class AnthropicCostConnectionService extends Context.Service< + AnthropicCostConnectionService, + AnthropicCostConnectionServiceApi +>()("@maple/backend/services/cost/AnthropicCostConnectionService", { + make: Effect.gen(function* () { + const connections = yield* CostConnectionStore + const featureFlags = yield* OrganizationFeatureFlagsService + const httpClient = yield* HttpClient.HttpClient + + /** The key's organization, once the key has proved it reads that organization's cost report. */ + const validateWithAnthropic = Effect.fn("AnthropicCostConnectionService.validateWithAnthropic")( + function* (key: Redacted.Redacted) { + const organization = yield* AnthropicCost.getOrganization(httpClient, key).pipe( + Effect.mapError(toKeyError("organization")), + ) + const now = yield* Clock.currentTimeMillis + const today = utcToday(now) + yield* AnthropicCost.getCostReport(httpClient, key, { + startDay: addDays(today, -1), + endDay: today, + }).pipe(Effect.mapError(toKeyError("costs"))) + return { accountId: organization.id, accountLabel: organization.name } + }, + ) + + const set = Effect.fn("AnthropicCostConnectionService.set")(function* ( + orgId: OrgId, + userId: UserId, + rawKey: string, + ) { + yield* Effect.annotateCurrentSpan({ orgId }) + const flags = yield* featureFlags.flags(orgId) + if (!flags.cloudCost) { + return yield* new CloudCostDisabledError({ + message: "Cloud cost is not enabled for this organization.", + }) + } + const apiKey = Str.trim(rawKey) + if (!couldBeAdminKey(apiKey)) { + return yield* refused("That is not an Anthropic Admin API key. They start with sk-ant-admin.") + } + const secret = Redacted.make(apiKey) + const account = yield* validateWithAnthropic(secret).pipe( + Effect.timeoutOrElse({ + duration: VALIDATE_TIMEOUT, + orElse: () => + Effect.fail( + new CostConnectionPersistenceError({ + message: "Could not reach Anthropic to check this key. Try again.", + }), + ), + }), + ) + const saved = yield* connections + .save({ orgId, userId, provider: "anthropic", secret, ...account }) + .pipe(Effect.mapError(toPersistenceError)) + if (saved === "other_account") { + return yield* refused( + "This key belongs to another Anthropic organization than the source does. Remove the source first to switch organizations.", + ) + } + return account + }) + + const remove = Effect.fn("AnthropicCostConnectionService.remove")(function* (orgId: OrgId) { + yield* Effect.annotateCurrentSpan({ orgId }) + yield* connections.remove(orgId, "anthropic").pipe(Effect.mapError(toPersistenceError)) + }) + + return { set, remove } satisfies AnthropicCostConnectionServiceApi + }), +}) { + static readonly layer = Layer.effect(this, this.make).pipe( + Layer.provide( + Layer.mergeAll( + FetchHttpClient.layer, + OrganizationFeatureFlagsService.layer, + CostConnectionStore.layer, + ), + ), + ) +} diff --git a/packages/backend/src/services/cost/CostSyncService.test.ts b/packages/backend/src/services/cost/CostSyncService.test.ts index 09cf5b4cb7..7a8554e772 100644 --- a/packages/backend/src/services/cost/CostSyncService.test.ts +++ b/packages/backend/src/services/cost/CostSyncService.test.ts @@ -28,7 +28,9 @@ import { WarehouseQueryService } from "@maple/backend/services/warehouse/Warehou import { type CostProviderAdapter, CostProviderError, type CostPullRow } from "./CostProvider" import { CostSyncService } from "./CostSyncService" import { AwsCostProvider } from "./providers/aws/AwsCostProvider" +import { AnthropicCostProvider } from "./providers/anthropic/AnthropicCostProvider" import { CloudflareCostProvider } from "./providers/cloudflare/CloudflareCostProvider" +import { OpenAiCostProvider } from "./providers/openai/OpenAiCostProvider" import { insertInstallation } from "./providers/github/fake-github" import { GithubCostProvider } from "./providers/github/GithubCostProvider" import { OpenRouterCostProvider } from "./providers/openrouter/OpenRouterCostProvider" @@ -283,6 +285,8 @@ const harness = (options: FakeProviderOptions = {}) => { Layer.succeed(AwsCostProvider, adapterOf("aws")), Layer.succeed(OpenRouterCostProvider, adapterOf("openrouter")), Layer.succeed(CloudflareCostProvider, adapterOf("cloudflare")), + Layer.succeed(AnthropicCostProvider, adapterOf("anthropic")), + Layer.succeed(OpenAiCostProvider, adapterOf("openai")), Layer.succeed(GithubCostProvider, adapterOf("github")), Layer.succeed(VercelCostProvider, vercelAdapter), Layer.succeed(PlanetScaleCostProvider, planetScaleAdapter), diff --git a/packages/backend/src/services/cost/CostSyncService.ts b/packages/backend/src/services/cost/CostSyncService.ts index 80e3d0955f..53feb6346d 100644 --- a/packages/backend/src/services/cost/CostSyncService.ts +++ b/packages/backend/src/services/cost/CostSyncService.ts @@ -36,8 +36,10 @@ import { reconcileOpenRouterCostSources } from "./openrouter-cost-sources" import { reconcilePlanetScaleCostSources } from "./planetscale-cost-sources" import { reconcileVercelCostSources } from "./vercel-cost-sources" import { AwsCostProvider } from "./providers/aws/AwsCostProvider" +import { AnthropicCostProvider } from "./providers/anthropic/AnthropicCostProvider" import { CloudflareCostProvider } from "./providers/cloudflare/CloudflareCostProvider" import { GithubCostProvider } from "./providers/github/GithubCostProvider" +import { OpenAiCostProvider } from "./providers/openai/OpenAiCostProvider" import { OpenRouterCostProvider } from "./providers/openrouter/OpenRouterCostProvider" import { PlanetScaleCostProvider } from "./providers/planetscale/PlanetScaleCostProvider" import { VercelCostProvider } from "./providers/vercel/VercelCostProvider" @@ -178,6 +180,8 @@ export class CostSyncService extends Context.Service const dbExecute = makeDbExecute( database, @@ -737,6 +741,8 @@ export class CostSyncService extends Context.Service cleanupTestDbs(trackedDbs)) + +const ORG = Schema.decodeSync(OrgId)("org_cost") +const USER = Schema.decodeSync(UserId)("user_cost") +const KEY = "sk-admin-0123456789abcdefghijABCDEFGHIJ0123456789" +const OTHER_KEY = "sk-admin-9876543210zyxwvutsrqZYXWVUTSRQ9876543210" +const NOW = DateTime.toEpochMillis(DateTime.makeUnsafe("2026-11-11T06:00:00Z")) +const SPEND = [{ day: "2026-10-20", value: 1.5, lineItem: "gpt-4o, input" }] +const VALIDATION = "@maple/http/cost-connections/CostConnectionValidationError" +const PERSISTENCE = "@maple/http/cost-connections/CostConnectionPersistenceError" +const unix = (day: string) => Date.parse(`${day}T00:00:00Z`) / 1000 + +const run = ( + testDb: TestDb, + world: FakeOpenAi, + effect: Effect.Effect, +) => + Effect.andThen(TestClock.setTime(NOW), effect).pipe( + Effect.provide( + Layer.effect(OpenAiCostConnectionService, OpenAiCostConnectionService.make).pipe( + Layer.provide(fakeOpenAiDeps(testDb, world)), + ), + ), + ) + +const set = (key: string) => OpenAiCostConnectionService.use((service) => service.set(ORG, USER, key)) +const remove = OpenAiCostConnectionService.use((service) => service.remove(ORG)) + +interface Stored { + readonly id: string + readonly provider: string + readonly secret_ciphertext: string + readonly account_id: string + readonly account_label: string +} + +const storedKey = (testDb: TestDb) => + Effect.promise(() => + queryFirstRow( + testDb, + "SELECT id, provider, secret_ciphertext, account_id, account_label FROM cost_connections WHERE org_id = 'org_cost'", + ), + ) + +const source = (testDb: TestDb) => + Effect.promise(() => + queryFirstRow<{ connection_ref: string; account_id: string; enabled: boolean }>( + testDb, + "SELECT connection_ref, account_id, enabled FROM cost_sources WHERE org_id = 'org_cost' AND provider = 'openai'", + ), + ) + +describe("OpenAiCostConnectionService", () => { + it.effect("checks a key against the last 180 days of costs, seals it and makes the cost source", () => { + const testDb = createTestDb(trackedDbs) + const world = makeFakeOpenAi({ costs: SPEND }) + return run( + testDb, + world, + Effect.gen(function* () { + const saved = yield* set(` ${KEY} `) + // The organization is only named on the key's own cost results. + assert.deepStrictEqual(saved, { accountId: FAKE_OPENAI_ORG, accountLabel: FAKE_OPENAI_ORG }) + assert.deepStrictEqual(world.urls, [ + `/v1/organization/costs?start_time=${unix("2026-05-16")}&end_time=${unix("2026-11-12")}&bucket_width=1d&limit=180`, + ]) + assert.deepStrictEqual(world.authorizations, [`Bearer ${KEY}`]) + + const stored = yield* storedKey(testDb) + assert.isFalse(Str.includes(KEY)(stored?.secret_ciphertext ?? KEY)) + assert.deepStrictEqual( + [stored?.provider, stored?.account_id, stored?.account_label], + ["openai", FAKE_OPENAI_ORG, FAKE_OPENAI_ORG], + ) + const made = yield* source(testDb) + assert.deepStrictEqual( + [made?.connection_ref, made?.account_id, made?.enabled], + [stored?.id, FAKE_OPENAI_ORG, true], + ) + }), + ) + }) + + it.effect( + "never sends text that is not an Admin API key, nor any key of an org outside the rollout", + () => { + const testDb = createTestDb(trackedDbs) + const world = makeFakeOpenAi({ costs: SPEND }) + return run( + testDb, + world, + Effect.gen(function* () { + const refusals = yield* Effect.forEach( + [ + "", + " ", + // A project key reads no organization costs. + "sk-proj-0123456789abcdef", + "sk-admin-", + "sk-admin-two words", + `sk-admin-${Str.repeat(600)("a")}`, + "sk-admin-line\nbreak", + ], + (text) => Effect.map(Effect.flip(set(text)), (error) => [error._tag, error.message]), + ) + assert.deepStrictEqual( + refusals, + Arr.replicate( + [VALIDATION, "That is not an OpenAI Admin API key. They start with sk-admin-."], + 7, + ), + ) + + world.cloudCost = false + const outside = yield* Effect.flip(set(KEY)) + assert.strictEqual(outside._tag, "@maple/http/errors/CloudCostDisabledError") + assert.deepStrictEqual(world.urls, []) + }), + ) + }, + ) + + it.effect("refuses a key OpenAI rejects or that cannot read costs, and stores nothing", () => { + const testDb = createTestDb(trackedDbs) + const world = makeFakeOpenAi({ costs: SPEND }) + const refusedWith = (status: number) => + Effect.gen(function* () { + world.status = status + const error = yield* Effect.flip(set(KEY)) + return [error._tag, error.message] + }) + const rejected = "OpenAI rejected this key. Use an Admin API key from the organization's settings." + return run( + testDb, + world, + Effect.gen(function* () { + assert.deepStrictEqual(yield* refusedWith(401), [VALIDATION, rejected]) + assert.deepStrictEqual(yield* refusedWith(400), [VALIDATION, rejected]) + assert.deepStrictEqual(yield* refusedWith(403), [ + VALIDATION, + "This key cannot read the organization's costs. Use an Admin API key.", + ]) + // An outage or a limit says nothing about the key: it is a retry, not a refusal. + assert.deepStrictEqual(yield* refusedWith(503), [ + PERSISTENCE, + "Could not check this key with OpenAI. Try again.", + ]) + assert.strictEqual((yield* refusedWith(429))[0], PERSISTENCE) + assert.isUndefined(yield* storedKey(testDb)) + assert.isUndefined(yield* source(testDb)) + }), + ) + }) + + it.effect("refuses a key whose organization has spent nothing, since no result names it", () => { + const testDb = createTestDb(trackedDbs) + const world = makeFakeOpenAi() + return run( + testDb, + world, + Effect.gen(function* () { + const error = yield* Effect.flip(set(KEY)) + assert.deepStrictEqual( + [error._tag, error.message], + [ + VALIDATION, + "This OpenAI organization has no API spend in the last 180 days, so there is nothing to read yet.", + ], + ) + // Spend older than the 180 days read does not count. + world.costs = [{ day: "2026-05-15", value: 1 }] + assert.include((yield* Effect.flip(set(KEY))).message, "no API spend") + assert.isUndefined(yield* storedKey(testDb)) + }), + ) + }) + + it.effect( + "refuses a key whose costs name two organizations, since either could be the one it reads", + () => { + const testDb = createTestDb(trackedDbs) + const world = makeFakeOpenAi({ + costs: [ + ...SPEND, + { day: "2026-10-21", value: 2, organizationId: "org-SecondOrganization0000" }, + ], + }) + return run( + testDb, + world, + Effect.gen(function* () { + const error = yield* Effect.flip(set(KEY)) + assert.strictEqual(error._tag, VALIDATION) + assert.include(error.message, "more than one organization") + assert.isUndefined(yield* storedKey(testDb)) + }), + ) + }, + ) + + it.effect( + "keeps the connection when the organization's key is replaced, and refuses another organization's", + () => { + const testDb = createTestDb(trackedDbs) + const world = makeFakeOpenAi({ costs: SPEND }) + return run( + testDb, + world, + Effect.gen(function* () { + yield* set(KEY) + const first = yield* storedKey(testDb) + const firstSource = yield* source(testDb) + + yield* set(OTHER_KEY) + const replaced = yield* storedKey(testDb) + assert.strictEqual(replaced?.id, first?.id) + assert.notStrictEqual(replaced?.secret_ciphertext, first?.secret_ciphertext) + assert.deepStrictEqual(yield* source(testDb), firstSource) + + // Another organization's spend would overwrite the days the source already holds. + world.organizationId = "org-SecondOrganization0000" + const refused = yield* Effect.flip(set(KEY)) + assert.deepStrictEqual( + [refused._tag, refused.message], + [ + VALIDATION, + "This key belongs to another OpenAI organization than the source does. Remove the source first to switch organizations.", + ], + ) + assert.deepStrictEqual(yield* storedKey(testDb), replaced) + }), + ) + }, + ) + + it.effect("removes the key and its source together, without needing the rollout", () => { + const testDb = createTestDb(trackedDbs) + const world = makeFakeOpenAi({ costs: SPEND }) + return run( + testDb, + world, + Effect.gen(function* () { + yield* set(KEY) + world.cloudCost = false + yield* remove + assert.isUndefined(yield* storedKey(testDb)) + assert.isUndefined(yield* source(testDb)) + }), + ) + }) +}) diff --git a/packages/backend/src/services/cost/OpenAiCostConnectionService.ts b/packages/backend/src/services/cost/OpenAiCostConnectionService.ts new file mode 100644 index 0000000000..1f9d38e421 --- /dev/null +++ b/packages/backend/src/services/cost/OpenAiCostConnectionService.ts @@ -0,0 +1,172 @@ +// The OpenAI Admin API key an org's OpenAI spend is read with: checked with OpenAI, then kept in the +// shared cost connection store. Admin keys cannot be scoped to costs, which the Cost page says. +import { + CloudCostDisabledError, + CostConnectionPersistenceError, + CostConnectionValidationError, + OPENAI_COST_KEY_MAX_LENGTH, + type OrgId, + type UserId, +} from "@maple/domain/http" +import { Clock, Context, Duration, Effect, Layer, Redacted, Array as Arr, String as Str } from "effect" +import { FetchHttpClient, HttpClient } from "effect/http" +import { OrganizationFeatureFlagsService } from "@maple/backend/services/org/OrganizationFeatureFlagsService" +import { CostConnectionStore, type CostConnectionStoreError } from "./CostConnectionStore" +import * as OpenAiCosts from "./providers/openai/openai-costs-api" +import { MAX_PULL_PAGES, unixDayStart } from "./providers/openai/OpenAiCostProvider" +import { addDays, utcToday } from "./sync-plan" + +const VALIDATE_TIMEOUT = Duration.seconds(20) +/** Days of spend the check reads for the key's organization: one page of daily buckets. */ +const VALIDATE_DAYS = 180 + +const refused = (message: string) => new CostConnectionValidationError({ message }) + +const toKeyError = ( + error: OpenAiCosts.OpenAiCostsError, +): CostConnectionValidationError | CostConnectionPersistenceError => { + // OpenAI answers 400 for text that is not shaped like a key it knows. + if (error.kind === "unauthorized" || error.kind === "bad_request") { + return refused("OpenAI rejected this key. Use an Admin API key from the organization's settings.") + } + if (error.kind === "forbidden") { + return refused("This key cannot read the organization's costs. Use an Admin API key.") + } + return new CostConnectionPersistenceError({ message: "Could not check this key with OpenAI. Try again." }) +} + +const toPersistenceError = (error: CostConnectionStoreError) => + new CostConnectionPersistenceError({ message: error.message }) + +/** An Admin API key: `sk-admin-` and printable text, unbroken. Nothing else is ever sent as one. */ +const couldBeKey = (key: string): boolean => + key.length <= OPENAI_COST_KEY_MAX_LENGTH && /^sk-admin-[\x21-\x7e]+$/.test(key) + +export interface OpenAiCostConnectionServiceApi { + /** Saves the key and answers the OpenAI organization it reads. */ + readonly set: ( + orgId: OrgId, + userId: UserId, + apiKey: string, + ) => Effect.Effect< + { readonly accountId: string; readonly accountLabel: string }, + CostConnectionPersistenceError | CostConnectionValidationError | CloudCostDisabledError + > + readonly remove: (orgId: OrgId) => Effect.Effect +} + +export class OpenAiCostConnectionService extends Context.Service< + OpenAiCostConnectionService, + OpenAiCostConnectionServiceApi +>()("@maple/backend/services/cost/OpenAiCostConnectionService", { + make: Effect.gen(function* () { + const connections = yield* CostConnectionStore + const featureFlags = yield* OrganizationFeatureFlagsService + const httpClient = yield* HttpClient.HttpClient + + /** + * The organization the key reads. No OpenAI endpoint names it, so it comes off the key's own + * cost results: a key whose organization has spent nothing has none to show yet. + */ + const validateWithOpenAi = Effect.fn("OpenAiCostConnectionService.validateWithOpenAi")(function* ( + key: Redacted.Redacted, + ) { + const now = yield* Clock.currentTimeMillis + const tomorrow = addDays(utcToday(now), 1) + const listing = yield* OpenAiCosts.listCosts( + httpClient, + key, + { + startTime: unixDayStart(addDays(tomorrow, -VALIDATE_DAYS)), + endTime: unixDayStart(tomorrow), + groupBy: [], + }, + MAX_PULL_PAGES, + ).pipe(Effect.mapError(toKeyError)) + const organizations = Arr.dedupe( + Arr.flatMap(listing.buckets, (bucket) => + Arr.flatMap(bucket.results, (result) => + result.organization_id === undefined || + result.organization_id === null || + result.organization_id === "" + ? [] + : [result.organization_id], + ), + ), + ) + yield* Effect.annotateCurrentSpan({ + "maple.cost.openai.pages": listing.pages, + "maple.cost.openai.organizations": organizations.length, + }) + const [organization] = organizations + if (organization === undefined) { + return yield* refused( + "This OpenAI organization has no API spend in the last 180 days, so there is nothing to read yet.", + ) + } + // Results of two organizations leave no telling which one the key belongs to. + if (organizations.length > 1) { + return yield* refused( + "OpenAI reports costs of more than one organization for this key, so Maple cannot tell which one it reads.", + ) + } + return { accountId: organization, accountLabel: organization } + }) + + const set = Effect.fn("OpenAiCostConnectionService.set")(function* ( + orgId: OrgId, + userId: UserId, + rawKey: string, + ) { + yield* Effect.annotateCurrentSpan({ orgId }) + const flags = yield* featureFlags.flags(orgId) + if (!flags.cloudCost) { + return yield* new CloudCostDisabledError({ + message: "Cloud cost is not enabled for this organization.", + }) + } + const apiKey = Str.trim(rawKey) + if (!couldBeKey(apiKey)) { + return yield* refused("That is not an OpenAI Admin API key. They start with sk-admin-.") + } + const secret = Redacted.make(apiKey) + const account = yield* validateWithOpenAi(secret).pipe( + Effect.timeoutOrElse({ + duration: VALIDATE_TIMEOUT, + orElse: () => + Effect.fail( + new CostConnectionPersistenceError({ + message: "Could not reach OpenAI to check this key. Try again.", + }), + ), + }), + ) + const saved = yield* connections + .save({ orgId, userId, provider: "openai", secret, ...account }) + .pipe(Effect.mapError(toPersistenceError)) + if (saved === "other_account") { + return yield* refused( + "This key belongs to another OpenAI organization than the source does. Remove the source first to switch organizations.", + ) + } + return account + }) + + const remove = Effect.fn("OpenAiCostConnectionService.remove")(function* (orgId: OrgId) { + yield* Effect.annotateCurrentSpan({ orgId }) + yield* connections.remove(orgId, "openai").pipe(Effect.mapError(toPersistenceError)) + }) + + return { set, remove } satisfies OpenAiCostConnectionServiceApi + }), +}) { + static readonly layer = Layer.effect(this, this.make).pipe( + Layer.provide( + Layer.mergeAll( + FetchHttpClient.layer, + OrganizationFeatureFlagsService.layer, + CostConnectionStore.layer, + ), + ), + ) +} diff --git a/packages/backend/src/services/cost/cost-connection-sources.ts b/packages/backend/src/services/cost/cost-connection-sources.ts index 2070cc10db..3d1dc25561 100644 --- a/packages/backend/src/services/cost/cost-connection-sources.ts +++ b/packages/backend/src/services/cost/cost-connection-sources.ts @@ -11,7 +11,11 @@ import { Array as Arr, Effect } from "effect" import { randomUUID } from "node:crypto" /** The providers whose credential lives in `cost_connections`: one added here needs no table, slot or reconcile of its own. */ -export const COST_CONNECTION_PROVIDERS = ["cloudflare"] as const satisfies ReadonlyArray +export const COST_CONNECTION_PROVIDERS = [ + "cloudflare", + "anthropic", + "openai", +] as const satisfies ReadonlyArray export type CostConnectionProvider = (typeof COST_CONNECTION_PROVIDERS)[number] /** Whether `provider` keeps its credential in `cost_connections`. */ diff --git a/packages/backend/src/services/cost/providers/anthropic/AnthropicCostProvider.test.ts b/packages/backend/src/services/cost/providers/anthropic/AnthropicCostProvider.test.ts new file mode 100644 index 0000000000..5800ef34be --- /dev/null +++ b/packages/backend/src/services/cost/providers/anthropic/AnthropicCostProvider.test.ts @@ -0,0 +1,187 @@ +import { afterEach, assert, describe, it } from "@effect/vitest" +import { OrgId, UserId } from "@maple/domain/http" +import { DateTime, Effect, Layer, Schema, Array as Arr } from "effect" +import { TestClock } from "effect/testing" +import { cleanupTestDbs, createTestDb, queryFirstRow, type TestDb } from "@maple/backend/testing/test-pglite" +import { AnthropicCostConnectionService } from "../../AnthropicCostConnectionService" +import type { CostProviderError } from "../../CostProvider" +import { addDays, daysFrom } from "../../sync-plan" +import { AnthropicCostProvider } from "./AnthropicCostProvider" +import { + costBucket, + costResult, + FAKE_ORGANIZATION, + type FakeAnthropic, + fakeAnthropicDeps, + makeFakeAnthropic, +} from "./fake-anthropic" + +const trackedDbs: TestDb[] = [] +afterEach(() => cleanupTestDbs(trackedDbs)) + +const ORG = Schema.decodeSync(OrgId)("org_cost") +const OTHER_ORG = Schema.decodeSync(OrgId)("org_other") +const USER = Schema.decodeSync(UserId)("user_cost") +const KEY = "sk-ant-admin01-0123456789abcdefghijABCDEFGHIJ0123456789-AbCdEfAA" +/** 40 days: two pages of the report. */ +const WINDOW = { startDay: "2026-10-02", endDay: "2026-11-10" } +/** The pull runs the morning after the window's last day. */ +const NOW = DateTime.toEpochMillis(DateTime.makeUnsafe("2026-11-11T06:00:00Z")) + +const run = ( + testDb: TestDb, + world: FakeAnthropic, + effect: Effect.Effect, +) => + effect.pipe( + Effect.provide( + Layer.mergeAll( + Layer.effect(AnthropicCostProvider, AnthropicCostProvider.make), + Layer.effect(AnthropicCostConnectionService, AnthropicCostConnectionService.make), + ).pipe(Layer.provide(fakeAnthropicDeps(testDb, world))), + ), + ) + +/** Saves `KEY` for `ORG` and answers the connection's id, with the fake's call log emptied. */ +const saveKey = (testDb: TestDb, world: FakeAnthropic) => + Effect.gen(function* () { + yield* TestClock.setTime(NOW) + yield* AnthropicCostConnectionService.use((service) => service.set(ORG, USER, KEY)) + const row = yield* Effect.promise(() => + queryFirstRow<{ id: string }>(testDb, "SELECT id FROM cost_connections"), + ) + world.urls.length = 0 + world.headers.length = 0 + return row?.id ?? "" + }) + +const pull = (orgId: OrgId, connectionRef: string, window = WINDOW) => + AnthropicCostProvider.use((provider) => provider.pull({ orgId, connectionRef }, window)) + +const reasonOf = (error: CostProviderError) => [error.reason, error.requests, error.provider] + +/** The cursor each request of the log asked with, `null` for the first page. */ +const pagesAsked = (world: FakeAnthropic) => + Arr.map(world.urls, (url) => new URL(`https://api.anthropic.com${url}`).searchParams.get("page")) + +/** A bucket for every day of the window, as the real report has, with spend on a few. */ +const everyDay = (spend: ReadonlyMap) => + Arr.map(daysFrom(addDays(WINDOW.startDay, -3), 46), (day) => { + const amount = spend.get(day) + return costBucket(day, amount === undefined ? [] : [costResult(amount)]) + }) + +describe("AnthropicCostProvider", () => { + it.effect("follows the report's pages and keeps the window's days, in dollars", () => { + const testDb = createTestDb(trackedDbs) + const world = makeFakeAnthropic({ + buckets: everyDay( + new Map([ + ["2026-10-01", "999"], + ["2026-10-02", "150"], + ["2026-11-02", "250"], + ["2026-11-10", "12.5"], + ]), + ), + }) + return run( + testDb, + world, + Effect.gen(function* () { + const connectionRef = yield* saveKey(testDb, world) + const pulled = yield* pull(ORG, connectionRef) + // 40 days at 31 a page: the second page is asked for with the first one's cursor. + assert.deepStrictEqual(pagesAsked(world), [null, "page_31"]) + assert.deepStrictEqual(world.headers, Arr.replicate([KEY, "2023-06-01"], 2)) + assert.strictEqual(pulled.requests, 0) + assert.deepStrictEqual( + Arr.map(pulled.rows, (row) => [ + row.day, + row.cloudService, + row.billedCost, + row.accountId, + row.estimated, + ]), + [ + ["2026-10-02", "anthropic/claude-opus-5-5", 1.5, FAKE_ORGANIZATION.id, false], + ["2026-11-02", "anthropic/claude-opus-5-5", 2.5, FAKE_ORGANIZATION.id, false], + ["2026-11-10", "anthropic/claude-opus-5-5", 0.125, FAKE_ORGANIZATION.id, true], + ], + ) + }), + ) + }) + + it.effect("fails as too large rather than read a report that never ends", () => { + const testDb = createTestDb(trackedDbs) + const world = makeFakeAnthropic({ buckets: everyDay(new Map()), endless: true }) + return run( + testDb, + world, + Effect.gen(function* () { + const connectionRef = yield* saveKey(testDb, world) + const error = yield* Effect.flip(pull(ORG, connectionRef)) + assert.deepStrictEqual(reasonOf(error), ["too_large", 0, "anthropic"]) + assert.strictEqual(world.urls.length, 60) + }), + ) + }) + + it.effect("asks for a new key when Anthropic rejects or narrows the saved one", () => { + const testDb = createTestDb(trackedDbs) + const world = makeFakeAnthropic() + return run( + testDb, + world, + Effect.gen(function* () { + const connectionRef = yield* saveKey(testDb, world) + world.costStatus = 401 + const rejected = yield* Effect.flip(pull(ORG, connectionRef)) + assert.deepStrictEqual(reasonOf(rejected), ["auth", 0, "anthropic"]) + assert.strictEqual( + rejected.message, + "Anthropic rejected the saved key. Replace the key to resume the sync.", + ) + world.costStatus = 403 + const narrowed = yield* Effect.flip(pull(ORG, connectionRef)) + assert.deepStrictEqual(reasonOf(narrowed), ["not_ready", 0, "anthropic"]) + world.costStatus = 429 + assert.strictEqual((yield* Effect.flip(pull(ORG, connectionRef))).reason, "rate_limited") + world.costStatus = 503 + assert.strictEqual((yield* Effect.flip(pull(ORG, connectionRef))).reason, "upstream") + }), + ) + }) + + it.effect("fails an unreadable answer or amount instead of storing a partial day", () => { + const testDb = createTestDb(trackedDbs) + const world = makeFakeAnthropic() + return run( + testDb, + world, + Effect.gen(function* () { + const connectionRef = yield* saveKey(testDb, world) + world.buckets = "maintenance" + assert.strictEqual((yield* Effect.flip(pull(ORG, connectionRef))).reason, "invalid_response") + world.buckets = [costBucket("2026-11-03", [costResult("100"), costResult("n/a")])] + assert.strictEqual((yield* Effect.flip(pull(ORG, connectionRef))).reason, "invalid_response") + }), + ) + }) + + it.effect("reads nothing for a source with no key, or another org's", () => { + const testDb = createTestDb(trackedDbs) + const world = makeFakeAnthropic() + return run( + testDb, + world, + Effect.gen(function* () { + const connectionRef = yield* saveKey(testDb, world) + const foreign = yield* Effect.flip(pull(OTHER_ORG, connectionRef)) + assert.deepStrictEqual(reasonOf(foreign), ["auth", 0, "anthropic"]) + assert.strictEqual(foreign.message, "No Anthropic key is saved for this source.") + assert.deepStrictEqual(world.urls, []) + }), + ) + }) +}) diff --git a/packages/backend/src/services/cost/providers/anthropic/AnthropicCostProvider.ts b/packages/backend/src/services/cost/providers/anthropic/AnthropicCostProvider.ts new file mode 100644 index 0000000000..b37787842b --- /dev/null +++ b/packages/backend/src/services/cost/providers/anthropic/AnthropicCostProvider.ts @@ -0,0 +1,130 @@ +/** + * Anthropic as a cost provider: an organization's daily API spend by model or tool, cost line and + * workspace, read from the cost report with the Admin API key the org saved. + */ +import { Clock, Context, Effect, Layer, Option, Redacted, Result } from "effect" +import { FetchHttpClient, HttpClient } from "effect/http" +import { + type CostProviderAdapter, + CostProviderError, + type CostPull, + type CostPullSource, + type CostPullWindow, +} from "../../CostProvider" +import { CostConnectionStore, loadFailureOf } from "../../CostConnectionStore" +import { addDays, utcToday } from "../../sync-plan" +import * as AnthropicCost from "./anthropic-cost-api" +import { toCostRows } from "./normalize" + +/** Pages one pull reads at most: years of days, far beyond any window the sync asks for. */ +const MAX_PAGES = 60 +/** The newest days Anthropic may still be counting. */ +const OPEN_DAYS = 2 + +type Reason = CostProviderError["reason"] + +// Anthropic does not bill for Admin API requests, so no failure counts any. +const failed = (reason: Reason, message: string) => + new CostProviderError({ message, provider: "anthropic", reason, requests: 0 }) + +const UNREADABLE = "Anthropic's cost report could not be read." + +/** Anthropic's failures, as what the sync does next and the Cost page shows. */ +const toCostProviderError = (error: AnthropicCost.AnthropicCostApiError): CostProviderError => { + switch (error.kind) { + case "unauthorized": + return failed("auth", "Anthropic rejected the saved key. Replace the key to resume the sync.") + // The key read the costs when it was saved: that access was taken away since. + case "forbidden": + return failed( + "not_ready", + "Anthropic no longer lets the saved key read the organization's costs. Replace it with an Admin API key.", + ) + case "invalid_response": + return failed("invalid_response", UNREADABLE) + case "rate_limited": + return failed("rate_limited", "Anthropic is rate limiting cost report requests.") + // A 400 is a request Maple wrote wrong, which a retry does not fix but a deploy does. + default: + return failed("upstream", "Anthropic did not answer the cost report request.") + } +} + +interface Report { + readonly buckets: ReadonlyArray + readonly pages: number +} + +export class AnthropicCostProvider extends Context.Service()( + "@maple/backend/services/cost/AnthropicCostProvider", + { + make: Effect.gen(function* () { + const connections = yield* CostConnectionStore + const httpClient = yield* HttpClient.HttpClient + + /** Every bucket of the window, one page at a time, following `next_page` while there is more. */ + const readReport = ( + key: Redacted.Redacted, + window: CostPullWindow, + page: string | undefined, + read: Report, + ): Effect.Effect => + Effect.gen(function* () { + if (read.pages >= MAX_PAGES) { + return yield* failed( + "too_large", + "Anthropic's cost report for this window spans too many pages.", + ) + } + const body = yield* AnthropicCost.getCostReport(httpClient, key, window, page).pipe( + Effect.mapError(toCostProviderError), + ) + const next = { buckets: [...read.buckets, ...body.data], pages: read.pages + 1 } + if (!body.has_more) return next + const cursor = body.next_page ?? null + // More pages and no way to ask for them: what was read is only part of the window. + if (cursor === null) return yield* failed("invalid_response", UNREADABLE) + return yield* readReport(key, window, cursor, next) + }) + + const pull = Effect.fn("AnthropicCostProvider.pull")( + function* (source: CostPullSource, window: CostPullWindow) { + yield* Effect.annotateCurrentSpan({ + orgId: source.orgId, + "maple.cost.provider": "anthropic", + "maple.cost.start_day": window.startDay, + "maple.cost.end_day": window.endDay, + }) + const credential = yield* connections + .load("anthropic", source) + .pipe(Effect.mapError(loadFailureOf("anthropic"))) + if (Option.isNone(credential)) { + return yield* failed("auth", "No Anthropic key is saved for this source.") + } + const { secret, accountId } = credential.value + const report = yield* readReport(secret, window, undefined, { buckets: [], pages: 0 }) + yield* Effect.annotateCurrentSpan({ "maple.cost.anthropic.pages": report.pages }) + const now = yield* Clock.currentTimeMillis + const openDay = addDays(utcToday(now), -OPEN_DAYS) + const normalized = toCostRows(report.buckets, accountId, window, openDay) + if (Result.isFailure(normalized)) { + yield* Effect.annotateCurrentSpan({ "maple.cost.anthropic.error": "bad_amount" }) + return yield* failed("invalid_response", UNREADABLE) + } + const rows = normalized.success + yield* Effect.annotateCurrentSpan({ "maple.cost.rows": rows.length }) + return { rows, requests: 0 } satisfies CostPull + }, + Effect.tapError((error) => + Effect.annotateCurrentSpan({ "maple.cost.error_reason": error.reason }), + ), + ) + + return { provider: "anthropic", pull } satisfies CostProviderAdapter + }), + }, +) { + static readonly layer = Layer.effect(this, this.make).pipe( + Layer.provide(Layer.mergeAll(FetchHttpClient.layer, CostConnectionStore.layer)), + ) +} diff --git a/packages/backend/src/services/cost/providers/anthropic/anthropic-cost-api.ts b/packages/backend/src/services/cost/providers/anthropic/anthropic-cost-api.ts new file mode 100644 index 0000000000..4b9912723a --- /dev/null +++ b/packages/backend/src/services/cost/providers/anthropic/anthropic-cost-api.ts @@ -0,0 +1,162 @@ +// The slice of Anthropic's Admin API the cost source uses: the organization a key belongs to, and +// one page of its daily cost report. Every failure is one `AnthropicCostApiError` whose `kind` +// says what the caller does next. +import { Duration, Effect, Redacted, Schema } from "effect" +import { FetchHttpClient, HttpClient, HttpClientRequest } from "effect/http" +import type { CostPullWindow } from "../../CostProvider" +import { addDays } from "../../sync-plan" + +export const BASE_URL = "https://api.anthropic.com" +const API_VERSION = "2023-06-01" +const REQUEST_TIMEOUT = Duration.seconds(30) +/** Daily buckets one page holds: the most the cost report serves. */ +export const BUCKETS_PER_PAGE = 31 + +export class AnthropicCostApiError extends Schema.TaggedError()( + "@maple/backend/cost/AnthropicCostApiError", + { + message: Schema.String, + kind: Schema.Literals([ + "unauthorized", + "forbidden", + "bad_request", + "rate_limited", + "invalid_response", + "upstream", + ]), + status: Schema.optionalKey(Schema.Number), + cause: Schema.optionalKey(Schema.Defect()), + }, +) {} + +const errorKind = (status: number): AnthropicCostApiError["kind"] => + status === 401 + ? "unauthorized" + : status === 403 + ? "forbidden" + : status === 400 + ? "bad_request" + : status === 429 + ? "rate_limited" + : "upstream" + +const call = ( + httpClient: HttpClient.HttpClient, + key: Redacted.Redacted, + request: HttpClientRequest.HttpClientRequest, + schema: S, +) => { + const failed = (kind: AnthropicCostApiError["kind"], what: string) => (cause: unknown) => + Effect.fail(new AnthropicCostApiError({ message: `Anthropic API ${what}`, kind, cause })) + return Effect.gen(function* () { + const response = yield* httpClient.execute( + HttpClientRequest.setHeaders(request, { + "x-api-key": Redacted.value(key), + "anthropic-version": API_VERSION, + }), + ) + if (response.status >= 300) { + return yield* new AnthropicCostApiError({ + message: `Anthropic API answered ${response.status}`, + kind: errorKind(response.status), + status: response.status, + }) + } + // A 2xx that is not JSON is an answer Maple cannot read, not a failed request. + const body = yield* response.json.pipe( + Effect.catchTag( + "HttpClientError", + failed("invalid_response", "answered with a body that is not JSON"), + ), + ) + return yield* Schema.decodeEffect(schema)(body) + }).pipe( + Effect.timeout(REQUEST_TIMEOUT), + Effect.catchTags({ + HttpClientError: failed("upstream", "request failed"), + TimeoutError: failed("upstream", "request timed out"), + SchemaError: failed("invalid_response", "answered with a body Maple cannot read"), + }), + // Callers show their own message; which failure it was stays on the span. + Effect.tapError((error) => + Effect.annotateCurrentSpan({ + "maple.cost.anthropic.error": error.kind, + "maple.cost.anthropic.status": error.status ?? 0, + }), + ), + // A redirect is not Anthropic answering, so the key is never followed to another host. + Effect.provideService(FetchHttpClient.RequestInit, { redirect: "manual" }), + Effect.annotateSpans("peer.service", "anthropic"), + ) +} + +const Organization = Schema.Struct({ id: Schema.String, name: Schema.String }) + +/** The organization the admin key belongs to. */ +export const getOrganization = (httpClient: HttpClient.HttpClient, key: Redacted.Redacted) => + call(httpClient, key, HttpClientRequest.get(`${BASE_URL}/v1/organizations/me`), Organization) + +const nullable = Schema.optionalKey(Schema.NullOr(Schema.String)) + +/** + * One cost line of a day. `amount` is a decimal in cents. The enum-like fields stay plain strings: + * a value Anthropic adds later must not fail the whole report. + */ +export const CostResult = Schema.Struct({ + amount: Schema.String, + currency: nullable, + cost_type: nullable, + description: nullable, + model: nullable, + token_type: nullable, + service_tier: nullable, + context_window: nullable, + speed: nullable, + inference_geo: nullable, + workspace_id: nullable, +}) +export type CostResult = typeof CostResult.Type + +const isoDay = Schema.String.check( + Schema.isPattern(/^\d{4}-\d{2}-\d{2}/, { description: "Expected to start with YYYY-MM-DD" }), +) + +/** One UTC day of the report, with every cost line of that day. */ +export const CostBucket = Schema.Struct({ + starting_at: isoDay, + ending_at: Schema.String, + results: Schema.Array(CostResult), +}) +export type CostBucket = typeof CostBucket.Type + +const CostReportPage = Schema.Struct({ + data: Schema.Array(CostBucket), + has_more: Schema.Boolean, + next_page: Schema.optionalKey(Schema.NullOr(Schema.String)), +}) +export type CostReportPage = typeof CostReportPage.Type + +/** + * One page of the window's daily buckets by workspace and cost line, oldest first. The range ends + * at the start of the day after the window: `ending_at` is exclusive. `page` is the previous + * page's `next_page`. + */ +export const getCostReport = ( + httpClient: HttpClient.HttpClient, + key: Redacted.Redacted, + window: CostPullWindow, + page?: string, +) => + call( + httpClient, + key, + HttpClientRequest.setUrlParams(HttpClientRequest.get(`${BASE_URL}/v1/organizations/cost_report`), { + starting_at: `${window.startDay}T00:00:00Z`, + ending_at: `${addDays(window.endDay, 1)}T00:00:00Z`, + bucket_width: "1d", + "group_by[]": ["workspace_id", "description"], + limit: BUCKETS_PER_PAGE, + page, + }), + CostReportPage, + ) diff --git a/packages/backend/src/services/cost/providers/anthropic/fake-anthropic.ts b/packages/backend/src/services/cost/providers/anthropic/fake-anthropic.ts new file mode 100644 index 0000000000..9102e02b66 --- /dev/null +++ b/packages/backend/src/services/cost/providers/anthropic/fake-anthropic.ts @@ -0,0 +1,130 @@ +// A fake Anthropic Admin API for tests: the organization and cost report calls answered from a +// mutable world, with every call recorded. The cost report pages its buckets by `limit`. +import { DISABLED_ORGANIZATION_FEATURE_FLAGS } from "@maple/domain/organization-feature-flags" +import { Effect, Layer, Array as Arr, String as Str } from "effect" +import { FetchHttpClient } from "effect/http" +import { Env } from "@maple/backend/platform/Env" +import { OrganizationFeatureFlagsService } from "@maple/backend/services/org/OrganizationFeatureFlagsService" +import type { TestDb } from "@maple/backend/testing/test-pglite" +import { CostConnectionStore } from "../../CostConnectionStore" +import { addDays } from "../../sync-plan" +import { fakeCostEnvConfig } from "../fake-cost-env" +import type { CostBucket, CostResult } from "./anthropic-cost-api" + +export type FakeCostResult = typeof CostResult.Encoded +export type FakeCostBucket = typeof CostBucket.Encoded + +export interface FakeAnthropic { + /** Status of `GET /v1/organizations/me`. */ + meStatus: number + organization: { readonly id: string; readonly name: string } + /** Status of the cost report. */ + costStatus: number + /** Every daily bucket the organization has, oldest first, or raw text for a malformed answer. */ + buckets: ReadonlyArray | string + /** A report that always says there is another page. */ + endless: boolean + /** Whether the org is in the `cloudCost` rollout. */ + cloudCost: boolean + readonly urls: Array + /** The `x-api-key` and `anthropic-version` of each call. */ + readonly headers: Array +} + +export const FAKE_ORGANIZATION = { id: "4f2b1c9e-0a7d-4c3b-9e5f-1a2b3c4d5e6f", name: "Acme" } + +export const makeFakeAnthropic = (overrides: Partial = {}): FakeAnthropic => ({ + meStatus: 200, + organization: FAKE_ORGANIZATION, + costStatus: 200, + buckets: [], + endless: false, + cloudCost: true, + urls: [], + headers: [], + ...overrides, +}) + +/** One cost line as the API answers it: a model's input tokens unless `over` says otherwise. */ +export const costResult = (amount: string, over: Partial = {}): FakeCostResult => ({ + amount, + currency: "USD", + cost_type: "tokens", + description: "Claude Opus 5.5 Usage - Input Tokens", + model: "claude-opus-5-5", + token_type: "uncached_input_tokens", + service_tier: "standard", + context_window: "0-200k", + speed: null, + inference_geo: null, + workspace_id: "wrkspc_01", + ...over, +}) + +/** The bucket of `day`. */ +export const costBucket = (day: string, results: ReadonlyArray): FakeCostBucket => ({ + starting_at: `${day}T00:00:00Z`, + ending_at: `${addDays(day, 1)}T00:00:00Z`, + results, +}) + +const refused = (status: number) => + Response.json( + { type: "error", error: { type: "authentication_error", message: "invalid x-api-key" } }, + { status }, + ) + +const costAnswer = (world: FakeAnthropic, params: URLSearchParams): Response => { + if (typeof world.buckets === "string") return new Response(world.buckets, { status: 200 }) + const from = params.get("starting_at") ?? "" + const to = params.get("ending_at") ?? "" + const limit = Number(params.get("limit") ?? "7") + // The cursor is opaque to callers; here it is the index of the page's first bucket. + const offset = Number(Str.slice(5)(params.get("page") ?? "page_0")) + const held = Arr.filter(world.buckets, (bucket) => bucket.starting_at >= from && bucket.ending_at <= to) + const data = Arr.take(Arr.drop(held, offset), limit) + const hasMore = world.endless || offset + limit < held.length + return Response.json({ + data, + has_more: hasMore, + next_page: hasMore ? `page_${offset + limit}` : null, + }) +} + +const fetchStub = + (world: FakeAnthropic): typeof fetch => + async (input: string | URL | Request, init?: RequestInit) => { + const href = typeof input === "string" ? input : input instanceof URL ? input.href : input.url + const url = new URL(href) + const headers = new Headers(init?.headers) + world.urls.push(`${url.pathname}${url.search}`) + world.headers.push([headers.get("x-api-key"), headers.get("anthropic-version")]) + if (url.pathname === "/v1/organizations/me") { + return world.meStatus === 200 + ? Response.json({ ...world.organization, type: "organization" }) + : refused(world.meStatus) + } + if (world.costStatus !== 200) return refused(world.costStatus) + return costAnswer(world, url.searchParams) + } + +/** Everything the Anthropic cost services need, over the fake and a test database. */ +export const fakeAnthropicDeps = (testDb: TestDb, world: FakeAnthropic) => + Layer.effect(CostConnectionStore, CostConnectionStore.make).pipe( + Layer.provideMerge( + Layer.mergeAll( + FetchHttpClient.layer, + Layer.succeed(OrganizationFeatureFlagsService, { + flags: () => + Effect.sync(() => ({ + ...DISABLED_ORGANIZATION_FEATURE_FLAGS, + cloudCost: world.cloudCost, + })), + }), + testDb.layer, + Env.layer, + ), + ), + Layer.provide(Layer.succeed(FetchHttpClient.Fetch, fetchStub(world))), + Layer.provide(fakeCostEnvConfig()), + ) diff --git a/packages/backend/src/services/cost/providers/anthropic/normalize.test.ts b/packages/backend/src/services/cost/providers/anthropic/normalize.test.ts new file mode 100644 index 0000000000..7011f1528c --- /dev/null +++ b/packages/backend/src/services/cost/providers/anthropic/normalize.test.ts @@ -0,0 +1,132 @@ +import { Result, Schema, Array as Arr } from "effect" +import { describe, expect, it } from "vitest" +import { CostBucket } from "./anthropic-cost-api" +import { costBucket, costResult, FAKE_ORGANIZATION, type FakeCostBucket } from "./fake-anthropic" +import { toCostRows } from "./normalize" + +const WINDOW = { startDay: "2026-11-01", endDay: "2026-11-10" } +/** The two newest days of a pull on 2026-11-11 can still change. */ +const OPEN_DAY = "2026-11-09" + +const normalize = (buckets: ReadonlyArray) => + toCostRows(Schema.decodeSync(Schema.Array(CostBucket))(buckets), FAKE_ORGANIZATION.id, WINDOW, OPEN_DAY) + +const rowsOf = (buckets: ReadonlyArray) => Result.getOrThrow(normalize(buckets)) + +describe("toCostRows", () => { + it("maps a model's token line, with its amount in cents as dollars", () => { + expect(rowsOf([costBucket("2026-11-03", [costResult("123.45")])])).toEqual([ + { + day: "2026-11-03", + accountId: FAKE_ORGANIZATION.id, + cloudService: "anthropic/claude-opus-5-5", + serviceCategory: "AI and Machine Learning", + usageType: "Claude Opus 5.5 Usage - Input Tokens", + region: "global", + resourceId: "wrkspc_01", + resourceType: "workspace", + serviceName: "", + chargeCategory: "usage", + basis: "billed", + currency: "USD", + billedCost: 1.2345, + effectiveCost: 1.2345, + listCost: 1.2345, + consumedQuantity: 0, + consumedUnit: "", + tags: { + service_tier: "standard", + token_type: "uncached_input_tokens", + context_window: "0-200k", + }, + estimated: false, + }, + ]) + }) + + it("names a line with no model by its cost type, else as Anthropic API with its description as the usage", () => { + const tool = (costType: string | null, description: string | null) => + costResult("10", { + model: null, + cost_type: costType, + description, + token_type: null, + service_tier: null, + context_window: null, + }) + const rows = rowsOf([ + costBucket("2026-11-03", [ + tool("web_search", "Web Search Usage"), + tool("code_execution", "Code Execution Usage"), + tool("session_usage", null), + tool("something_new", "Priority Tier Commitment"), + tool(null, null), + ]), + ]) + expect(Arr.map(rows, (row) => [row.cloudService, row.usageType, row.tags])).toEqual([ + ["Web search", "Web Search Usage", {}], + ["Code execution", "Code Execution Usage", {}], + ["Managed Agents sessions", "session_usage", {}], + ["Anthropic API", "Priority Tier Commitment", {}], + ["Anthropic API", "", {}], + ]) + }) + + it("puts the default workspace's lines on `default`, and a geography's on its region", () => { + const rows = rowsOf([ + costBucket("2026-11-03", [ + costResult("100", { workspace_id: null }), + costResult("100", { inference_geo: "us", currency: "usd" }), + ]), + ]) + expect(Arr.map(rows, (row) => [row.resourceId, row.region, row.currency])).toEqual([ + ["default", "global", "USD"], + ["wrkspc_01", "us", "USD"], + ]) + }) + + it("sums lines of one key, keeping only the tags they agree on", () => { + const rows = rowsOf([ + costBucket("2026-11-03", [ + costResult("100", { service_tier: "standard" }), + costResult("50", { service_tier: "batch" }), + costResult("25", { workspace_id: "wrkspc_02" }), + ]), + ]) + expect(Arr.map(rows, (row) => [row.resourceId, row.billedCost, row.listCost, row.tags])).toEqual([ + ["wrkspc_01", 1.5, 1.5, { token_type: "uncached_input_tokens", context_window: "0-200k" }], + [ + "wrkspc_02", + 0.25, + 0.25, + { service_tier: "standard", token_type: "uncached_input_tokens", context_window: "0-200k" }, + ], + ]) + }) + + it("keeps only the window's days, and marks the days still being counted as estimated", () => { + const rows = rowsOf([ + costBucket("2026-10-31", [costResult("100")]), + costBucket("2026-11-01", [costResult("100")]), + costBucket("2026-11-08", [costResult("100")]), + costBucket("2026-11-09", []), + costBucket("2026-11-10", [costResult("100")]), + costBucket("2026-11-11", [costResult("100")]), + ]) + expect(Arr.map(rows, (row) => [row.day, row.estimated])).toEqual([ + ["2026-11-01", false], + ["2026-11-08", false], + ["2026-11-10", true], + ]) + }) + + it.each(["", "n/a", "Infinity"])("fails on the amount %j instead of dropping the line", (amount) => { + expect(normalize([costBucket("2026-11-03", [costResult("100"), costResult(amount)])])).toEqual( + Result.fail(amount), + ) + }) + + it("does not read an unreadable line outside the window", () => { + expect(normalize([costBucket("2026-10-01", [costResult("n/a")])])).toEqual(Result.succeed([])) + }) +}) diff --git a/packages/backend/src/services/cost/providers/anthropic/normalize.ts b/packages/backend/src/services/cost/providers/anthropic/normalize.ts new file mode 100644 index 0000000000..e61fc04e0d --- /dev/null +++ b/packages/backend/src/services/cost/providers/anthropic/normalize.ts @@ -0,0 +1,112 @@ +// Anthropic's cost report as `cost_daily` rows: one per day, model or tool, cost line and workspace. +import { Array as Arr, Option, Record as Rec, Result, String as Str } from "effect" +import type { CostPullRow, CostPullWindow } from "../../CostProvider" +import type { CostBucket, CostResult } from "./anthropic-cost-api" + +const CATEGORY = "AI and Machine Learning" + +/** What a cost line with no model is for, by its `cost_type`. */ +const TOOL_SERVICES = new Map([ + ["web_search", "Web search"], + ["code_execution", "Code execution"], + ["session_usage", "Managed Agents sessions"], +]) + +/** Fields that tell cost lines of one key apart without being part of it, kept as tags. */ +const TAG_FIELDS = ["service_tier", "token_type", "context_window", "speed"] as const + +const dayOf = (timestamp: string): string => Str.slice(0, 10)(timestamp) + +// NUL cannot be in a day or a name, so the key keeps the parts apart. +const keyOf = (parts: ReadonlyArray): string => Arr.join(parts, "\u0000") + +/** A model's lines are named by the model, which the Cost page shows with Anthropic's logo. */ +const serviceOf = (result: CostResult): string => { + const model = result.model ?? null + if (model !== null) return `anthropic/${model}` + // Any other charge goes under one name the Cost page knows as Anthropic's; its line is the usage type. + return TOOL_SERVICES.get(result.cost_type ?? "") ?? "Anthropic API" +} + +const tagsOf = (result: CostResult): Record => + Rec.fromEntries( + Arr.flatMap(TAG_FIELDS, (field): ReadonlyArray<[string, string]> => { + const value = result[field] ?? null + return value === null ? [] : [[field, value]] + }), + ) + +/** Tags every row of a group agrees on: a tag the rows disagree on describes none of the sum. */ +const sharedTags = ( + first: Readonly>, + rest: ReadonlyArray>>, +): Record => Rec.filter(first, (value, key) => Arr.every(rest, (tags) => tags[key] === value)) + +/** An amount that is not a decimal number: storing it as anything would invent or drop spend. */ +const isAmount = (amount: string): boolean => Str.trim(amount) !== "" && Number.isFinite(Number(amount)) + +/** + * The window's rows, or the first amount that is not a number. Lines of one key (day, service, + * usage type, region, workspace) are summed: the warehouse reads by that key, so two rows would + * count once. Days from `openDay` on can still change. + */ +export const toCostRows = ( + buckets: ReadonlyArray, + accountId: string, + window: CostPullWindow, + openDay: string, +): Result.Result, string> => { + const lines = Arr.flatMap(buckets, (bucket) => { + const day = dayOf(bucket.starting_at) + return day >= window.startDay && day <= window.endDay + ? Arr.map(bucket.results, (result) => ({ day, result })) + : [] + }) + const bad = Arr.findFirst(lines, ({ result }) => !isAmount(result.amount)) + if (Option.isSome(bad)) return Result.fail(bad.value.result.amount) + const rows = Arr.map(lines, ({ day, result }): CostPullRow => { + // Anthropic reports cents. + const cost = Number(result.amount) / 100 + return { + day, + accountId, + cloudService: serviceOf(result), + serviceCategory: CATEGORY, + usageType: result.description ?? result.cost_type ?? "", + region: result.inference_geo ?? "global", + resourceId: result.workspace_id ?? "default", + resourceType: "workspace", + serviceName: "", + chargeCategory: "usage", + basis: "billed", + currency: Str.toUpperCase(result.currency ?? "USD"), + billedCost: cost, + effectiveCost: cost, + listCost: cost, + consumedQuantity: 0, + consumedUnit: "", + tags: tagsOf(result), + estimated: day >= openDay, + } + }) + const groups = Arr.groupBy(rows, (row) => + keyOf([row.day, row.cloudService, row.usageType, row.region, row.resourceId, row.chargeCategory]), + ) + return Result.succeed( + Arr.map(Rec.values(groups), (group): CostPullRow => { + const [first, ...rest] = group + const sum = (of: (row: CostPullRow) => number) => + Arr.reduce(group, 0, (total, row) => total + of(row)) + return { + ...first, + billedCost: sum((row) => row.billedCost), + effectiveCost: sum((row) => row.effectiveCost), + listCost: sum((row) => row.listCost), + tags: sharedTags( + first.tags, + Arr.map(rest, (row) => row.tags), + ), + } + }), + ) +} diff --git a/packages/backend/src/services/cost/providers/openai/OpenAiCostProvider.test.ts b/packages/backend/src/services/cost/providers/openai/OpenAiCostProvider.test.ts new file mode 100644 index 0000000000..afac875eac --- /dev/null +++ b/packages/backend/src/services/cost/providers/openai/OpenAiCostProvider.test.ts @@ -0,0 +1,192 @@ +import { afterEach, assert, describe, it } from "@effect/vitest" +import { OrgId, UserId } from "@maple/domain/http" +import { DateTime, Effect, Layer, Schema, Array as Arr } from "effect" +import { TestClock } from "effect/testing" +import { cleanupTestDbs, createTestDb, queryFirstRow, type TestDb } from "@maple/backend/testing/test-pglite" +import { OpenAiCostConnectionService } from "../../OpenAiCostConnectionService" +import { FAKE_OPENAI_ORG, type FakeOpenAi, fakeOpenAiDeps, makeFakeOpenAi } from "./fake-openai" +import { OpenAiCostProvider } from "./OpenAiCostProvider" + +const trackedDbs: TestDb[] = [] +afterEach(() => cleanupTestDbs(trackedDbs)) + +const ORG = Schema.decodeSync(OrgId)("org_cost") +const OTHER_ORG = Schema.decodeSync(OrgId)("org_other") +const USER = Schema.decodeSync(UserId)("user_cost") +const KEY = "sk-admin-0123456789abcdefghijABCDEFGHIJ0123456789" +const WINDOW = { startDay: "2026-10-20", endDay: "2026-11-10" } +/** The pull runs the morning after the window's last day. */ +const NOW = DateTime.toEpochMillis(DateTime.makeUnsafe("2026-11-11T06:00:00Z")) +const unix = (day: string) => Date.parse(`${day}T00:00:00Z`) / 1000 + +const run = ( + testDb: TestDb, + world: FakeOpenAi, + effect: Effect.Effect, +) => + effect.pipe( + Effect.provide( + Layer.mergeAll( + Layer.effect(OpenAiCostProvider, OpenAiCostProvider.make), + Layer.effect(OpenAiCostConnectionService, OpenAiCostConnectionService.make), + ).pipe(Layer.provide(fakeOpenAiDeps(testDb, world))), + ), + ) + +/** Saves `KEY` for `ORG` and answers the connection's id, with the fake's call log emptied. */ +const saveKey = (testDb: TestDb, world: FakeOpenAi) => + Effect.gen(function* () { + yield* TestClock.setTime(NOW) + yield* OpenAiCostConnectionService.use((service) => service.set(ORG, USER, KEY)) + const row = yield* Effect.promise(() => + queryFirstRow<{ id: string }>(testDb, "SELECT id FROM cost_connections"), + ) + world.urls.length = 0 + world.authorizations.length = 0 + return row?.id ?? "" + }) + +const pull = (orgId: OrgId, connectionRef: string, window = WINDOW) => + OpenAiCostProvider.use((provider) => provider.pull({ orgId, connectionRef }, window)) + +describe("OpenAiCostProvider", () => { + it.effect("reads every page of the window's costs by project and line item", () => { + const testDb = createTestDb(trackedDbs) + const world = makeFakeOpenAi({ + costs: [ + { day: "2026-10-19", value: 9, lineItem: "gpt-4o, input", projectId: "proj_a" }, + { day: "2026-10-20", value: 1.5, lineItem: "gpt-4o, input", projectId: "proj_a" }, + { day: "2026-10-31", value: 2, lineItem: "gpt-4o, output", projectId: "proj_b" }, + // The window's last day is only served when the range ends the day after. + { day: "2026-11-10", value: 0.25, lineItem: "o3, input", projectId: null }, + ], + }) + return run( + testDb, + world, + Effect.gen(function* () { + const connectionRef = yield* saveKey(testDb, world) + world.pageSize = 10 + const pulled = yield* pull(ORG, connectionRef) + const query = `start_time=${unix("2026-10-20")}&end_time=${unix("2026-11-11")}&bucket_width=1d&group_by=project_id&group_by=line_item&limit=180` + // 22 days at 10 a page, each next page by the cursor the one before gave. + assert.deepStrictEqual(world.urls, [ + `/v1/organization/costs?${query}`, + `/v1/organization/costs?${query}&page=page_10`, + `/v1/organization/costs?${query}&page=page_20`, + ]) + assert.deepStrictEqual(world.authorizations, Arr.replicate(`Bearer ${KEY}`, 3)) + // OpenAI bills none of the requests. + assert.strictEqual(pulled.requests, 0) + assert.deepStrictEqual( + Arr.map(pulled.rows, (row) => [ + row.day, + row.cloudService, + row.resourceId, + row.billedCost, + row.accountId, + row.estimated, + ]), + [ + ["2026-10-20", "openai/gpt-4o", "proj_a", 1.5, FAKE_OPENAI_ORG, false], + ["2026-10-31", "openai/gpt-4o", "proj_b", 2, FAKE_OPENAI_ORG, false], + ["2026-11-10", "openai/o3", "", 0.25, FAKE_OPENAI_ORG, true], + ], + ) + }), + ) + }) + + it.effect( + "fails a window whose costs take more pages than a pull reads, or that page without a cursor", + () => { + const testDb = createTestDb(trackedDbs) + const world = makeFakeOpenAi({ + costs: [{ day: "2026-10-20", value: 1, lineItem: "gpt-4o, input" }], + }) + return run( + testDb, + world, + Effect.gen(function* () { + const connectionRef = yield* saveKey(testDb, world) + world.pageSize = 1 + const tooLarge = yield* Effect.flip( + pull(ORG, connectionRef, { startDay: "2026-08-01", endDay: "2026-11-10" }), + ) + assert.deepStrictEqual([tooLarge.reason, tooLarge.provider], ["too_large", "openai"]) + // It stops at the cap rather than reading on. + assert.lengthOf(world.urls, 60) + + // More to read and no way to read it: part of the window is not the window. + world.pageSize = 10 + world.dropCursor = true + assert.strictEqual( + (yield* Effect.flip(pull(ORG, connectionRef))).reason, + "invalid_response", + ) + }), + ) + }, + ) + + it.effect("says what the sync should do about each way OpenAI refuses", () => { + const testDb = createTestDb(trackedDbs) + const world = makeFakeOpenAi({ costs: [{ day: "2026-10-20", value: 1 }] }) + return run( + testDb, + world, + Effect.gen(function* () { + const connectionRef = yield* saveKey(testDb, world) + const failures = yield* Effect.forEach([401, 403, 400, 429, 502], (status) => + Effect.gen(function* () { + world.status = status + const error = yield* Effect.flip(pull(ORG, connectionRef)) + return [error.reason, error.requests] + }), + ) + assert.deepStrictEqual(failures, [ + ["auth", 0], + ["not_ready", 0], + ["upstream", 0], + ["rate_limited", 0], + ["upstream", 0], + ]) + }), + ) + }) + + it.effect("fails an answer it cannot read as invalid, with nothing to store", () => { + const testDb = createTestDb(trackedDbs) + const world = makeFakeOpenAi({ costs: [{ day: "2026-10-20", value: 1 }] }) + return run( + testDb, + world, + Effect.gen(function* () { + const connectionRef = yield* saveKey(testDb, world) + world.costs = "maintenance" + assert.strictEqual((yield* Effect.flip(pull(ORG, connectionRef))).reason, "invalid_response") + // A page without its buckets is not read as a day with no cost. + world.costs = '{"object":"page","has_more":false}' + assert.strictEqual((yield* Effect.flip(pull(ORG, connectionRef))).reason, "invalid_response") + // An amount past what a number holds cannot be stored as cost. + world.costs = `{"data":[{"start_time":${unix("2026-10-21")},"end_time":${unix("2026-10-22")},"results":[{"amount":{"value":1e999}}]}],"has_more":false}` + assert.strictEqual((yield* Effect.flip(pull(ORG, connectionRef))).reason, "invalid_response") + }), + ) + }) + + it.effect("never reads a key through another org's source, or a connection that is gone", () => { + const testDb = createTestDb(trackedDbs) + const world = makeFakeOpenAi({ costs: [{ day: "2026-10-20", value: 1 }] }) + return run( + testDb, + world, + Effect.gen(function* () { + const connectionRef = yield* saveKey(testDb, world) + assert.strictEqual((yield* Effect.flip(pull(OTHER_ORG, connectionRef))).reason, "auth") + assert.strictEqual((yield* Effect.flip(pull(ORG, "no-such-connection"))).reason, "auth") + assert.deepStrictEqual(world.urls, []) + }), + ) + }) +}) diff --git a/packages/backend/src/services/cost/providers/openai/OpenAiCostProvider.ts b/packages/backend/src/services/cost/providers/openai/OpenAiCostProvider.ts new file mode 100644 index 0000000000..d1b1fa5ed6 --- /dev/null +++ b/packages/backend/src/services/cost/providers/openai/OpenAiCostProvider.ts @@ -0,0 +1,117 @@ +/** + * OpenAI as a cost provider: an organization's daily API spend by model, line item and project, + * read with the Admin API key the org saved. + */ +import { Clock, Context, DateTime, Effect, Layer, Option, Result } from "effect" +import { FetchHttpClient, HttpClient } from "effect/http" +import { + type CostProviderAdapter, + CostProviderError, + type CostPull, + type CostPullSource, + type CostPullWindow, +} from "../../CostProvider" +import { CostConnectionStore, loadFailureOf } from "../../CostConnectionStore" +import { addDays, utcToday } from "../../sync-plan" +import * as OpenAiCosts from "./openai-costs-api" +import { toCostRows } from "./normalize" + +/** Pages one pull reads at most: far beyond a year of days grouped by project and line item. */ +export const MAX_PULL_PAGES = 60 +/** The newest days OpenAI may still be counting. */ +const OPEN_DAYS = 2 + +type Reason = CostProviderError["reason"] + +// OpenAI does not bill for API requests, so no failure counts any. +const failed = (reason: Reason, message: string) => + new CostProviderError({ message, provider: "openai", reason, requests: 0 }) + +/** OpenAI's failures, as what the sync does next and the Cost page shows. */ +const toCostProviderError = (error: OpenAiCosts.OpenAiCostsError): CostProviderError => { + switch (error.kind) { + case "unauthorized": + return failed("auth", "OpenAI rejected the saved key. Replace the key to resume the sync.") + // The key passed this check when it was saved: its access was taken away since. + case "forbidden": + return failed( + "not_ready", + "OpenAI no longer lets the saved key read the organization's costs. Replace it with an Admin API key.", + ) + case "invalid_response": + return failed("invalid_response", "OpenAI's cost answer could not be read.") + case "rate_limited": + return failed("rate_limited", "OpenAI is rate limiting cost requests.") + // A 400 is a request Maple wrote wrong, which a retry does not fix but a deploy does. + default: + return failed("upstream", "OpenAI did not answer the cost request.") + } +} + +/** The Unix seconds `day` starts at, UTC. */ +export const unixDayStart = (day: string): number => + DateTime.toEpochMillis(DateTime.makeUnsafe(`${day}T00:00:00Z`)) / 1000 + +export class OpenAiCostProvider extends Context.Service()( + "@maple/backend/services/cost/OpenAiCostProvider", + { + make: Effect.gen(function* () { + const connections = yield* CostConnectionStore + const httpClient = yield* HttpClient.HttpClient + + const pull = Effect.fn("OpenAiCostProvider.pull")( + function* (source: CostPullSource, window: CostPullWindow) { + yield* Effect.annotateCurrentSpan({ + orgId: source.orgId, + "maple.cost.provider": "openai", + "maple.cost.start_day": window.startDay, + "maple.cost.end_day": window.endDay, + }) + const credential = yield* connections + .load("openai", source) + .pipe(Effect.mapError(loadFailureOf("openai"))) + if (Option.isNone(credential)) { + return yield* failed("auth", "No OpenAI key is saved for this source.") + } + const { secret, accountId } = credential.value + const listing = yield* OpenAiCosts.listCosts( + httpClient, + secret, + { + startTime: unixDayStart(window.startDay), + endTime: unixDayStart(addDays(window.endDay, 1)), + groupBy: ["project_id", "line_item"], + }, + MAX_PULL_PAGES, + ).pipe(Effect.mapError(toCostProviderError)) + yield* Effect.annotateCurrentSpan({ "maple.cost.openai.pages": listing.pages }) + // Part of the window's costs would store its other days as empty. + if (!listing.complete) { + return yield* failed( + "too_large", + `OpenAI's costs for this window take more than ${MAX_PULL_PAGES} pages.`, + ) + } + const now = yield* Clock.currentTimeMillis + const openDay = addDays(utcToday(now), -OPEN_DAYS) + const normalized = toCostRows(listing.buckets, accountId, window, openDay) + if (Result.isFailure(normalized)) { + return yield* failed("invalid_response", "OpenAI's cost answer could not be read.") + } + const rows = normalized.success + yield* Effect.annotateCurrentSpan({ "maple.cost.rows": rows.length }) + return { rows, requests: 0 } satisfies CostPull + }, + Effect.tapError((error) => + Effect.annotateCurrentSpan({ "maple.cost.error_reason": error.reason }), + ), + ) + + return { provider: "openai", pull } satisfies CostProviderAdapter + }), + }, +) { + static readonly layer = Layer.effect(this, this.make).pipe( + Layer.provide(Layer.mergeAll(FetchHttpClient.layer, CostConnectionStore.layer)), + ) +} diff --git a/packages/backend/src/services/cost/providers/openai/fake-openai.ts b/packages/backend/src/services/cost/providers/openai/fake-openai.ts new file mode 100644 index 0000000000..c3ac94d625 --- /dev/null +++ b/packages/backend/src/services/cost/providers/openai/fake-openai.ts @@ -0,0 +1,151 @@ +// A fake OpenAI for tests: the organization costs endpoint answered from a mutable world, with every +// call recorded. Like the real API it answers a bucket for every day of the range, empty or not, +// sums costs by the `group_by` fields, and pages buckets with an opaque cursor. +import { DISABLED_ORGANIZATION_FEATURE_FLAGS } from "@maple/domain/organization-feature-flags" +import { DateTime, Effect, Layer, Array as Arr, Record as Rec, String as Str } from "effect" +import { FetchHttpClient } from "effect/http" +import { Env } from "@maple/backend/platform/Env" +import { OrganizationFeatureFlagsService } from "@maple/backend/services/org/OrganizationFeatureFlagsService" +import type { TestDb } from "@maple/backend/testing/test-pglite" +import { CostConnectionStore } from "../../CostConnectionStore" +import { fakeCostEnvConfig } from "../fake-cost-env" + +export const FAKE_OPENAI_ORG = "org-GLHrIv00VVN9dEQC2b4wsBkf" + +/** One charge on one UTC day. */ +export interface FakeCost { + readonly day: string + readonly value: number + readonly lineItem?: string | null + readonly projectId?: string | null + readonly quantity?: number | null + readonly unit?: string | null + /** The organization the charge is reported under; the world's own when left out. */ + readonly organizationId?: string +} + +export interface FakeOpenAi { + /** Status of the costs call. */ + status: number + /** Every charge the organization has, or raw text for a malformed answer. */ + costs: ReadonlyArray | string + organizationId: string + /** Buckets one page holds at most, below what the request's `limit` asks for. */ + pageSize: number + /** Answers `has_more` with no `next_page`. */ + dropCursor: boolean + /** Whether the org is in the `cloudCost` rollout. */ + cloudCost: boolean + readonly urls: Array + readonly authorizations: Array +} + +export const makeFakeOpenAi = (overrides: Partial = {}): FakeOpenAi => ({ + status: 200, + costs: [], + organizationId: FAKE_OPENAI_ORG, + pageSize: 180, + dropCursor: false, + cloudCost: true, + urls: [], + authorizations: [], + ...overrides, +}) + +const DAY_SECONDS = 86_400 +const dayOf = (seconds: number) => DateTime.formatIsoDateUtc(DateTime.makeUnsafe(seconds * 1000)) + +const keyOf = (cost: FakeCost, world: FakeOpenAi, groupBy: ReadonlyArray) => + Arr.join( + [ + cost.organizationId ?? world.organizationId, + Arr.contains(groupBy, "project_id") ? (cost.projectId ?? "") : "", + Arr.contains(groupBy, "line_item") ? (cost.lineItem ?? "") : "", + ], + "\u0000", + ) + +/** One bucket's results: costs summed per group, the fields not grouped by left null. */ +const resultsOf = (world: FakeOpenAi, costs: ReadonlyArray, groupBy: ReadonlyArray) => + Arr.map(Rec.values(Arr.groupBy(costs, (cost) => keyOf(cost, world, groupBy))), (group) => { + const first = Arr.headNonEmpty(group) + const grouped = (field: string, value: string | null | undefined) => + Arr.contains(groupBy, field) ? (value ?? null) : null + return { + object: "organization.costs.result", + amount: { value: Arr.reduce(group, 0, (total, cost) => total + cost.value), currency: "usd" }, + line_item: grouped("line_item", first.lineItem), + project_id: grouped("project_id", first.projectId), + organization_id: first.organizationId ?? world.organizationId, + quantity: first.quantity ?? null, + quantity_unit: first.unit ?? null, + } + }) + +const costsAnswer = (world: FakeOpenAi, costs: ReadonlyArray, params: URLSearchParams) => { + const start = Number(params.get("start_time") ?? "0") + const end = Number(params.get("end_time") ?? `${start + DAY_SECONDS}`) + const groupBy = params.getAll("group_by") + const size = Math.min(world.pageSize, Number(params.get("limit") ?? "7")) + const offset = Number(Str.replace("page_", "")(params.get("page") ?? "page_0")) + const days = Math.max(0, Math.ceil((end - start) / DAY_SECONDS)) + const buckets = Arr.map(Arr.take(Arr.drop(Arr.range(0, days - 1), offset), size), (index) => { + const startTime = start + index * DAY_SECONDS + return { + object: "bucket", + start_time: startTime, + end_time: startTime + DAY_SECONDS, + results: resultsOf( + world, + Arr.filter(costs, (cost) => cost.day === dayOf(startTime)), + groupBy, + ), + } + }) + const next = offset + size + const hasMore = days > 0 && next < days + return Response.json({ + object: "page", + data: days === 0 ? [] : buckets, + has_more: hasMore, + next_page: hasMore && !world.dropCursor ? `page_${next}` : null, + }) +} + +const fetchStub = + (world: FakeOpenAi): typeof fetch => + async (input: string | URL | Request, init?: RequestInit) => { + const href = typeof input === "string" ? input : input instanceof URL ? input.href : input.url + const url = new URL(href) + world.urls.push(`${url.pathname}${url.search}`) + world.authorizations.push(new Headers(init?.headers).get("authorization")) + if (world.status !== 200) { + return Response.json( + { error: { message: "Request refused", type: "invalid_request_error" } }, + { status: world.status }, + ) + } + if (typeof world.costs === "string") return new Response(world.costs, { status: 200 }) + return costsAnswer(world, world.costs, url.searchParams) + } + +/** Everything the OpenAI cost services need, over the fake and a test database. */ +export const fakeOpenAiDeps = (testDb: TestDb, world: FakeOpenAi) => + Layer.effect(CostConnectionStore, CostConnectionStore.make).pipe( + Layer.provideMerge( + Layer.mergeAll( + FetchHttpClient.layer, + Layer.succeed(OrganizationFeatureFlagsService, { + flags: () => + Effect.sync(() => ({ + ...DISABLED_ORGANIZATION_FEATURE_FLAGS, + cloudCost: world.cloudCost, + })), + }), + testDb.layer, + Env.layer, + ), + ), + Layer.provide(Layer.succeed(FetchHttpClient.Fetch, fetchStub(world))), + Layer.provide(fakeCostEnvConfig()), + ) diff --git a/packages/backend/src/services/cost/providers/openai/normalize.test.ts b/packages/backend/src/services/cost/providers/openai/normalize.test.ts new file mode 100644 index 0000000000..6ab52756f2 --- /dev/null +++ b/packages/backend/src/services/cost/providers/openai/normalize.test.ts @@ -0,0 +1,155 @@ +import { Result, Schema, Array as Arr } from "effect" +import { describe, expect, it } from "vitest" +import { toCostRows } from "./normalize" +import { CostBucket } from "./openai-costs-api" + +const ACCOUNT = "org-GLHrIv00VVN9dEQC2b4wsBkf" +const WINDOW = { startDay: "2026-10-01", endDay: "2026-11-10" } +/** The two newest days of a pull on 2026-11-11 can still change. */ +const OPEN_DAY = "2026-11-09" + +const startOf = (day: string) => Date.parse(`${day}T00:00:00Z`) / 1000 + +/** A bucket as the API answers it, with the fields Maple does not read. */ +const bucket = (day: string, results: ReadonlyArray>) => ({ + object: "bucket", + start_time: startOf(day), + end_time: startOf(day) + 86_400, + results: Arr.map(results, (result) => ({ + object: "organization.costs.result", + organization_id: ACCOUNT, + ...result, + })), +}) + +const rows = (buckets: ReadonlyArray) => + toCostRows(Schema.decodeUnknownSync(Schema.Array(CostBucket))(buckets), ACCOUNT, WINDOW, OPEN_DAY) + +const okRows = (buckets: ReadonlyArray) => { + const result = rows(buckets) + if (Result.isFailure(result)) return expect.fail(result.failure) + return result.success +} + +const charge = (lineItem: string | null, value: number, extra: Record = {}) => ({ + amount: { value, currency: "usd" }, + line_item: lineItem, + project_id: "proj_abc", + ...extra, +}) + +describe("toCostRows", () => { + it("maps a recorded result: the model is the service, the line item the usage type", () => { + expect( + okRows([ + bucket("2026-10-03", [ + charge("gpt-4o-2024-08-06, input", 1.25, { quantity: 50_000, quantity_unit: "tokens" }), + ]), + ]), + ).toEqual([ + { + day: "2026-10-03", + accountId: ACCOUNT, + cloudService: "openai/gpt-4o-2024-08-06", + serviceCategory: "AI and Machine Learning", + usageType: "gpt-4o-2024-08-06, input", + region: "global", + resourceId: "proj_abc", + resourceType: "project", + serviceName: "", + chargeCategory: "usage", + basis: "billed", + currency: "USD", + billedCost: 1.25, + effectiveCost: 1.25, + listCost: 1.25, + consumedQuantity: 50_000, + consumedUnit: "tokens", + tags: {}, + estimated: false, + }, + ]) + }) + + it("names the service after the model, the product, or nothing when the line item is missing", () => { + const mapped = okRows([ + bucket("2026-10-03", [ + charge("ft-gpt-4o-2024-08-06, input", 1), + charge("assistants api | file search", 2), + charge(null, 3, { project_id: null }), + ]), + ]) + expect( + Arr.map(mapped, (row) => [row.cloudService, row.usageType, row.resourceId, row.resourceType]), + ).toEqual([ + ["openai/ft-gpt-4o-2024-08-06", "ft-gpt-4o-2024-08-06, input", "proj_abc", "project"], + ["openai/assistants api | file search", "assistants api | file search", "proj_abc", "project"], + ["openai/unattributed", "", "", ""], + ]) + }) + + it("merges results of one key, adding quantities only in the row's own unit", () => { + const mapped = okRows([ + bucket("2026-10-03", [ + charge("gpt-4o, input", 1, { quantity: 100, quantity_unit: "tokens" }), + charge("gpt-4o, input", 2, { quantity: 50, quantity_unit: "tokens" }), + charge("gpt-4o, input", 0.5, { quantity: 7, quantity_unit: "requests" }), + // Another project is another resource, so another row. + charge("gpt-4o, input", 4, { project_id: "proj_other" }), + ]), + ]) + expect( + Arr.map(mapped, (row) => [ + row.resourceId, + row.billedCost, + row.consumedQuantity, + row.consumedUnit, + ]), + ).toEqual([ + ["proj_abc", 3.5, 150, "tokens"], + ["proj_other", 4, 0, ""], + ]) + }) + + it("keeps only the window's days, and marks the newest two as estimated", () => { + const mapped = okRows([ + bucket("2026-09-30", [charge("gpt-4o, input", 9)]), + bucket("2026-10-01", [charge("gpt-4o, input", 1)]), + bucket("2026-11-08", [charge("gpt-4o, input", 2)]), + bucket("2026-11-09", [charge("gpt-4o, input", 3)]), + bucket("2026-11-10", [charge("gpt-4o, input", 4)]), + bucket("2026-11-11", [charge("gpt-4o, input", 9)]), + ]) + expect(Arr.map(mapped, (row) => [row.day, row.billedCost, row.estimated])).toEqual([ + ["2026-10-01", 1, false], + ["2026-11-08", 2, false], + ["2026-11-09", 3, true], + ["2026-11-10", 4, true], + ]) + }) + + it("skips results without an amount, and reads a missing currency as US dollars", () => { + const mapped = okRows([ + bucket("2026-10-03", [ + { line_item: "gpt-4o, input", project_id: null }, + { amount: {}, line_item: "gpt-4o, output", project_id: null }, + { amount: { value: 2 }, line_item: "gpt-4o, cached", project_id: null }, + { amount: { value: 1, currency: "eur" }, line_item: "gpt-4o, batch", project_id: null }, + ]), + ]) + expect(Arr.map(mapped, (row) => [row.usageType, row.currency])).toEqual([ + ["gpt-4o, cached", "USD"], + ["gpt-4o, batch", "EUR"], + ]) + }) + + it("fails an amount that is not a finite number instead of storing it", () => { + const result = rows([ + bucket("2026-10-03", [ + charge("gpt-4o, input", 1), + charge("gpt-4o, output", Number.POSITIVE_INFINITY), + ]), + ]) + expect(Result.isFailure(result)).toBe(true) + }) +}) diff --git a/packages/backend/src/services/cost/providers/openai/normalize.ts b/packages/backend/src/services/cost/providers/openai/normalize.ts new file mode 100644 index 0000000000..b0a83364b5 --- /dev/null +++ b/packages/backend/src/services/cost/providers/openai/normalize.ts @@ -0,0 +1,104 @@ +// OpenAI's daily costs as `cost_daily` rows: one per day, model (or product) and line item, per +// project. The `openai/` service prefix is what the Cost page shows the OpenAI logo for. +import { DateTime, Option, Array as Arr, Record as Rec, Result, String as Str } from "effect" +import type { CostPullRow, CostPullWindow } from "../../CostProvider" +import type { CostBucket } from "./openai-costs-api" + +const SERVICE_PREFIX = "openai/" +const UNATTRIBUTED = "unattributed" +const CATEGORY = "AI and Machine Learning" +// A model's line items read ", ", as "gpt-4o-2024-08-06, input". +const PART_SEPARATOR = ", " + +/** The service a line item belongs to: the model of ", ", else the line item itself. */ +export const serviceOf = (lineItem: string | null): string => { + if (lineItem === null || lineItem === "") return `${SERVICE_PREFIX}${UNATTRIBUTED}` + const cut = Option.getOrElse(Str.indexOf(PART_SEPARATOR)(lineItem), () => 0) + return `${SERVICE_PREFIX}${cut > 0 ? Str.slice(0, cut)(lineItem) : lineItem}` +} + +const dayOf = (unixSeconds: number): string => + DateTime.formatIsoDateUtc(DateTime.makeUnsafe(unixSeconds * 1000)) + +// NUL cannot be in a day, a name or an id, so the key keeps the parts apart. +const keyOf = (parts: ReadonlyArray): string => Arr.join(parts, "\u0000") + +const sum = (items: ReadonlyArray, of: (item: A) => number): number => + Arr.reduce(items, 0, (total, item) => total + of(item)) + +interface Charge { + readonly day: string + readonly lineItem: string | null + readonly projectId: string | null + readonly value: number + readonly currency: string + readonly quantity: number + readonly unit: string +} + +/** + * The window's charges, merged per day, service, line item and project. A result without an amount + * is not a charge. One with an amount that is not a finite number fails: it cannot be stored as cost. + */ +export const toCostRows = ( + buckets: ReadonlyArray, + accountId: string, + window: CostPullWindow, + openDay: string, +): Result.Result, string> => { + const charges = Arr.flatMap(buckets, (bucket) => { + const day = dayOf(bucket.start_time) + if (day < window.startDay || day > window.endDay) return [] + return Arr.flatMap(bucket.results, (result): ReadonlyArray => { + const value = result.amount?.value + if (value === undefined || value === null) return [] + return [ + { + day, + lineItem: result.line_item ?? null, + projectId: result.project_id ?? null, + value, + currency: Str.toUpperCase(result.amount?.currency ?? "usd"), + quantity: result.quantity ?? 0, + unit: result.quantity_unit ?? "", + }, + ] + }) + }) + const bad = Arr.findFirst(charges, (charge) => !Number.isFinite(charge.value)) + if (Option.isSome(bad)) { + return Result.fail(`OpenAI reported an amount that is not a number on ${bad.value.day}`) + } + // A day's rows are replaced whole and read per key, so two rows of one key would count once. + const groups = Arr.groupBy(charges, (charge) => + keyOf([charge.day, serviceOf(charge.lineItem), charge.lineItem ?? "", charge.projectId ?? ""]), + ) + return Result.succeed( + Arr.map(Rec.values(groups), (group): CostPullRow => { + const first = Arr.headNonEmpty(group) + const value = sum(group, (charge) => charge.value) + return { + day: first.day, + accountId, + cloudService: serviceOf(first.lineItem), + serviceCategory: CATEGORY, + usageType: first.lineItem ?? "", + region: "global", + resourceId: first.projectId ?? "", + resourceType: first.projectId === null ? "" : "project", + serviceName: "", + chargeCategory: "usage", + basis: "billed", + currency: first.currency, + billedCost: value, + effectiveCost: value, + listCost: value, + // Only quantities in the row's own unit add up. + consumedQuantity: sum(group, (charge) => (charge.unit === first.unit ? charge.quantity : 0)), + consumedUnit: first.unit, + tags: {}, + estimated: first.day >= openDay, + } + }), + ) +} diff --git a/packages/backend/src/services/cost/providers/openai/openai-costs-api.ts b/packages/backend/src/services/cost/providers/openai/openai-costs-api.ts new file mode 100644 index 0000000000..89ea6bb409 --- /dev/null +++ b/packages/backend/src/services/cost/providers/openai/openai-costs-api.ts @@ -0,0 +1,172 @@ +// The slice of OpenAI's Administration API the cost source uses: the organization's daily costs, +// read with an Admin API key. Every failure is one `OpenAiCostsError` whose `kind` says what the +// caller does next. +import { Duration, Effect, Redacted, Schema } from "effect" +import { FetchHttpClient, HttpClient, HttpClientRequest } from "effect/http" + +export const COSTS_URL = "https://api.openai.com/v1/organization/costs" +const REQUEST_TIMEOUT = Duration.seconds(30) +/** The most daily buckets one page holds. */ +export const MAX_BUCKETS_PER_PAGE = 180 + +export class OpenAiCostsError extends Schema.TaggedError()( + "@maple/backend/cost/OpenAiCostsError", + { + message: Schema.String, + kind: Schema.Literals([ + "unauthorized", + "forbidden", + "bad_request", + "rate_limited", + "invalid_response", + "upstream", + ]), + status: Schema.optionalKey(Schema.Number), + cause: Schema.optionalKey(Schema.Defect()), + }, +) {} + +const errorKind = (status: number): OpenAiCostsError["kind"] => + status === 401 + ? "unauthorized" + : status === 403 + ? "forbidden" + : status === 400 + ? "bad_request" + : status === 429 + ? "rate_limited" + : "upstream" + +/** One charge of a day. A result without an amount is not a charge. */ +export const CostResult = Schema.Struct({ + amount: Schema.optionalKey( + Schema.NullOr( + Schema.Struct({ + value: Schema.optionalKey(Schema.NullOr(Schema.Number)), + currency: Schema.optionalKey(Schema.NullOr(Schema.String)), + }), + ), + ), + line_item: Schema.optionalKey(Schema.NullOr(Schema.String)), + project_id: Schema.optionalKey(Schema.NullOr(Schema.String)), + /** Not in the documented schema, but on real answers: the only place a key's organization shows. */ + organization_id: Schema.optionalKey(Schema.NullOr(Schema.String)), + quantity: Schema.optionalKey(Schema.NullOr(Schema.Number)), + quantity_unit: Schema.optionalKey(Schema.NullOr(Schema.String)), +}) +export type CostResult = typeof CostResult.Type + +/** One UTC day, `start_time` and `end_time` in Unix seconds. */ +export const CostBucket = Schema.Struct({ + start_time: Schema.Number, + end_time: Schema.Number, + results: Schema.Array(CostResult), +}) +export type CostBucket = typeof CostBucket.Type + +const CostPage = Schema.Struct({ + data: Schema.Array(CostBucket), + has_more: Schema.Boolean, + next_page: Schema.optionalKey(Schema.NullOr(Schema.String)), +}) + +export interface CostsQuery { + /** Unix seconds, inclusive. */ + readonly startTime: number + /** Unix seconds, exclusive. */ + readonly endTime: number + readonly groupBy: ReadonlyArray<"project_id" | "line_item"> +} + +const costsPage = ( + httpClient: HttpClient.HttpClient, + key: Redacted.Redacted, + query: CostsQuery, + page: string | undefined, +) => { + const failed = (kind: OpenAiCostsError["kind"], what: string) => (cause: unknown) => + Effect.fail(new OpenAiCostsError({ message: `OpenAI API ${what}`, kind, cause })) + const request = HttpClientRequest.setUrlParams(HttpClientRequest.get(COSTS_URL), { + start_time: query.startTime, + end_time: query.endTime, + bucket_width: "1d", + group_by: query.groupBy, + limit: MAX_BUCKETS_PER_PAGE, + page, + }) + return Effect.gen(function* () { + const response = yield* httpClient.execute(HttpClientRequest.bearerToken(request, key)) + if (response.status >= 300) { + return yield* new OpenAiCostsError({ + message: `OpenAI API answered ${response.status}`, + kind: errorKind(response.status), + status: response.status, + }) + } + // A 2xx that is not JSON is an answer Maple cannot read, not a failed request. + const body = yield* response.json.pipe( + Effect.catchTag( + "HttpClientError", + failed("invalid_response", "answered with a body that is not JSON"), + ), + ) + return yield* Schema.decodeUnknownEffect(CostPage)(body) + }).pipe( + Effect.timeout(REQUEST_TIMEOUT), + Effect.catchTags({ + HttpClientError: failed("upstream", "request failed"), + TimeoutError: failed("upstream", "request timed out"), + SchemaError: failed("invalid_response", "answered with a body Maple cannot read"), + }), + // Callers show their own message; which failure it was stays on the span. + Effect.tapError((error) => + Effect.annotateCurrentSpan({ + "maple.cost.openai.error": error.kind, + "maple.cost.openai.status": error.status ?? 0, + }), + ), + // A redirect is not OpenAI answering, so the key is never followed to another host. + Effect.provideService(FetchHttpClient.RequestInit, { redirect: "manual" }), + Effect.annotateSpans("peer.service", "openai"), + ) +} + +export interface CostListing { + readonly buckets: ReadonlyArray + readonly pages: number + /** False when more pages were left than `maxPages` allowed. */ + readonly complete: boolean +} + +/** Every bucket of the query, following `next_page` one page at a time up to `maxPages`. */ +export const listCosts = ( + httpClient: HttpClient.HttpClient, + key: Redacted.Redacted, + query: CostsQuery, + maxPages: number, +) => { + const from = ( + page: string | undefined, + held: ReadonlyArray, + pages: number, + ): Effect.Effect => + Effect.flatMap(costsPage(httpClient, key, query, page), (body) => { + const buckets = [...held, ...body.data] + const read = pages + 1 + if (!body.has_more) return Effect.succeed({ buckets, pages: read, complete: true }) + const next = body.next_page ?? "" + // More to read and no cursor to read it with: what was read is only part of the costs. + if (next === "") { + return Effect.fail( + new OpenAiCostsError({ + message: "OpenAI API says there is more and gives no next page", + kind: "invalid_response", + }), + ) + } + return read >= maxPages + ? Effect.succeed({ buckets, pages: read, complete: false }) + : from(next, buckets, read) + }) + return from(undefined, [], 0) +} diff --git a/packages/backend/src/services/cost/sync-plan.ts b/packages/backend/src/services/cost/sync-plan.ts index 1ad9435528..876dbad886 100644 --- a/packages/backend/src/services/cost/sync-plan.ts +++ b/packages/backend/src/services/cost/sync-plan.ts @@ -46,6 +46,11 @@ const GITHUB_HISTORY_MONTHS = 3 * this many months before the current one: a window from there to today is never over a year. */ const VERCEL_HISTORY_MONTHS = 11 +/** + * Anthropic's cost report and OpenAI's costs endpoint do not document how far back they serve. + * History stops at the first day of the month this many months back, a conservative start. + */ +const MODEL_API_HISTORY_MONTHS = 3 /** PlanetScale invoices are read back to the first day of the month this many months before the current one. */ const PLANETSCALE_HISTORY_MONTHS = 12 // Through this day of the month the previous month's bill is still open; the first settle after @@ -102,6 +107,8 @@ const COST_SYNC_PROFILES = { lagDays: 0, head: "month", }, + anthropic: { floor: (today) => monthStart(today, -MODEL_API_HISTORY_MONTHS), lagDays: 0, head: "days" }, + openai: { floor: (today) => monthStart(today, -MODEL_API_HISTORY_MONTHS), lagDays: 0, head: "days" }, } as const satisfies Record /** The oldest day the sync pulls from `provider`. */ diff --git a/packages/domain/src/cost.ts b/packages/domain/src/cost.ts index 2dd341aad1..a52b11414a 100644 --- a/packages/domain/src/cost.ts +++ b/packages/domain/src/cost.ts @@ -31,6 +31,8 @@ export const CostProvider = Schema.Literals([ "vercel", "cloudflare", "planetscale", + "anthropic", + "openai", ]) export type CostProvider = typeof CostProvider.Type diff --git a/packages/domain/src/http/anthropic-cost.ts b/packages/domain/src/http/anthropic-cost.ts new file mode 100644 index 0000000000..436ed33878 --- /dev/null +++ b/packages/domain/src/http/anthropic-cost.ts @@ -0,0 +1,44 @@ +import { HttpApiEndpoint, HttpApiGroup } from "effect/http-api" +import { Schema } from "effect" +import { + CostConnectionForbiddenError, + CostConnectionPersistenceError, + CostConnectionRemoved, + CostConnectionSaved, + CostConnectionValidationError, +} from "./cost-connections" +import { SessionAuthorization } from "./current-tenant" +import { CloudCostDisabledError } from "./query-engine" + +/** Far above any admin key: it only bounds what the request carries. */ +export const ANTHROPIC_COST_KEY_MAX_LENGTH = 512 + +/** An Anthropic Admin API key (sk-ant-admin...). */ +export class AnthropicCostKeyRequest extends Schema.Class("AnthropicCostKeyRequest")( + { + apiKey: Schema.String.check(Schema.isMaxLength(ANTHROPIC_COST_KEY_MAX_LENGTH)), + }, +) {} + +/** Saving a key makes the org's Anthropic cost source, replacing one keeps it, removing deletes it. */ +export class AnthropicCostApiGroup extends HttpApiGroup.make("anthropicCost") + .add( + HttpApiEndpoint.put("set", "/", { + payload: AnthropicCostKeyRequest, + success: CostConnectionSaved, + error: [ + CostConnectionPersistenceError, + CostConnectionForbiddenError, + CostConnectionValidationError, + CloudCostDisabledError, + ], + }), + ) + .add( + HttpApiEndpoint.delete("remove", "/", { + success: CostConnectionRemoved, + error: [CostConnectionPersistenceError, CostConnectionForbiddenError], + }), + ) + .prefix("/internal/cost/anthropic") + .middleware(SessionAuthorization) {} diff --git a/packages/domain/src/http/index.ts b/packages/domain/src/http/index.ts index 4a5401beba..fdda2ea169 100644 --- a/packages/domain/src/http/index.ts +++ b/packages/domain/src/http/index.ts @@ -11,6 +11,8 @@ export * from "./openrouter-key" export * from "./openrouter-cost" export * from "./cost-connections" export * from "./cloudflare-cost" +export * from "./anthropic-cost" +export * from "./openai-cost" export * from "./github-cost" export * from "./planetscale-cost" export * from "./vercel-cost" diff --git a/packages/domain/src/http/internal-api.ts b/packages/domain/src/http/internal-api.ts index 69dae06ee7..2815599f58 100644 --- a/packages/domain/src/http/internal-api.ts +++ b/packages/domain/src/http/internal-api.ts @@ -7,7 +7,9 @@ import { BillingApiGroup } from "./billing" import { CodeReviewApiGroup } from "./code-review" import { DemoApiGroup } from "./demo" import { AuditTrailApiGroup } from "./audit-trail" +import { AnthropicCostApiGroup } from "./anthropic-cost" import { CloudflareCostApiGroup } from "./cloudflare-cost" +import { OpenAiCostApiGroup } from "./openai-cost" import { DependencyScanApiGroup } from "./dependency-scan" import { DigestApiGroup } from "./digest" import { ErrorsApiGroup } from "./errors" @@ -68,6 +70,8 @@ export class MapleInternalApi extends HttpApi.make("MapleInternalApi") .add(OpenRouterKeyApiGroup) .add(OpenRouterCostApiGroup) .add(CloudflareCostApiGroup) + .add(AnthropicCostApiGroup) + .add(OpenAiCostApiGroup) .add(GithubCostApiGroup) .add(PlanetScaleCostApiGroup) .add(VercelCostApiGroup) diff --git a/packages/domain/src/http/openai-cost.ts b/packages/domain/src/http/openai-cost.ts new file mode 100644 index 0000000000..762ffc08be --- /dev/null +++ b/packages/domain/src/http/openai-cost.ts @@ -0,0 +1,42 @@ +import { HttpApiEndpoint, HttpApiGroup } from "effect/http-api" +import { Schema } from "effect" +import { + CostConnectionForbiddenError, + CostConnectionPersistenceError, + CostConnectionRemoved, + CostConnectionSaved, + CostConnectionValidationError, +} from "./cost-connections" +import { SessionAuthorization } from "./current-tenant" +import { CloudCostDisabledError } from "./query-engine" + +/** Far above any admin key: it only bounds what the request carries. */ +export const OPENAI_COST_KEY_MAX_LENGTH = 512 + +/** An OpenAI Admin API key (sk-admin-...). */ +export class OpenAiCostKeyRequest extends Schema.Class("OpenAiCostKeyRequest")({ + apiKey: Schema.String.check(Schema.isMaxLength(OPENAI_COST_KEY_MAX_LENGTH)), +}) {} + +/** Saving a key makes the org's OpenAI cost source, replacing one keeps it, removing deletes it. */ +export class OpenAiCostApiGroup extends HttpApiGroup.make("openaiCost") + .add( + HttpApiEndpoint.put("set", "/", { + payload: OpenAiCostKeyRequest, + success: CostConnectionSaved, + error: [ + CostConnectionPersistenceError, + CostConnectionForbiddenError, + CostConnectionValidationError, + CloudCostDisabledError, + ], + }), + ) + .add( + HttpApiEndpoint.delete("remove", "/", { + success: CostConnectionRemoved, + error: [CostConnectionPersistenceError, CostConnectionForbiddenError], + }), + ) + .prefix("/internal/cost/openai") + .middleware(SessionAuthorization) {} From 5327e1a6f98582a1488eb30fdb4867c019b395ea Mon Sep 17 00:00:00 2001 From: Makisuo Date: Fri, 13 Nov 2026 21:42:59 +0100 Subject: [PATCH 2/6] fix(cost): review pass on the Anthropic and OpenAI sources An Anthropic key that reads the organization but is refused its costs is refused, not reported as an outage. Rows of one key in two currencies stay apart. Anthropic's inference geography is a tag: as a region it took another platform's mark. An OpenAI account reads as "OpenAI", since its stored name is its id. The three pasted-credential routes share their handler bodies, and the lab keys its credential changes by provider. --- .../routes/internal/anthropic-cost.http.ts | 43 +++------------ .../routes/internal/cloudflare-cost.http.ts | 43 +++------------ .../internal/cost-connection-handlers.ts | 52 +++++++++++++++++++ .../src/routes/internal/openai-cost.http.ts | 43 +++------------ apps/web/src/lab/cost-lab.ts | 4 +- apps/web/src/page/cost/credential-sources.ts | 10 ++-- apps/web/src/page/cost/marks.test.ts | 23 ++++++-- apps/web/src/page/cost/marks.ts | 3 +- .../AnthropicCostConnectionService.test.ts | 6 +++ .../cost/AnthropicCostConnectionService.ts | 3 +- .../providers/anthropic/normalize.test.ts | 10 ++-- .../cost/providers/anthropic/normalize.ts | 17 ++++-- .../cost/providers/openai/normalize.test.ts | 2 +- .../cost/providers/openai/normalize.ts | 8 ++- 14 files changed, 140 insertions(+), 127 deletions(-) create mode 100644 apps/api/src/routes/internal/cost-connection-handlers.ts diff --git a/apps/api/src/routes/internal/anthropic-cost.http.ts b/apps/api/src/routes/internal/anthropic-cost.http.ts index 82667f85de..325403b132 100644 --- a/apps/api/src/routes/internal/anthropic-cost.http.ts +++ b/apps/api/src/routes/internal/anthropic-cost.http.ts @@ -1,23 +1,8 @@ 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 { AnthropicCostConnectionService } from "@maple/backend/services/cost/AnthropicCostConnectionService" - -// 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 HttpAnthropicCostLive = HttpApiBuilder.group(MapleInternalApi, "anthropicCost", (handlers) => Effect.gen(function* () { @@ -25,26 +10,14 @@ export const HttpAnthropicCostLive = HttpApiBuilder.group(MapleInternalApi, "ant 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.apiKey) - yield* recordHttpAudit("cost_source_key.updated", { - metadata: { provider: "anthropic", accountId: saved.accountId }, - }) - return new CostConnectionSaved(saved) - }).pipe(Effect.withSpan("HttpAnthropicCost.set")), + saveCostConnection("anthropic", (orgId, userId) => + connections.set(orgId, userId, payload.apiKey), + ).pipe(Effect.withSpan("HttpAnthropicCost.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: "anthropic" }, - }) - return new CostConnectionRemoved({ removed: true }) - }).pipe(Effect.withSpan("HttpAnthropicCost.remove")), + removeCostConnection("anthropic", connections.remove).pipe( + Effect.withSpan("HttpAnthropicCost.remove"), + ), ) }), ) diff --git a/apps/api/src/routes/internal/cloudflare-cost.http.ts b/apps/api/src/routes/internal/cloudflare-cost.http.ts index f584dfeadb..fe0ccc15b5 100644 --- a/apps/api/src/routes/internal/cloudflare-cost.http.ts +++ b/apps/api/src/routes/internal/cloudflare-cost.http.ts @@ -1,23 +1,8 @@ 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* () { @@ -25,26 +10,14 @@ export const HttpCloudflareCostLive = HttpApiBuilder.group(MapleInternalApi, "cl 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"), + ), ) }), ) diff --git a/apps/api/src/routes/internal/cost-connection-handlers.ts b/apps/api/src/routes/internal/cost-connection-handlers.ts new file mode 100644 index 0000000000..320399a96a --- /dev/null +++ b/apps/api/src/routes/internal/cost-connection-handlers.ts @@ -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 = ( + 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 = ( + provider: CostProvider, + remove: (orgId: OrgId) => Effect.Effect, +) => + 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 }) + }) diff --git a/apps/api/src/routes/internal/openai-cost.http.ts b/apps/api/src/routes/internal/openai-cost.http.ts index 52f1091746..8ae4e03ef6 100644 --- a/apps/api/src/routes/internal/openai-cost.http.ts +++ b/apps/api/src/routes/internal/openai-cost.http.ts @@ -1,23 +1,8 @@ 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 { OpenAiCostConnectionService } from "@maple/backend/services/cost/OpenAiCostConnectionService" - -// 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 HttpOpenAiCostLive = HttpApiBuilder.group(MapleInternalApi, "openaiCost", (handlers) => Effect.gen(function* () { @@ -25,26 +10,14 @@ export const HttpOpenAiCostLive = HttpApiBuilder.group(MapleInternalApi, "openai 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.apiKey) - yield* recordHttpAudit("cost_source_key.updated", { - metadata: { provider: "openai", accountId: saved.accountId }, - }) - return new CostConnectionSaved(saved) - }).pipe(Effect.withSpan("HttpOpenAiCost.set")), + saveCostConnection("openai", (orgId, userId) => + connections.set(orgId, userId, payload.apiKey), + ).pipe(Effect.withSpan("HttpOpenAiCost.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: "openai" }, - }) - return new CostConnectionRemoved({ removed: true }) - }).pipe(Effect.withSpan("HttpOpenAiCost.remove")), + removeCostConnection("openai", connections.remove).pipe( + Effect.withSpan("HttpOpenAiCost.remove"), + ), ) }), ) diff --git a/apps/web/src/lab/cost-lab.ts b/apps/web/src/lab/cost-lab.ts index 41b5f01e8d..7f1349d33c 100644 --- a/apps/web/src/lab/cost-lab.ts +++ b/apps/web/src/lab/cost-lab.ts @@ -51,7 +51,7 @@ import { } from "../page/cost/command" import { Message as CostMessage } from "../page/cost/message" import { Model as CostModel, type Search } from "../page/cost/model" -import type { CredentialProvider } from "../page/cost/providers" +import { CredentialProvider } from "../page/cost/providers" import * as Cost from "../page/cost/update" import { view as costView } from "../page/cost/view" import type { Resources } from "../platform/resources" @@ -137,7 +137,7 @@ export const flags: Effect.Effect = Effect.sync(() => ({ export const Model = Schema.Struct({ state: LabState, /** Pasted-credential providers added (true) or removed (false) since the state loaded. */ - credentials: Schema.Record(Schema.String, Schema.Boolean), + credentials: Schema.Record(CredentialProvider, Schema.optionalKey(Schema.Boolean)), /** GitHub installations added (true) or removed (false) since the state loaded, by id. */ github: Schema.Record(Schema.String, Schema.Boolean), /** Vercel added (true) or removed (false) since the state loaded; null before either. */ diff --git a/apps/web/src/page/cost/credential-sources.ts b/apps/web/src/page/cost/credential-sources.ts index 7dfba88c78..7431444b62 100644 --- a/apps/web/src/page/cost/credential-sources.ts +++ b/apps/web/src/page/cost/credential-sources.ts @@ -32,6 +32,10 @@ const CLOUDFLARE_BILLING_TOKEN_URL = * A cost source added by pasting a credential: everything its dialog steps, its Sources row and * its toasts say, and the commands that save and remove it. The form itself is shared. */ +/** What adding a provider read back three months brings in, as the sync profile serves it. */ +const THREE_MONTHS_ADD = + "Maple reads this month and the last at once, then the two months before, and every day from here on." + export type CredentialSource = Readonly<{ /** The line under the provider's name in the list, and once its source exists. */ pick: Readonly<{ meta: string; addedMeta: string }> @@ -151,8 +155,7 @@ export const CREDENTIAL_SOURCES = { }, add: { title: "Add Anthropic", - description: - "Maple reads this month and the last at once, then the two months before, and every day from here on.", + description: THREE_MONTHS_ADD, toast: "Anthropic added", }, replace: { @@ -188,8 +191,7 @@ export const CREDENTIAL_SOURCES = { }, add: { title: "Add OpenAI", - description: - "Maple reads this month and the last at once, then the two months before, and every day from here on.", + description: THREE_MONTHS_ADD, toast: "OpenAI added", }, replace: { diff --git a/apps/web/src/page/cost/marks.test.ts b/apps/web/src/page/cost/marks.test.ts index d8cc1c0960..a3b7ac664a 100644 --- a/apps/web/src/page/cost/marks.test.ts +++ b/apps/web/src/page/cost/marks.test.ts @@ -17,8 +17,16 @@ const source = ( provider: CostSourceSummary["provider"], accountId: string, id = "0195f1c2-7a3b-7c4d-8e5f-6a7b8c9d0e1f", -): CostSourceSummary => - Schema.decodeSync(CostSourceSummary)({ +): CostSourceSummary => named(provider, accountId, id, undefined) + +/** A source whose account has the name the save stored for it (none when `accountName` is undefined). */ +const named = ( + provider: CostSourceSummary["provider"], + accountId: string, + id: string, + accountName: string | undefined, +): CostSourceSummary => { + const wire: typeof CostSourceSummary.Encoded = { id, provider, connectionRef: id, @@ -31,7 +39,9 @@ const source = ( lastError: null, lastErrorReason: null, requestsThisMonth: 0, - }) + } + return Schema.decodeSync(CostSourceSummary)(accountName === undefined ? wire : { ...wire, accountName }) +} const sources = [source("aws", "465760687006"), source("openrouter", "user_2zZ0ZRlIh83bwUonpAHz1xTcuG6")] @@ -49,7 +59,11 @@ describe("cost key marks", () => { }) test("Anthropic and OpenAI spend wears its model's or vendor's mark, never a platform's", () => { - const vendors = [source("anthropic", "org-uuid-1"), source("openai", "org-AbC123")] + // OpenAI's source is named by its id, as the save stores it. + const vendors = [ + named("anthropic", "org-uuid-1", "0195f1c2-7a3b-7c4d-8e5f-6a7b8c9d0e31", "Example Labs"), + named("openai", "org-AbC123", "0195f1c2-7a3b-7c4d-8e5f-6a7b8c9d0e32", "org-AbC123"), + ] const anthropic = keyMark("provider", "anthropic", [])?.icon const openai = keyMark("provider", "openai", [])?.icon // A Claude model wears its family's mark, as it does when OpenRouter bills it. @@ -58,6 +72,7 @@ describe("cost key marks", () => { expect(keyMark("cloud_service", "openai/gpt-5", vendors)?.icon).toBe(openai) expect(keyMark("account", "org-AbC123", vendors)?.icon).toBe(openai) expect(keyLabel("account", "org-AbC123", vendors)).toBe("OpenAI") + expect(keyLabel("account", "org-uuid-1", vendors)).toBe("Anthropic Example Labs") }) test("a model vendor beside one platform leaves that platform the owner of a name it does not know", () => { diff --git a/apps/web/src/page/cost/marks.ts b/apps/web/src/page/cost/marks.ts index 9c24a6daf6..3ca1d3fdc5 100644 --- a/apps/web/src/page/cost/marks.ts +++ b/apps/web/src/page/cost/marks.ts @@ -221,7 +221,8 @@ export const accountLabel = (key: string, sources: ReadonlyArray source.accountName), Option.getOrUndefined, ) - if (accountName !== undefined) return `${name} ${accountName}` + // OpenAI names an organization by its id alone, which is no name to add. + if (accountName !== undefined && accountName !== key) return `${name} ${accountName}` return sameProvider.length <= 1 ? name : `${name} ${key}` } diff --git a/packages/backend/src/services/cost/AnthropicCostConnectionService.test.ts b/packages/backend/src/services/cost/AnthropicCostConnectionService.test.ts index ebf4943302..aec981834d 100644 --- a/packages/backend/src/services/cost/AnthropicCostConnectionService.test.ts +++ b/packages/backend/src/services/cost/AnthropicCostConnectionService.test.ts @@ -176,6 +176,12 @@ describe("AnthropicCostConnectionService", () => { [noCosts._tag, noCosts.message], [VALIDATION, "This key cannot read the organization's costs. Use an Admin API key."], ) + // Refused its costs after it read the organization: a retry would not change that. + world.costStatus = 401 + assert.deepStrictEqual( + (yield* Effect.flip(set(KEY))).message, + "This key cannot read the organization's costs. Use an Admin API key.", + ) assert.isUndefined(yield* storedKey(testDb)) }), ) diff --git a/packages/backend/src/services/cost/AnthropicCostConnectionService.ts b/packages/backend/src/services/cost/AnthropicCostConnectionService.ts index 97d72b7ea1..3df82767cd 100644 --- a/packages/backend/src/services/cost/AnthropicCostConnectionService.ts +++ b/packages/backend/src/services/cost/AnthropicCostConnectionService.ts @@ -33,7 +33,8 @@ const toKeyError = if (step === "organization" && rejected) { return refused("Anthropic rejected this key. Use an Admin API key from the Claude Console.") } - if (step === "costs" && error.kind === "forbidden") { + // A key that read the organization and is then refused its costs is not an admin key's access. + if (step === "costs" && (error.kind === "forbidden" || error.kind === "unauthorized")) { return refused("This key cannot read the organization's costs. Use an Admin API key.") } return new CostConnectionPersistenceError({ message: UNCHECKED }) diff --git a/packages/backend/src/services/cost/providers/anthropic/normalize.test.ts b/packages/backend/src/services/cost/providers/anthropic/normalize.test.ts index 7011f1528c..de8601ce9a 100644 --- a/packages/backend/src/services/cost/providers/anthropic/normalize.test.ts +++ b/packages/backend/src/services/cost/providers/anthropic/normalize.test.ts @@ -72,16 +72,18 @@ describe("toCostRows", () => { ]) }) - it("puts the default workspace's lines on `default`, and a geography's on its region", () => { + it("puts the default workspace's lines on `default`, and a geography in a tag, not the region", () => { const rows = rowsOf([ costBucket("2026-11-03", [ costResult("100", { workspace_id: null }), costResult("100", { inference_geo: "us", currency: "usd" }), ]), ]) - expect(Arr.map(rows, (row) => [row.resourceId, row.region, row.currency])).toEqual([ - ["default", "global", "USD"], - ["wrkspc_01", "us", "USD"], + expect( + Arr.map(rows, (row) => [row.resourceId, row.region, row.currency, row.tags.inference_geo]), + ).toEqual([ + ["default", "global", "USD", undefined], + ["wrkspc_01", "global", "USD", "us"], ]) }) diff --git a/packages/backend/src/services/cost/providers/anthropic/normalize.ts b/packages/backend/src/services/cost/providers/anthropic/normalize.ts index e61fc04e0d..add9feed89 100644 --- a/packages/backend/src/services/cost/providers/anthropic/normalize.ts +++ b/packages/backend/src/services/cost/providers/anthropic/normalize.ts @@ -13,7 +13,7 @@ const TOOL_SERVICES = new Map([ ]) /** Fields that tell cost lines of one key apart without being part of it, kept as tags. */ -const TAG_FIELDS = ["service_tier", "token_type", "context_window", "speed"] as const +const TAG_FIELDS = ["service_tier", "token_type", "context_window", "speed", "inference_geo"] as const const dayOf = (timestamp: string): string => Str.slice(0, 10)(timestamp) @@ -47,7 +47,7 @@ const isAmount = (amount: string): boolean => Str.trim(amount) !== "" && Number. /** * The window's rows, or the first amount that is not a number. Lines of one key (day, service, - * usage type, region, workspace) are summed: the warehouse reads by that key, so two rows would + * usage type, region, workspace, currency) are summed: the warehouse reads by that key, so two rows would * count once. Days from `openDay` on can still change. */ export const toCostRows = ( @@ -73,7 +73,8 @@ export const toCostRows = ( cloudService: serviceOf(result), serviceCategory: CATEGORY, usageType: result.description ?? result.cost_type ?? "", - region: result.inference_geo ?? "global", + // The inference geography is a price tier, not a place the Cost page can name: it is a tag. + region: "global", resourceId: result.workspace_id ?? "default", resourceType: "workspace", serviceName: "", @@ -90,7 +91,15 @@ export const toCostRows = ( } }) const groups = Arr.groupBy(rows, (row) => - keyOf([row.day, row.cloudService, row.usageType, row.region, row.resourceId, row.chargeCategory]), + keyOf([ + row.day, + row.cloudService, + row.usageType, + row.region, + row.resourceId, + row.chargeCategory, + row.currency, + ]), ) return Result.succeed( Arr.map(Rec.values(groups), (group): CostPullRow => { diff --git a/packages/backend/src/services/cost/providers/openai/normalize.test.ts b/packages/backend/src/services/cost/providers/openai/normalize.test.ts index 6ab52756f2..7e06e5c2e4 100644 --- a/packages/backend/src/services/cost/providers/openai/normalize.test.ts +++ b/packages/backend/src/services/cost/providers/openai/normalize.test.ts @@ -111,7 +111,7 @@ describe("toCostRows", () => { ]) }) - it("keeps only the window's days, and marks the newest two as estimated", () => { + it("keeps only the window's days, and marks the days from the open day on as estimated", () => { const mapped = okRows([ bucket("2026-09-30", [charge("gpt-4o, input", 9)]), bucket("2026-10-01", [charge("gpt-4o, input", 1)]), diff --git a/packages/backend/src/services/cost/providers/openai/normalize.ts b/packages/backend/src/services/cost/providers/openai/normalize.ts index b0a83364b5..d7a397ed01 100644 --- a/packages/backend/src/services/cost/providers/openai/normalize.ts +++ b/packages/backend/src/services/cost/providers/openai/normalize.ts @@ -71,7 +71,13 @@ export const toCostRows = ( } // A day's rows are replaced whole and read per key, so two rows of one key would count once. const groups = Arr.groupBy(charges, (charge) => - keyOf([charge.day, serviceOf(charge.lineItem), charge.lineItem ?? "", charge.projectId ?? ""]), + keyOf([ + charge.day, + serviceOf(charge.lineItem), + charge.lineItem ?? "", + charge.projectId ?? "", + charge.currency, + ]), ) return Result.succeed( Arr.map(Rec.values(groups), (group): CostPullRow => { From 2f27ebf9c24572dac4d7956502c3a6458bd3a643 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Fri, 13 Nov 2026 21:43:13 +0100 Subject: [PATCH 3/6] docs(cost): Anthropic's inference geography is a tag --- docs/cloud-cost-plan.md | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/docs/cloud-cost-plan.md b/docs/cloud-cost-plan.md index 78e6a7c97a..86c4c81e2e 100644 --- a/docs/cloud-cost-plan.md +++ b/docs/cloud-cost-plan.md @@ -486,16 +486,16 @@ The billing API facts, which hold for either credential: ### Anthropic (billed, Claude API only) -- Read with an Admin API key (`sk-ant-admin...`) the org pastes, kept in `cost_connections`. - `GET /v1/organizations/me` names the organization (its id is the account, its name the label). -- `GET /v1/organizations/cost_report`, daily buckets grouped by `workspace_id` and `description`, - 31 buckets a page, followed by `next_page`. `amount` is a decimal string in cents. The docs call - it legacy beside `/v1/organizations/analytics/cost`, which Console organizations only get as an +- Read with an Admin API key (`sk-ant-admin...`) the org pastes, kept in `cost_connections`. `GET + /v1/organizations/me` names the organization (its id is the account, its name the label). +- `GET /v1/organizations/cost_report`, daily buckets grouped by `workspace_id` and `description`, 31 + buckets a page, followed by `next_page`. `amount` is a decimal string in cents. The docs call it + legacy beside `/v1/organizations/analytics/cost`, which Console organizations only get as an early-access preview, so the legacy report is the one every org can read. - Rows: a model's lines are `anthropic/` (the Cost page shows the model's mark), web search, code execution and Managed Agents sessions their own names, anything else `Anthropic API`. The - description is the usage type, the workspace the resource (`default` for the default - workspace), the inference geography the region. + description is the usage type, the workspace the resource (`default` for the default workspace), + and the inference geography a tag. - Not covered: Claude subscriptions billed through claude.ai, and Claude on Bedrock, Vertex AI or Foundry, which bill through those clouds. From 32ad1bef4e5436b6156cf141d03267486f225dde Mon Sep 17 00:00:00 2001 From: Makisuo Date: Fri, 13 Nov 2026 21:58:43 +0100 Subject: [PATCH 4/6] fix(cost): Anthropic's mark in the foreground tone, like OpenAI's and OpenRouter's --- apps/web/src/page/cost/providers.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/web/src/page/cost/providers.ts b/apps/web/src/page/cost/providers.ts index b27d1b549a..fb20d39773 100644 --- a/apps/web/src/page/cost/providers.ts +++ b/apps/web/src/page/cost/providers.ts @@ -62,6 +62,7 @@ export const COST_PROVIDERS = { anthropic: { name: "Anthropic", icon: "AnthropicIcon", + iconClassName: "text-foreground", requestUsd: null, }, openai: { From a4c626be1f1dde405fb4850d3ee7213078fda015 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Fri, 13 Nov 2026 22:02:59 +0100 Subject: [PATCH 5/6] fix(cost): repeated page cursors, OpenAI's documented organization header, re-added lab sources A next page cursor equal to the page just read fails the pull at once instead of reading the same page up to the cap, for Anthropic and OpenAI. OpenAI's organization comes from the documented openai-organization header of the costs answer first, then from the organization_id its results carry. A key whose costs exist but name no organization is refused as that, not as no spend. In the cost lab, a pasted-credential source removed and added again is a new source waiting for its sync, without the old one's cost. --- apps/web/src/lab/cost-fixture.test.ts | 15 +++++--- apps/web/src/lab/cost-fixture.ts | 34 ++++++++++++++---- apps/web/src/lab/cost-lab.ts | 9 +++-- docs/cloud-cost-plan.md | 10 +++--- .../cost/OpenAiCostConnectionService.test.ts | 36 +++++++++++++++++++ .../cost/OpenAiCostConnectionService.ts | 29 +++++++++------ .../anthropic/AnthropicCostProvider.test.ts | 7 ++++ .../anthropic/AnthropicCostProvider.ts | 6 ++-- .../providers/anthropic/fake-anthropic.ts | 9 ++++- .../openai/OpenAiCostProvider.test.ts | 10 ++++++ .../cost/providers/openai/fake-openai.ts | 19 ++++++++-- .../cost/providers/openai/openai-costs-api.ts | 31 +++++++++++----- 12 files changed, 174 insertions(+), 41 deletions(-) diff --git a/apps/web/src/lab/cost-fixture.test.ts b/apps/web/src/lab/cost-fixture.test.ts index ac7339b597..d90811e428 100644 --- a/apps/web/src/lab/cost-fixture.test.ts +++ b/apps/web/src/lab/cost-fixture.test.ts @@ -3,6 +3,8 @@ import { CredentialProvider } from "../page/cost/providers" import { describe, expect, test } from "vitest" import { LAB_SOURCES, + type LabCredentialChange, + credentialChangeAfter, labBreakdown, labGithubInstallations, labRemovedProviders, @@ -48,9 +50,9 @@ describe("cost lab fixture", () => { test.each(CredentialProvider.literals)( "%s is added waiting for its first sync, and a removed one takes its cost with it", (provider) => { - const outcome = (added: boolean | null, from = LAB_SOURCES.synced) => { + const outcome = (change: LabCredentialChange | null, from = LAB_SOURCES.synced) => { const changes = { - credentials: added === null ? {} : { [provider]: added }, + credentials: change === null ? {} : { [provider]: change }, github: {}, vercel: null, planetScale: null, @@ -67,8 +69,13 @@ describe("cost lab fixture", () => { } } expect(outcome(null)).toEqual({ sources: ["ok"], costRemoved: false }) - expect(outcome(false)).toEqual({ sources: [], costRemoved: true }) - expect(outcome(true, LAB_SOURCES.none)).toEqual({ sources: ["pending"], 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 }) }, ) diff --git a/apps/web/src/lab/cost-fixture.ts b/apps/web/src/lab/cost-fixture.ts index 90c6d054e0..cd0162d646 100644 --- a/apps/web/src/lab/cost-fixture.ts +++ b/apps/web/src/lab/cost-fixture.ts @@ -561,12 +561,27 @@ export const labGithubLogin = (installationId: string): string => ) /** - * What the lab's reader did since the state loaded. `credentials`: a pasted-credential provider to - * whether it was added (true) or removed (false), absent for neither; `vercel` and `planetScale` - * the same, null for neither. `github`: an installation's id to whether it is a source now. +/** + * What happened to a pasted-credential source: `added` (or its credential replaced), `removed`, or + * `readded` after a removal, which is a new source with none of the old one's cost. + */ +export const LabCredentialChange = Schema.Literals(["added", "removed", "readded"]) +export type LabCredentialChange = typeof LabCredentialChange.Type + +/** The change a save (`isSource`) or a removal makes to a provider's `previous` one. */ +export const credentialChangeAfter = ( + previous: LabCredentialChange | undefined, + isSource: boolean, +): LabCredentialChange => + !isSource ? "removed" : previous === "removed" || previous === "readded" ? "readded" : "added" + +/** + * What the lab's reader did since the state loaded. `credentials`: a pasted-credential provider's + * change, absent for none; `vercel` and `planetScale`: added (true), removed (false) or neither + * (null). `github`: an installation's id to whether it is a source now. */ export type LabSourceChanges = Readonly<{ - credentials: Readonly>> + credentials: Readonly>> github: Readonly> vercel: boolean | null planetScale: boolean | null @@ -580,7 +595,9 @@ export const labRemovedProviders = (changes: LabSourceChanges): ReadonlyArray - (isCredentialProvider(provider) && changes.credentials[provider] === false) || + (isCredentialProvider(provider) && + (changes.credentials[provider] === "removed" || + changes.credentials[provider] === "readded")) || (provider === "github" && Rec.has(changes.github, LAB_GITHUB_INSTALLATION)) || // A team added again is a new source: it has no cost until a sync the lab does not model. (provider === "vercel" && changes.vercel !== null) || @@ -598,7 +615,8 @@ export const labSourcesWith = ( ): CostSourcesResponse => { const kept = Arr.filter(Schema.encodeSync(CostSourcesResponse)(sources).sources, (entry) => isCredentialProvider(entry.provider) - ? changes.credentials[entry.provider] !== false + ? changes.credentials[entry.provider] === undefined || + changes.credentials[entry.provider] === "added" : entry.provider === "vercel" ? changes.vercel === null : entry.provider === "planetscale" @@ -612,7 +630,9 @@ export const labSourcesWith = ( ...Arr.map( Arr.filter( CredentialProvider.literals, - (provider) => changes.credentials[provider] === true && !has(provider), + (provider) => + changes.credentials[provider] === "readded" || + (changes.credentials[provider] === "added" && !has(provider)), ), (provider) => justAdded(CREDENTIAL_SOURCE_OF[provider]), ), diff --git a/apps/web/src/lab/cost-lab.ts b/apps/web/src/lab/cost-lab.ts index 7f1349d33c..63c080b656 100644 --- a/apps/web/src/lab/cost-lab.ts +++ b/apps/web/src/lab/cost-lab.ts @@ -63,7 +63,9 @@ import { LAB_TODAY, LAB_PLANETSCALE_ORGANIZATION, LAB_VERCEL_TEAM, + LabCredentialChange, type LabSourceChanges, + credentialChangeAfter, labBreakdown, labGithubInstallations, labGithubLogin, @@ -137,7 +139,7 @@ export const flags: Effect.Effect = Effect.sync(() => ({ export const Model = Schema.Struct({ state: LabState, /** Pasted-credential providers added (true) or removed (false) since the state loaded. */ - credentials: Schema.Record(CredentialProvider, Schema.optionalKey(Schema.Boolean)), + credentials: Schema.Record(CredentialProvider, Schema.optionalKey(LabCredentialChange)), /** GitHub installations added (true) or removed (false) since the state loaded, by id. */ github: Schema.Record(Schema.String, Schema.Boolean), /** Vercel added (true) or removed (false) since the state loaded; null before either. */ @@ -641,7 +643,10 @@ const changesAfter = (changes: LabSourceChanges, answered: ReadonlyArray ({ ...current, - credentials: { ...current.credentials, [provider]: isSource }, + credentials: { + ...current.credentials, + [provider]: credentialChangeAfter(current.credentials[provider], isSource), + }, }) switch (answer._tag) { case "CompletedSaveCredential": diff --git a/docs/cloud-cost-plan.md b/docs/cloud-cost-plan.md index 86c4c81e2e..857c0c3c71 100644 --- a/docs/cloud-cost-plan.md +++ b/docs/cloud-cost-plan.md @@ -503,10 +503,12 @@ The billing API facts, which hold for either credential: - Read with an Admin API key (`sk-admin-...`), kept in `cost_connections`. `GET /v1/organization/costs`, daily buckets grouped by `project_id` and `line_item`, 180 a page. -- No endpoint names the organization an admin key belongs to. The org id comes from - `organization_id` on the key's own cost results, so a save reads the last 180 days and refuses - an organization with no spend in them (nothing to show yet) or results of two organizations. - The label is the id: OpenAI reports no name. +- No endpoint answers an admin key with its organization. A save reads the last 180 days of costs + and takes the organization from the `openai-organization` response header (documented as the + organization of the request), else from the one `organization_id` the results carry (seen on real + answers, not in the schema). A key with neither is refused: with no spend in those days as + having nothing to read yet, with spend as an organization OpenAI did not name. Results of two + organizations are refused too. The label is the id. - Rows: a line item `, ` is `openai/`, any other `openai/`. The line item is the usage type, the project the resource, the quantity and its unit kept. diff --git a/packages/backend/src/services/cost/OpenAiCostConnectionService.test.ts b/packages/backend/src/services/cost/OpenAiCostConnectionService.test.ts index 9f05fe8c95..a106407772 100644 --- a/packages/backend/src/services/cost/OpenAiCostConnectionService.test.ts +++ b/packages/backend/src/services/cost/OpenAiCostConnectionService.test.ts @@ -189,6 +189,42 @@ describe("OpenAiCostConnectionService", () => { ) }) + it.effect("takes the organization from the openai-organization header first, also with no spend", () => { + const testDb = createTestDb(trackedDbs) + const world = makeFakeOpenAi({ organizationHeader: "org-FromHeader0000" }) + return run( + testDb, + world, + Effect.gen(function* () { + // No spend, but OpenAI names the organization: there is an account to read from. + assert.deepStrictEqual(yield* set(KEY), { + accountId: "org-FromHeader0000", + accountLabel: "org-FromHeader0000", + }) + }), + ) + }) + + it.effect("refuses a key with spend whose organization OpenAI does not name, saying so", () => { + const testDb = createTestDb(trackedDbs) + const world = makeFakeOpenAi({ costs: [{ day: "2026-10-20", value: 1, organizationId: "" }] }) + return run( + testDb, + world, + Effect.gen(function* () { + const error = yield* Effect.flip(set(KEY)) + assert.deepStrictEqual( + [error._tag, error.message], + [ + VALIDATION, + "OpenAI did not say which organization this key belongs to, so Maple cannot add it yet.", + ], + ) + assert.isUndefined(yield* storedKey(testDb)) + }), + ) + }) + it.effect( "refuses a key whose costs name two organizations, since either could be the one it reads", () => { diff --git a/packages/backend/src/services/cost/OpenAiCostConnectionService.ts b/packages/backend/src/services/cost/OpenAiCostConnectionService.ts index 1f9d38e421..af43716af4 100644 --- a/packages/backend/src/services/cost/OpenAiCostConnectionService.ts +++ b/packages/backend/src/services/cost/OpenAiCostConnectionService.ts @@ -65,8 +65,8 @@ export class OpenAiCostConnectionService extends Context.Service< const httpClient = yield* HttpClient.HttpClient /** - * The organization the key reads. No OpenAI endpoint names it, so it comes off the key's own - * cost results: a key whose organization has spent nothing has none to show yet. + * The organization the key reads: the documented `openai-organization` header of the costs + * answer, else the one `organization_id` its results carry. No other OpenAI endpoint names it. */ const validateWithOpenAi = Effect.fn("OpenAiCostConnectionService.validateWithOpenAi")(function* ( key: Redacted.Redacted, @@ -83,7 +83,7 @@ export class OpenAiCostConnectionService extends Context.Service< }, MAX_PULL_PAGES, ).pipe(Effect.mapError(toKeyError)) - const organizations = Arr.dedupe( + const fromResults = Arr.dedupe( Arr.flatMap(listing.buckets, (bucket) => Arr.flatMap(bucket.results, (result) => result.organization_id === undefined || @@ -94,22 +94,31 @@ export class OpenAiCostConnectionService extends Context.Service< ), ), ) + const charges = Arr.some(listing.buckets, (bucket) => Arr.isReadonlyArrayNonEmpty(bucket.results)) yield* Effect.annotateCurrentSpan({ "maple.cost.openai.pages": listing.pages, - "maple.cost.openai.organizations": organizations.length, + "maple.cost.openai.organization_header": listing.organization !== undefined, + "maple.cost.openai.organizations": fromResults.length, + "maple.cost.openai.has_results": charges, }) - const [organization] = organizations - if (organization === undefined) { - return yield* refused( - "This OpenAI organization has no API spend in the last 180 days, so there is nothing to read yet.", - ) + // The documented `openai-organization` header first; else the one organization the results name. + if (listing.organization !== undefined) { + return { accountId: listing.organization, accountLabel: listing.organization } } // Results of two organizations leave no telling which one the key belongs to. - if (organizations.length > 1) { + if (fromResults.length > 1) { return yield* refused( "OpenAI reports costs of more than one organization for this key, so Maple cannot tell which one it reads.", ) } + const [organization] = fromResults + if (organization === undefined) { + return yield* refused( + charges + ? "OpenAI did not say which organization this key belongs to, so Maple cannot add it yet." + : "This OpenAI organization has no API spend in the last 180 days, so there is nothing to read yet.", + ) + } return { accountId: organization, accountLabel: organization } }) diff --git a/packages/backend/src/services/cost/providers/anthropic/AnthropicCostProvider.test.ts b/packages/backend/src/services/cost/providers/anthropic/AnthropicCostProvider.test.ts index 5800ef34be..58aaab1036 100644 --- a/packages/backend/src/services/cost/providers/anthropic/AnthropicCostProvider.test.ts +++ b/packages/backend/src/services/cost/providers/anthropic/AnthropicCostProvider.test.ts @@ -123,6 +123,13 @@ describe("AnthropicCostProvider", () => { const error = yield* Effect.flip(pull(ORG, connectionRef)) assert.deepStrictEqual(reasonOf(error), ["too_large", 0, "anthropic"]) assert.strictEqual(world.urls.length, 60) + + // A next page that is the page just read would repeat it: the pull stops at once. + world.urls.length = 0 + world.repeatCursor = true + const repeated = yield* Effect.flip(pull(ORG, connectionRef)) + assert.deepStrictEqual(reasonOf(repeated), ["invalid_response", 0, "anthropic"]) + assert.strictEqual(world.urls.length, 2) }), ) }) diff --git a/packages/backend/src/services/cost/providers/anthropic/AnthropicCostProvider.ts b/packages/backend/src/services/cost/providers/anthropic/AnthropicCostProvider.ts index b37787842b..793e891928 100644 --- a/packages/backend/src/services/cost/providers/anthropic/AnthropicCostProvider.ts +++ b/packages/backend/src/services/cost/providers/anthropic/AnthropicCostProvider.ts @@ -82,8 +82,10 @@ export class AnthropicCostProvider extends Context.Service | string /** A report that always says there is another page. */ endless: boolean + /** A report whose next page is the one just asked for. */ + repeatCursor: boolean /** Whether the org is in the `cloudCost` rollout. */ cloudCost: boolean readonly urls: Array @@ -39,6 +41,7 @@ export const makeFakeAnthropic = (overrides: Partial = {}): FakeA costStatus: 200, buckets: [], endless: false, + repeatCursor: false, cloudCost: true, urls: [], headers: [], @@ -87,7 +90,11 @@ const costAnswer = (world: FakeAnthropic, params: URLSearchParams): Response => return Response.json({ data, has_more: hasMore, - next_page: hasMore ? `page_${offset + limit}` : null, + next_page: hasMore + ? world.repeatCursor + ? (params.get("page") ?? "page_0") + : `page_${offset + limit}` + : null, }) } diff --git a/packages/backend/src/services/cost/providers/openai/OpenAiCostProvider.test.ts b/packages/backend/src/services/cost/providers/openai/OpenAiCostProvider.test.ts index afac875eac..3061dde4f9 100644 --- a/packages/backend/src/services/cost/providers/openai/OpenAiCostProvider.test.ts +++ b/packages/backend/src/services/cost/providers/openai/OpenAiCostProvider.test.ts @@ -124,6 +124,16 @@ describe("OpenAiCostProvider", () => { (yield* Effect.flip(pull(ORG, connectionRef))).reason, "invalid_response", ) + + // A next page that is the page just read would repeat it: the pull stops at once. + world.dropCursor = false + world.repeatCursor = true + world.urls.length = 0 + assert.strictEqual( + (yield* Effect.flip(pull(ORG, connectionRef))).reason, + "invalid_response", + ) + assert.lengthOf(world.urls, 2) }), ) }, diff --git a/packages/backend/src/services/cost/providers/openai/fake-openai.ts b/packages/backend/src/services/cost/providers/openai/fake-openai.ts index c3ac94d625..a230e4605c 100644 --- a/packages/backend/src/services/cost/providers/openai/fake-openai.ts +++ b/packages/backend/src/services/cost/providers/openai/fake-openai.ts @@ -34,6 +34,10 @@ export interface FakeOpenAi { pageSize: number /** Answers `has_more` with no `next_page`. */ dropCursor: boolean + /** Answers `has_more` with the cursor it was asked with, so reading on repeats the page. */ + repeatCursor: boolean + /** The `openai-organization` header every answer carries; none when undefined. */ + organizationHeader: string | undefined /** Whether the org is in the `cloudCost` rollout. */ cloudCost: boolean readonly urls: Array @@ -46,6 +50,8 @@ export const makeFakeOpenAi = (overrides: Partial = {}): FakeOpenAi organizationId: FAKE_OPENAI_ORG, pageSize: 180, dropCursor: false, + repeatCursor: false, + organizationHeader: undefined, cloudCost: true, urls: [], authorizations: [], @@ -108,7 +114,12 @@ const costsAnswer = (world: FakeOpenAi, costs: ReadonlyArray, params: object: "page", data: days === 0 ? [] : buckets, has_more: hasMore, - next_page: hasMore && !world.dropCursor ? `page_${next}` : null, + next_page: + hasMore && !world.dropCursor + ? world.repeatCursor + ? (params.get("page") ?? "page_0") + : `page_${next}` + : null, }) } @@ -126,7 +137,11 @@ const fetchStub = ) } if (typeof world.costs === "string") return new Response(world.costs, { status: 200 }) - return costsAnswer(world, world.costs, url.searchParams) + const answer = costsAnswer(world, world.costs, url.searchParams) + if (world.organizationHeader !== undefined) { + answer.headers.set("openai-organization", world.organizationHeader) + } + return answer } /** Everything the OpenAI cost services need, over the fake and a test database. */ diff --git a/packages/backend/src/services/cost/providers/openai/openai-costs-api.ts b/packages/backend/src/services/cost/providers/openai/openai-costs-api.ts index 89ea6bb409..bea01515f3 100644 --- a/packages/backend/src/services/cost/providers/openai/openai-costs-api.ts +++ b/packages/backend/src/services/cost/providers/openai/openai-costs-api.ts @@ -49,7 +49,7 @@ export const CostResult = Schema.Struct({ ), line_item: Schema.optionalKey(Schema.NullOr(Schema.String)), project_id: Schema.optionalKey(Schema.NullOr(Schema.String)), - /** Not in the documented schema, but on real answers: the only place a key's organization shows. */ + /** Not in the documented schema, but on real answers; the `openai-organization` header comes first. */ organization_id: Schema.optionalKey(Schema.NullOr(Schema.String)), quantity: Schema.optionalKey(Schema.NullOr(Schema.Number)), quantity_unit: Schema.optionalKey(Schema.NullOr(Schema.String)), @@ -110,7 +110,13 @@ const costsPage = ( failed("invalid_response", "answered with a body that is not JSON"), ), ) - return yield* Schema.decodeUnknownEffect(CostPage)(body) + const page = yield* Schema.decodeUnknownEffect(CostPage)(body) + // Documented as the organization associated with the request. + const organization = response.headers["openai-organization"] + return { + page, + organization: organization === undefined || organization === "" ? undefined : organization, + } }).pipe( Effect.timeout(REQUEST_TIMEOUT), Effect.catchTags({ @@ -136,6 +142,8 @@ export interface CostListing { readonly pages: number /** False when more pages were left than `maxPages` allowed. */ readonly complete: boolean + /** The `openai-organization` header of the first page, when OpenAI sent one. */ + readonly organization: string | undefined } /** Every bucket of the query, following `next_page` one page at a time up to `maxPages`. */ @@ -149,14 +157,19 @@ export const listCosts = ( page: string | undefined, held: ReadonlyArray, pages: number, + organization: string | undefined, ): Effect.Effect => - Effect.flatMap(costsPage(httpClient, key, query, page), (body) => { + Effect.flatMap(costsPage(httpClient, key, query, page), (answer) => { + const body = answer.page const buckets = [...held, ...body.data] const read = pages + 1 - if (!body.has_more) return Effect.succeed({ buckets, pages: read, complete: true }) + const org = organization ?? answer.organization + if (!body.has_more) + return Effect.succeed({ buckets, pages: read, complete: true, organization: org }) const next = body.next_page ?? "" - // More to read and no cursor to read it with: what was read is only part of the costs. - if (next === "") { + // More to read and no cursor to read it with, or the same page again: reading on would only + // repeat it, so what was read is only part of the costs. + if (next === "" || next === page) { return Effect.fail( new OpenAiCostsError({ message: "OpenAI API says there is more and gives no next page", @@ -165,8 +178,8 @@ export const listCosts = ( ) } return read >= maxPages - ? Effect.succeed({ buckets, pages: read, complete: false }) - : from(next, buckets, read) + ? Effect.succeed({ buckets, pages: read, complete: false, organization: org }) + : from(next, buckets, read, org) }) - return from(undefined, [], 0) + return from(undefined, [], 0, undefined) } From effdb8eaa2d1e3617acdc7aaa0b8413d3060251c Mon Sep 17 00:00:00 2001 From: Makisuo Date: Fri, 13 Nov 2026 22:08:49 +0100 Subject: [PATCH 6/6] fix(cost): OpenAI costs group_by in the bracketed form OpenAI's SDKs send --- .../services/cost/providers/openai/OpenAiCostProvider.test.ts | 2 +- .../backend/src/services/cost/providers/openai/fake-openai.ts | 3 ++- .../src/services/cost/providers/openai/openai-costs-api.ts | 3 ++- 3 files changed, 5 insertions(+), 3 deletions(-) diff --git a/packages/backend/src/services/cost/providers/openai/OpenAiCostProvider.test.ts b/packages/backend/src/services/cost/providers/openai/OpenAiCostProvider.test.ts index 3061dde4f9..f9d7f4893c 100644 --- a/packages/backend/src/services/cost/providers/openai/OpenAiCostProvider.test.ts +++ b/packages/backend/src/services/cost/providers/openai/OpenAiCostProvider.test.ts @@ -68,7 +68,7 @@ describe("OpenAiCostProvider", () => { const connectionRef = yield* saveKey(testDb, world) world.pageSize = 10 const pulled = yield* pull(ORG, connectionRef) - const query = `start_time=${unix("2026-10-20")}&end_time=${unix("2026-11-11")}&bucket_width=1d&group_by=project_id&group_by=line_item&limit=180` + const query = `start_time=${unix("2026-10-20")}&end_time=${unix("2026-11-11")}&bucket_width=1d&group_by%5B%5D=project_id&group_by%5B%5D=line_item&limit=180` // 22 days at 10 a page, each next page by the cursor the one before gave. assert.deepStrictEqual(world.urls, [ `/v1/organization/costs?${query}`, diff --git a/packages/backend/src/services/cost/providers/openai/fake-openai.ts b/packages/backend/src/services/cost/providers/openai/fake-openai.ts index a230e4605c..9adb90e97f 100644 --- a/packages/backend/src/services/cost/providers/openai/fake-openai.ts +++ b/packages/backend/src/services/cost/providers/openai/fake-openai.ts @@ -91,7 +91,8 @@ const resultsOf = (world: FakeOpenAi, costs: ReadonlyArray, groupBy: R const costsAnswer = (world: FakeOpenAi, costs: ReadonlyArray, params: URLSearchParams) => { const start = Number(params.get("start_time") ?? "0") const end = Number(params.get("end_time") ?? `${start + DAY_SECONDS}`) - const groupBy = params.getAll("group_by") + // Only the bracketed name groups, so a request that sends another gets ungrouped results. + const groupBy = params.getAll("group_by[]") const size = Math.min(world.pageSize, Number(params.get("limit") ?? "7")) const offset = Number(Str.replace("page_", "")(params.get("page") ?? "page_0")) const days = Math.max(0, Math.ceil((end - start) / DAY_SECONDS)) diff --git a/packages/backend/src/services/cost/providers/openai/openai-costs-api.ts b/packages/backend/src/services/cost/providers/openai/openai-costs-api.ts index bea01515f3..6c252d943a 100644 --- a/packages/backend/src/services/cost/providers/openai/openai-costs-api.ts +++ b/packages/backend/src/services/cost/providers/openai/openai-costs-api.ts @@ -90,7 +90,8 @@ const costsPage = ( start_time: query.startTime, end_time: query.endTime, bucket_width: "1d", - group_by: query.groupBy, + // Bracketed, as OpenAI's own SDKs encode an array parameter. + "group_by[]": query.groupBy, limit: MAX_BUCKETS_PER_PAGE, page, })