Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 37 additions & 1 deletion apps/ai/src/chat/turn-metering.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,9 @@ import { ConfigProvider, Effect, Logger, Schema, Array as Arr, String as Str } f
import { FetchHttpClient } from "effect/http"
import { afterEach, assert, beforeEach, describe, it } from "vitest"
import type { AiModelSpend } from "@maple/backend/services/billing/autumn-tracker"
import { PrReviewId } from "@maple/domain/http"
import { ownBillingCustomer } from "@maple/backend/services/billing/billing-customer"
import { meterTurn } from "./turn-runner"
import { meterTurn, recordReviewCost } from "./turn-runner"

const ORG = "org_test"
const INVESTIGATION = "0199a4d1-9f3c-7c8e-b2a1-3f5e7d9c1b40"
Expand Down Expand Up @@ -449,3 +450,38 @@ describe("meterTurn AI credits", () => {
)
})
})

describe("recordReviewCost", () => {
const REVIEW = Schema.decodeSync(PrReviewId)("00000000-0000-4000-9000-000000000001")
const org = Schema.decodeSync(OrgId)(ORG)
const record = (reviewId: typeof REVIEW | undefined, charges: Array<AiModelSpend>, ownKey: boolean) => {
const added: Array<number> = []
const reviews = {
addCost: (_orgId: OrgId, _reviewId: typeof REVIEW, cost: number) =>
Effect.sync(() => void added.push(cost)),
}
return Effect.runPromise(recordReviewCost(reviews, org, reviewId, { charges }, ownKey)).then(
() => added,
)
}
const priced = (providerCostMicrousd: number): AiModelSpend => ({
...charge(GLM, 1000, 100),
providerCostMicrousd,
})

it("adds the reported cost of every priced call to the review", async () => {
assert.deepEqual(
await record(REVIEW, [priced(1_000), charge(SONNET, 10, 10), priced(250)], false),
[1_250],
)
})

it("records 0 on the org's own key, which Maple does not bill", async () => {
assert.deepEqual(await record(REVIEW, [priced(1_000)], true), [0])
})

it("records nothing outside a review, or when no call was priced", async () => {
assert.deepEqual(await record(undefined, [priced(1_000)], false), [])
assert.deepEqual(await record(REVIEW, [charge(GLM, 1000, 100)], false), [])
})
})
43 changes: 36 additions & 7 deletions apps/ai/src/chat/turn-runner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,9 @@
* its *own* runtime, so it just resolves `InvestigationService` without a cycle.
*/
import { type ChatTurnOrigin, type ChatTurnTenantEncoded } from "@maple/domain/chat-session"
import type { BillingCustomerId, PrReviewFailureReason } from "@maple/domain/http"
import type { BillingCustomerId, PrReviewFailureReason, PrReviewId } from "@maple/domain/http"
import type { PrReviewServiceApi } from "@maple/backend/services/pr-review/PrReviewService"
import type { OrgId } from "@maple/primitives"
import { CodeLoader } from "@maple/backend/platform/bindings"
import { forkRequestScoped } from "@maple/backend/platform/fork-request-scoped"
import { Effect, Layer, ManagedRuntime, Match, Option, Redacted, Array as Arr, String as Str } from "effect"
Expand Down Expand Up @@ -45,7 +47,7 @@ import {
type ReviewTurnState,
withPartialReason,
} from "./review/completion"
import { makeRunUsage, type RunUsage } from "./usage"
import { makeRunUsage, reportedCostMicrousd, type RunUsage } from "./usage"
import type { MakeCompletion } from "./completion"

import { interruptOnAbort, makeTurnObservability, turnSpanAttributes } from "./turn-outcome"
Expand Down Expand Up @@ -186,6 +188,29 @@ export const meterTurn = (
)
}

/**
* Adds the turn's reported cost to its review's row, which the billing page breaks code review spend
* down by. A turn on the org's own key adds 0: it ran, and Maple billed nothing for it.
*/
export const recordReviewCost = (
reviews: Pick<PrReviewServiceApi, "addCost">,
orgId: OrgId,
reviewId: PrReviewId | undefined,
usage: Pick<RunUsage, "charges">,
ownKey: boolean,
): Effect.Effect<void> => {
const costMicrousd = ownKey ? 0 : reportedCostMicrousd(usage)
if (reviewId === undefined || costMicrousd === undefined) return Effect.void
return reviews.addCost(orgId, reviewId, costMicrousd).pipe(
Effect.timeout(METERING_TIMEOUT),
Effect.catchCause((cause) =>
Effect.logWarning("Could not record a review's cost").pipe(
Effect.annotateLogs({ orgId, reviewId, cause: String(cause) }),
),
),
)
}

/**
* Drive one turn to completion.
*
Expand Down Expand Up @@ -676,6 +701,7 @@ export const runChatSessionTurn = async (input: RunChatSessionTurnInput): Promis
})

const program = Effect.gen(function* () {
const reviews = yield* PrReviewService
const orgKey = yield* (yield* OrgOpenRouterKeyService).resolveForRun(tenant.orgId)
yield* Effect.annotateCurrentSpan("maple.llm.byok", Option.isSome(orgKey))
// Resolved before the turn so the finalizer has nothing left to look up. A failed lookup
Expand All @@ -700,11 +726,14 @@ export const runChatSessionTurn = async (input: RunChatSessionTurnInput): Promis
// still burned the tokens it burned, and the pre-`ensuring` shape billed none of them.
Effect.ensuring(
Effect.suspend(() =>
Option.match(customerId, {
onNone: () => Effect.void,
onSome: (payer) =>
meterTurn(input, payer, origin, usage, { ownKey: Option.isSome(orgKey) }),
}),
Effect.andThen(
Option.match(customerId, {
onNone: () => Effect.void,
onSome: (payer) =>
meterTurn(input, payer, origin, usage, { ownKey: Option.isSome(orgKey) }),
}),
recordReviewCost(reviews, tenant.orgId, prReviewId, usage, Option.isSome(orgKey)),
),
),
),
)
Expand Down
10 changes: 9 additions & 1 deletion apps/ai/src/chat/usage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
import type { AiModelSpend } from "@maple/backend/services/billing/autumn-tracker"
import type { RunBudgetHook, RunUsageDelta } from "@yielded/agent/run-options"
import type { ModelCallUsage } from "@yielded/agent/usage"
import { Effect } from "effect"
import { Array as Arr, Effect, Number as Num } from "effect"
import { autumnModelId } from "../platform/ai-credits"

/**
Expand Down Expand Up @@ -87,6 +87,14 @@ const recordCharge = (usage: RunUsage, call: ModelCallUsage) => {
}
}

/** What the provider reported the run's calls cost, in microdollars; undefined when none reported one. */
export const reportedCostMicrousd = (usage: Pick<RunUsage, "charges">): number | undefined => {
const priced = Arr.flatMap(usage.charges, (charge) =>
charge.providerCostMicrousd === undefined ? [] : [charge.providerCostMicrousd],
)
return priced.length === 0 ? undefined : Num.sumAll(priced)
}

/**
* A budget hook that only watches.
*
Expand Down
96 changes: 92 additions & 4 deletions apps/api/src/routes/internal/billing.http.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { assert, describe, it } from "@effect/vitest"
import { BillingCustomerResolver } from "@maple/backend/services/billing/BillingCustomerResolver"
import { BillingOrgService } from "@maple/backend/services/billing/BillingOrgService"
import { PrReviewAnalyticsService } from "@maple/backend/services/pr-review/PrReviewAnalyticsService"
import { ConfigProvider, Context, Effect, Layer, Schema, Array as Arr } from "effect"
import { FetchHttpClient, HttpRouter } from "effect/http"
import { HttpApi, HttpApiBuilder } from "effect/http-api"
Expand Down Expand Up @@ -648,6 +649,8 @@ describe("billing writes over HTTP", () => {
readonly stripe?: object
/** The org this one is billed through, when it is a member org. */
readonly billedThrough?: OrgId
readonly billingOrgs?: object
readonly codeReviews?: object
} = {},
) => {
const routes = HttpApiBuilder.layer(BillingOnlyApi).pipe(
Expand All @@ -664,13 +667,26 @@ describe("billing writes over HTTP", () => {
),
Layer.provideMerge(Layer.succeed(EdgeCacheService, makeCache())),
Layer.provideMerge(Layer.succeed(DailySpendService, { get: die })),
Layer.provideMerge(
Layer.succeed(PrReviewAnalyticsService, {
analytics: die,
listReviews: die,
getReview: die,
listFindings: die,
spend: die,
...overrides.codeReviews,
}),
),
Layer.provideMerge(
overrides.billedThrough === undefined
? BillingCustomerResolver.standalone
: billedThroughResolver(overrides.billedThrough),
),
Layer.provideMerge(
Layer.succeed(BillingOrgService, { members: () => Effect.succeed([]) } as never),
Layer.succeed(BillingOrgService, {
members: () => Effect.succeed([]),
...overrides.billingOrgs,
} as never),
),
Layer.provideMerge(Layer.succeed(ProductEventsService, { track: die, trackMany: die })),
Layer.provideMerge(Layer.succeed(StripeClient, { request: die, ...overrides.stripe })),
Expand All @@ -687,20 +703,20 @@ describe("billing writes over HTTP", () => {
)
const { handler, dispose } = HttpRouter.toWebHandler(routes as never, { disableLogger: true })

const send = (method: string) => async (path: string, body: unknown) => {
const send = (method: string) => async (path: string, body?: unknown) => {
const response = await handler(
new Request(`http://maple.test${path}`, {
method,
headers: { authorization: "Bearer test-token", "content-type": "application/json" },
body: JSON.stringify(body),
...(body === undefined ? undefined : { body: JSON.stringify(body) }),
}),
Context.empty(),
)
const text = await response.text()
return { status: response.status, body: text.length === 0 ? null : JSON.parse(text) }
}

return { post: send("POST"), put: send("PUT"), dispose }
return { get: send("GET"), post: send("POST"), put: send("PUT"), dispose }
}

it("refuses attach from a non-admin member", async () => {
Expand Down Expand Up @@ -776,6 +792,78 @@ describe("billing writes over HTTP", () => {
}
})

it("breaks AI credits down over the whole bill, and keeps the spend when the model split fails", async () => {
const SIBLING = Schema.decodeSync(OrgId)("org_billing_sibling")
const spendOrgs: Array<ReadonlyArray<string>> = []
const harness = makeHarness(["org:member"], {
billedThrough: PAYER_ORG,
billingOrgs: {
members: (payer: OrgId) => Effect.succeed(payer === PAYER_ORG ? [{ orgId: SIBLING }] : []),
},
codeReviews: {
spend: (orgIds: ReadonlyArray<string>) =>
Effect.sync(() => {
spendOrgs.push(orgIds)
return {
reviews: 1,
pullRequests: 1,
credits: 0.05,
unpricedReviews: 0,
reReviews: 0,
reReviewCredits: 0,
repositories: [],
topPullRequests: [],
}
}),
},
autumn: {
getOrCreateCustomer: () =>
Effect.succeed({
statusCode: 200,
response: {
id: PAYER_ORG,
subscriptions: [
{
planId: "startup",
status: "active",
currentPeriodStart: 1_000,
currentPeriodEnd: 2_000_000_000_000,
},
],
},
}),
aggregateEvents: (_customer: string, options: { readonly groupBy?: string }) =>
Effect.succeed(
options.groupBy === "properties.model"
? { statusCode: 500, response: { message: "aggregate failed" } }
: {
statusCode: 200,
response: {
list: [
{
period: 0,
values: { ai_credits: 0.05 },
groupedValues: { ai_credits: { review: 0.05 } },
},
],
},
},
),
},
})
try {
const response = await harness.get("/internal/billing/ai-credit-usage")
assert.strictEqual(response.status, 200)
assert.deepStrictEqual(response.body.bySource, [{ source: "review", credits: 0.05 }])
assert.deepStrictEqual(response.body.byModel, [])
assert.strictEqual(response.body.codeReview.reviews, 1)
// The viewer is a member org: the breakdown covers the payer and every org it pays for.
assert.deepStrictEqual(spendOrgs, [[PAYER_ORG, SIBLING]])
} finally {
await harness.dispose()
}
})

it("refuses the Stripe customer portal from a non-admin member", async () => {
const harness = makeHarness(["org:member"])
try {
Expand Down
Loading
Loading