From cd957acdd46802057f6ae36d52b3fe691d9ad60f Mon Sep 17 00:00:00 2001 From: Matic Jurglic <matic@jurglic.si> Date: Mon, 18 Nov 2024 16:40:10 +0100 Subject: [PATCH 1/9] Track credit consumption in AI bot --- packages/ai-bot/lib/send-response.ts | 2 +- packages/ai-bot/main.ts | 113 +++++++++++++- packages/ai-bot/package.json | 4 +- packages/billing/billing-queries.ts | 60 ++++++++ packages/postgres/pg-config.ts | 4 +- packages/realm-server/tests/billing-test.ts | 154 ++++++++++++++++++++ 6 files changed, 329 insertions(+), 8 deletions(-) diff --git a/packages/ai-bot/lib/send-response.ts b/packages/ai-bot/lib/send-response.ts index 4b913c8987..494aa114e5 100644 --- a/packages/ai-bot/lib/send-response.ts +++ b/packages/ai-bot/lib/send-response.ts @@ -133,7 +133,7 @@ export class Responder { } } - async onError(error: OpenAIError) { + async onError(error: OpenAIError | string) { Sentry.captureException(error); return await sendError( this.client, diff --git a/packages/ai-bot/main.ts b/packages/ai-bot/main.ts index 910a3851ee..4d3f169dfb 100644 --- a/packages/ai-bot/main.ts +++ b/packages/ai-bot/main.ts @@ -7,7 +7,7 @@ import { type MatrixEvent, } from 'matrix-js-sdk'; import OpenAI from 'openai'; -import { logger, aiBotUsername } from '@cardstack/runtime-common'; +import { logger, aiBotUsername, retry } from '@cardstack/runtime-common'; import { constructHistory, getModifyPrompt, @@ -24,21 +24,94 @@ import { handleDebugCommands } from './lib/debug'; import { MatrixClient } from './lib/matrix'; import type { MatrixEvent as DiscreteMatrixEvent } from 'https://cardstack.com/base/matrix-event'; import * as Sentry from '@sentry/node'; +import { PgAdapter, TransactionManager } from '@cardstack/postgres'; +import { + getUserByMatrixUserId, + spendCredits, +} from '@cardstack/billing/billing-queries'; let log = logger('ai-bot'); +let trackAiUsageCostPromises = new Map<string, Promise<void>>(); + class Assistant { private openai: OpenAI; private client: MatrixClient; + private pgAdapter: PgAdapter; id: string; constructor(client: MatrixClient, id: string) { this.openai = new OpenAI({ - baseURL: 'https://openrouter.ai/api/v1', // We use openrouter so that we can track usage cost in $ + baseURL: 'https://openrouter.ai/api/v1', apiKey: process.env.OPENROUTER_API_KEY, }); this.id = id; this.client = client; + this.pgAdapter = new PgAdapter(); + } + + async fetchGenerationCost(generationId: string) { + let response = await ( + await fetch( + `https://openrouter.ai/api/v1/generation?id=${generationId}`, + { + headers: { + Authorization: `Bearer ${process.env.OPENROUTER_API_KEY}`, + }, + }, + ) + ).json(); + + if (response.error && response.error.includes('not found')) { + return null; + } + + return response.data.total_cost; + } + + async trackAiUsageCost(matrixUserId: string, generationId: string) { + try { + let costInUsd = await retry( + () => this.fetchGenerationCost(generationId), + { + retries: 10, + delayMs: 500, + }, + ); + + let creditsConsumed = Math.round(costInUsd / 0.001); + + let user = await getUserByMatrixUserId(this.pgAdapter, matrixUserId); + if (!user) { + throw new Error('should not happen: user with matrix id not found'); + } + + let txManager = new TransactionManager(this.pgAdapter); + await txManager.withTransaction(async () => { + await spendCredits(this.pgAdapter, user!.id, creditsConsumed); + }); + + log.info('Successfully tracked AI usage:', { + matrixUserId, + generationId, + creditsConsumed, + }); + } catch (err) { + log.error('Failed to track AI usage:', err); + throw err; + } + } + + async enqueueTrackAiUsageCost(matrixUserId: string, generationId: string) { + if (trackAiUsageCostPromises.has(matrixUserId)) { + return; + } + trackAiUsageCostPromises.set( + matrixUserId, + this.trackAiUsageCost(matrixUserId, generationId).finally(() => { + trackAiUsageCostPromises.delete(matrixUserId); + }), + ); } getResponse(history: DiscreteMatrixEvent[]) { @@ -177,6 +250,21 @@ Common issues are: } const responder = new Responder(client, room.roomId); + + // Do not generate new responses if previous ones' cost is still being reported + let pendingCreditsConsumptionPromise = trackAiUsageCostPromises.get( + event.getSender()!, + ); + if (pendingCreditsConsumptionPromise) { + try { + await pendingCreditsConsumptionPromise; + } catch (e) { + log.error(e); + return responder.onError( + 'There was an error reporting Boxel Credits usage. Try again or contact support if the problem persists.', + ); + } + } await responder.initialize(); if (historyError) { @@ -186,9 +274,11 @@ Common issues are: return; } + let generationId: string | undefined; const runner = assistant .getResponse(history) .on('chunk', async (chunk, _snapshot) => { + generationId = chunk.id; await responder.onChunk(chunk); }) .on('content', async (_delta, snapshot) => { @@ -200,9 +290,24 @@ Common issues are: .on('error', async (error) => { await responder.onError(error); }); + // We also need to catch the error when getting the final content - let finalContent = await runner.finalContent().catch(responder.onError); - await responder.finalize(finalContent); + let finalContent; + try { + finalContent = await runner.finalContent(); + await responder.finalize(finalContent); + } catch (error) { + await responder.onError(error); + } finally { + if (generationId) { + let userMatrixId = event.getSender()!; + + assistant.enqueueTrackAiUsageCost( + userMatrixId, + generationId as string, + ); + } + } if (shouldSetRoomTitle(eventList, aiBotUserId, event)) { return await assistant.setTitle(room.roomId, history, event); diff --git a/packages/ai-bot/package.json b/packages/ai-bot/package.json index 9578581ce1..a946eccbce 100644 --- a/packages/ai-bot/package.json +++ b/packages/ai-bot/package.json @@ -1,7 +1,9 @@ { "name": "@cardstack/ai-bot", "dependencies": { - "@cardstack/runtime-common": "workspace:^", + "@cardstack/runtime-common": "workspace:*", + "@cardstack/postgres": "workspace:*", + "@cardstack/billing": "workspace:*", "@sentry/node": "^8.31.0", "@types/node": "^18.18.5", "@types/stream-chain": "^2.0.1", diff --git a/packages/billing/billing-queries.ts b/packages/billing/billing-queries.ts index acc7e5e2ff..07f513f79d 100644 --- a/packages/billing/billing-queries.ts +++ b/packages/billing/billing-queries.ts @@ -490,3 +490,63 @@ export async function expireRemainingPlanAllowanceInSubscriptionCycle( subscriptionCycleId, }); } + +export async function spendCredits( + dbAdapter: DBAdapter, + userId: string, + creditsToSpend: number, +) { + let subscription = await getMostRecentSubscription(dbAdapter, userId); + if (!subscription) { + throw new Error('subscription not found'); + } + let subscriptionCycle = await getMostRecentSubscriptionCycle( + dbAdapter, + subscription.id, + ); + if (!subscriptionCycle) { + throw new Error('subscription cycle not found'); + } + let availablePlanAllowanceCredits = await sumUpCreditsLedger(dbAdapter, { + creditType: [ + 'plan_allowance', + 'plan_allowance_used', + 'plan_allowance_expired', + ], + userId, + }); + + if (availablePlanAllowanceCredits >= creditsToSpend) { + await addToCreditsLedger(dbAdapter, { + userId, + creditAmount: -creditsToSpend, + creditType: 'plan_allowance_used', + subscriptionCycleId: subscriptionCycle.id, + }); + } else { + // If user does not have enough plan allowance credits to cover the spend, use extra credits + let availableExtraCredits = await sumUpCreditsLedger(dbAdapter, { + creditType: ['extra_credit', 'extra_credit_used'], + userId, + }); + let planAllowanceToSpend = availablePlanAllowanceCredits; // Spend all plan allowance credits first + let extraCreditsToSpend = creditsToSpend - planAllowanceToSpend; + if (extraCreditsToSpend > availableExtraCredits) { + extraCreditsToSpend = availableExtraCredits; + } + + await addToCreditsLedger(dbAdapter, { + userId, + creditAmount: -planAllowanceToSpend, + creditType: 'plan_allowance_used', + subscriptionCycleId: subscriptionCycle.id, + }); + + await addToCreditsLedger(dbAdapter, { + userId, + creditAmount: -extraCreditsToSpend, + creditType: 'extra_credit_used', + subscriptionCycleId: subscriptionCycle.id, + }); + } +} diff --git a/packages/postgres/pg-config.ts b/packages/postgres/pg-config.ts index a2b7256275..d9d3939577 100644 --- a/packages/postgres/pg-config.ts +++ b/packages/postgres/pg-config.ts @@ -3,9 +3,9 @@ import { type ClientConfig } from 'pg'; export function postgresConfig(defaultConfig: ClientConfig = {}) { return Object.assign({}, defaultConfig, { host: process.env.PGHOST || 'localhost', - port: process.env.PGPORT || '5432', + port: process.env.PGPORT || '5435', user: process.env.PGUSER || 'postgres', password: process.env.PGPASSWORD || undefined, - database: process.env.PGDATABASE || 'postgres', + database: process.env.PGDATABASE || 'boxel', }); } diff --git a/packages/realm-server/tests/billing-test.ts b/packages/realm-server/tests/billing-test.ts index c0cd26a1bc..ae91da67de 100644 --- a/packages/realm-server/tests/billing-test.ts +++ b/packages/realm-server/tests/billing-test.ts @@ -18,6 +18,9 @@ import { addToCreditsLedger, insertSubscription, User, + spendCredits, + Plan, + Subscription, } from '@cardstack/billing/billing-queries'; import { @@ -25,6 +28,7 @@ import { StripeSubscriptionDeletedWebhookEvent, StripeCheckoutSessionCompletedWebhookEvent, } from '@cardstack/billing/stripe-webhook-handlers'; +import { add } from 'date-fns'; async function fetchStripeEvents(dbAdapter: PgAdapter) { return await query(dbAdapter, [`SELECT * FROM stripe_events`]); @@ -796,4 +800,154 @@ module('billing', function (hooks) { assert.strictEqual(availableExtraCredits, 25000); }); }); + + // eslint-disable-next-line qunit/no-only + module.only('ai usage tracking', function (hooks) { + let user: User; + let creatorPlan: Plan; + let subscription: Subscription; + let subscriptionCycle: SubscriptionCycle; + + hooks.beforeEach(async function () { + user = await insertUser(dbAdapter, 'testuser', 'cus_123'); + creatorPlan = await insertPlan( + dbAdapter, + 'Creator', + 12, + 2500, + 'prod_creator', + ); + subscription = await insertSubscription(dbAdapter, { + user_id: user.id, + plan_id: creatorPlan.id, + started_at: 1, + status: 'active', + stripe_subscription_id: 'sub_1234567890', + }); + subscriptionCycle = await insertSubscriptionCycle(dbAdapter, { + subscriptionId: subscription.id, + periodStart: 1, + periodEnd: 2, + }); + }); + + test('spends ai credits correctly when no extra credits are available', async function (assert) { + // User receives 2500 credits for the creator plan and spends 2490 credits + await addToCreditsLedger(dbAdapter, { + userId: user.id, + creditAmount: creatorPlan.creditsIncluded, + creditType: 'plan_allowance', + subscriptionCycleId: subscriptionCycle.id, + }); + + await addToCreditsLedger(dbAdapter, { + userId: user.id, + creditAmount: -2490, + creditType: 'plan_allowance_used', + subscriptionCycleId: subscriptionCycle.id, + }); + + assert.strictEqual( + await sumUpCreditsLedger(dbAdapter, { + userId: user.id, + }), + 10, + ); + + await spendCredits(dbAdapter, user.id, 2); + + assert.strictEqual( + await sumUpCreditsLedger(dbAdapter, { + userId: user.id, + }), + 8, + ); + + await spendCredits(dbAdapter, user.id, 5); + + assert.strictEqual( + await sumUpCreditsLedger(dbAdapter, { + userId: user.id, + }), + 3, + ); + + // Make sure that we can't spend more credits than the user has - in this case user has 3 credits left and we try to spend 5 + await spendCredits(dbAdapter, user.id, 5); + assert.strictEqual( + await sumUpCreditsLedger(dbAdapter, { + userId: user.id, + }), + 0, + ); + }); + + test('spends ai credits correctly when extra credits are available', async function (assert) { + // User receives 2500 credits for the creator plan and spends 2490 credits + await addToCreditsLedger(dbAdapter, { + userId: user.id, + creditAmount: creatorPlan.creditsIncluded, + creditType: 'plan_allowance', + subscriptionCycleId: subscriptionCycle.id, + }); + + await addToCreditsLedger(dbAdapter, { + userId: user.id, + creditAmount: -2490, + creditType: 'plan_allowance_used', + subscriptionCycleId: subscriptionCycle.id, + }); + + assert.strictEqual( + await sumUpCreditsLedger(dbAdapter, { + userId: user.id, + }), + 10, + ); + + // Add 5 extra credits + await addToCreditsLedger(dbAdapter, { + userId: user.id, + creditAmount: 5, + creditType: 'extra_credit', + subscriptionCycleId: null, + }); + + // User has 15 credits in total: 10 credits from the plan allowance and 5 extra credits + assert.strictEqual( + await sumUpCreditsLedger(dbAdapter, { + userId: user.id, + }), + 15, + ); + + // This should spend 10 credits from the plan allowance and 2 from the extra credits + await spendCredits(dbAdapter, user.id, 12); + + // Plan allowance is now 0, 3 credits left from the extra credits + assert.strictEqual( + await sumUpCreditsLedger(dbAdapter, { + userId: user.id, + }), + 3, + ); + + // Make sure the available credits come from the extra credits and not the plan allowance + assert.strictEqual( + await sumUpCreditsLedger(dbAdapter, { + userId: user.id, + creditType: ['plan_allowance', 'plan_allowance_used'], + }), + 0, + ); + + assert.strictEqual( + await sumUpCreditsLedger(dbAdapter, { + userId: user.id, + creditType: ['extra_credit', 'extra_credit_used'], + }), + 3, + ); + }); + }); }); From 41b520ae97bbbd898ffc2917ef518b3a69156141 Mon Sep 17 00:00:00 2001 From: Matic Jurglic <matic@jurglic.si> Date: Tue, 19 Nov 2024 10:59:37 +0100 Subject: [PATCH 2/9] AI bot saves credits usage for user in credits ledger --- packages/ai-bot/lib/ai-cost.ts | 75 +++++++++++++++++++ packages/ai-bot/main.ts | 82 +++------------------ packages/billing/billing-queries.ts | 30 ++++---- packages/realm-server/tests/billing-test.ts | 4 +- 4 files changed, 105 insertions(+), 86 deletions(-) create mode 100644 packages/ai-bot/lib/ai-cost.ts diff --git a/packages/ai-bot/lib/ai-cost.ts b/packages/ai-bot/lib/ai-cost.ts new file mode 100644 index 0000000000..0e1f7d0bd2 --- /dev/null +++ b/packages/ai-bot/lib/ai-cost.ts @@ -0,0 +1,75 @@ +import { + getMostRecentSubscription, + getUserByMatrixUserId, + spendCredits, +} from '@cardstack/billing/billing-queries'; +import { PgAdapter, TransactionManager } from '@cardstack/postgres'; +import { logger, retry } from '@cardstack/runtime-common'; +import * as Sentry from '@sentry/node'; + +let log = logger('ai-bot'); + +export async function saveUsageCost( + pgAdapter: PgAdapter, + matrixUserId: string, + generationId: string, +) { + try { + // Generation data is sometimes not immediately available, so we retry a couple of times until we are able to get the cost + let costInUsd = await retry(() => fetchGenerationCost(generationId), { + retries: 10, + delayMs: 500, + }); + + let creditsConsumed = Math.round(costInUsd / 0.001); + + let user = await getUserByMatrixUserId(pgAdapter, matrixUserId); + + // This check is for transition period where we don't have subscriptions have the subscription flow fully rolled out. + // When we have assurance that all users who use the bot have subscriptions, we can remove this subscription check. + let subscription = await getMostRecentSubscription(pgAdapter, user!.id); + if (!subscription) { + log.info( + `user ${matrixUserId} has no subscription, skipping credit usage tracking`, + ); + return Promise.resolve(); + } + + if (!user) { + throw new Error( + `should not happen: user with matrix id ${matrixUserId} not found in the users table`, + ); + } + + let txManager = new TransactionManager(pgAdapter); + + await txManager.withTransaction(async () => { + await spendCredits(pgAdapter, user!.id, creditsConsumed); + + // TODO: send a signal to the host app to update credits balance displayed in the UI + }); + } catch (err) { + log.error( + `Failed to track AI usage (matrixUserId: ${matrixUserId}, generationId: ${generationId}):`, + err, + ); + Sentry.captureException(err); + // Don't throw, because we don't want to crash the bot over this + } +} + +async function fetchGenerationCost(generationId: string) { + let response = await ( + await fetch(`https://openrouter.ai/api/v1/generation?id=${generationId}`, { + headers: { + Authorization: `Bearer ${process.env.OPENROUTER_API_KEY}`, + }, + }) + ).json(); + + if (response.error && response.error.includes('not found')) { + return null; + } + + return response.data.total_cost; +} diff --git a/packages/ai-bot/main.ts b/packages/ai-bot/main.ts index 4d3f169dfb..a8ef6837a4 100644 --- a/packages/ai-bot/main.ts +++ b/packages/ai-bot/main.ts @@ -7,7 +7,7 @@ import { type MatrixEvent, } from 'matrix-js-sdk'; import OpenAI from 'openai'; -import { logger, aiBotUsername, retry } from '@cardstack/runtime-common'; +import { logger, aiBotUsername } from '@cardstack/runtime-common'; import { constructHistory, getModifyPrompt, @@ -24,11 +24,9 @@ import { handleDebugCommands } from './lib/debug'; import { MatrixClient } from './lib/matrix'; import type { MatrixEvent as DiscreteMatrixEvent } from 'https://cardstack.com/base/matrix-event'; import * as Sentry from '@sentry/node'; -import { PgAdapter, TransactionManager } from '@cardstack/postgres'; -import { - getUserByMatrixUserId, - spendCredits, -} from '@cardstack/billing/billing-queries'; + +import { saveUsageCost } from './lib/ai-cost'; +import { PgAdapter } from '@cardstack/postgres'; let log = logger('ai-bot'); @@ -50,65 +48,13 @@ class Assistant { this.pgAdapter = new PgAdapter(); } - async fetchGenerationCost(generationId: string) { - let response = await ( - await fetch( - `https://openrouter.ai/api/v1/generation?id=${generationId}`, - { - headers: { - Authorization: `Bearer ${process.env.OPENROUTER_API_KEY}`, - }, - }, - ) - ).json(); - - if (response.error && response.error.includes('not found')) { - return null; - } - - return response.data.total_cost; - } - async trackAiUsageCost(matrixUserId: string, generationId: string) { - try { - let costInUsd = await retry( - () => this.fetchGenerationCost(generationId), - { - retries: 10, - delayMs: 500, - }, - ); - - let creditsConsumed = Math.round(costInUsd / 0.001); - - let user = await getUserByMatrixUserId(this.pgAdapter, matrixUserId); - if (!user) { - throw new Error('should not happen: user with matrix id not found'); - } - - let txManager = new TransactionManager(this.pgAdapter); - await txManager.withTransaction(async () => { - await spendCredits(this.pgAdapter, user!.id, creditsConsumed); - }); - - log.info('Successfully tracked AI usage:', { - matrixUserId, - generationId, - creditsConsumed, - }); - } catch (err) { - log.error('Failed to track AI usage:', err); - throw err; - } - } - - async enqueueTrackAiUsageCost(matrixUserId: string, generationId: string) { if (trackAiUsageCostPromises.has(matrixUserId)) { return; } trackAiUsageCostPromises.set( matrixUserId, - this.trackAiUsageCost(matrixUserId, generationId).finally(() => { + saveUsageCost(this.pgAdapter, matrixUserId, generationId).finally(() => { trackAiUsageCostPromises.delete(matrixUserId); }), ); @@ -206,6 +152,7 @@ Common issues are: async function (event, room, toStartOfTimeline) { try { let eventBody = event.getContent().body; + let senderMatrixUserId = event.getSender()!; if (!room) { return; } @@ -223,7 +170,7 @@ Common issues are: return; // don't respond to card fragments, we just gather these in our history } - if (event.getSender() === aiBotUserId) { + if (senderMatrixUserId === aiBotUserId) { return; } log.info( @@ -231,7 +178,7 @@ Common issues are: event.getType(), room?.name, room?.roomId, - event.getSender(), + senderMatrixUserId, eventBody, ); @@ -250,10 +197,11 @@ Common issues are: } const responder = new Responder(client, room.roomId); + await responder.initialize(); // Do not generate new responses if previous ones' cost is still being reported let pendingCreditsConsumptionPromise = trackAiUsageCostPromises.get( - event.getSender()!, + senderMatrixUserId!, ); if (pendingCreditsConsumptionPromise) { try { @@ -261,11 +209,10 @@ Common issues are: } catch (e) { log.error(e); return responder.onError( - 'There was an error reporting Boxel Credits usage. Try again or contact support if the problem persists.', + 'There was an error saving your Boxel credits usage. Try again or contact support if the problem persists.', ); } } - await responder.initialize(); if (historyError) { responder.finalize( @@ -300,12 +247,7 @@ Common issues are: await responder.onError(error); } finally { if (generationId) { - let userMatrixId = event.getSender()!; - - assistant.enqueueTrackAiUsageCost( - userMatrixId, - generationId as string, - ); + assistant.trackAiUsageCost(senderMatrixUserId, generationId); } } diff --git a/packages/billing/billing-queries.ts b/packages/billing/billing-queries.ts index 07f513f79d..371968c77a 100644 --- a/packages/billing/billing-queries.ts +++ b/packages/billing/billing-queries.ts @@ -524,7 +524,7 @@ export async function spendCredits( subscriptionCycleId: subscriptionCycle.id, }); } else { - // If user does not have enough plan allowance credits to cover the spend, use extra credits + // If user does not have enough plan allowance credits to cover the spend, try to also use extra credits let availableExtraCredits = await sumUpCreditsLedger(dbAdapter, { creditType: ['extra_credit', 'extra_credit_used'], userId, @@ -535,18 +535,22 @@ export async function spendCredits( extraCreditsToSpend = availableExtraCredits; } - await addToCreditsLedger(dbAdapter, { - userId, - creditAmount: -planAllowanceToSpend, - creditType: 'plan_allowance_used', - subscriptionCycleId: subscriptionCycle.id, - }); + if (planAllowanceToSpend > 0) { + await addToCreditsLedger(dbAdapter, { + userId, + creditAmount: -planAllowanceToSpend, + creditType: 'plan_allowance_used', + subscriptionCycleId: subscriptionCycle.id, + }); + } - await addToCreditsLedger(dbAdapter, { - userId, - creditAmount: -extraCreditsToSpend, - creditType: 'extra_credit_used', - subscriptionCycleId: subscriptionCycle.id, - }); + if (extraCreditsToSpend > 0) { + await addToCreditsLedger(dbAdapter, { + userId, + creditAmount: -extraCreditsToSpend, + creditType: 'extra_credit_used', + subscriptionCycleId: subscriptionCycle.id, + }); + } } } diff --git a/packages/realm-server/tests/billing-test.ts b/packages/realm-server/tests/billing-test.ts index ae91da67de..4a35f08f0d 100644 --- a/packages/realm-server/tests/billing-test.ts +++ b/packages/realm-server/tests/billing-test.ts @@ -28,7 +28,6 @@ import { StripeSubscriptionDeletedWebhookEvent, StripeCheckoutSessionCompletedWebhookEvent, } from '@cardstack/billing/stripe-webhook-handlers'; -import { add } from 'date-fns'; async function fetchStripeEvents(dbAdapter: PgAdapter) { return await query(dbAdapter, [`SELECT * FROM stripe_events`]); @@ -801,8 +800,7 @@ module('billing', function (hooks) { }); }); - // eslint-disable-next-line qunit/no-only - module.only('ai usage tracking', function (hooks) { + module('AI usage tracking', function (hooks) { let user: User; let creatorPlan: Plan; let subscription: Subscription; From 08255dbe8945c2c40fb1b5dba145fcb122f8db52 Mon Sep 17 00:00:00 2001 From: Matic Jurglic <matic@jurglic.si> Date: Tue, 19 Nov 2024 11:00:24 +0100 Subject: [PATCH 3/9] Save subscription cycle in extra credits ledger entries --- .../checkout-session-completed.ts | 20 ++++++++++++++++++- packages/realm-server/tests/billing-test.ts | 19 ++++++++++++++++++ 2 files changed, 38 insertions(+), 1 deletion(-) diff --git a/packages/billing/stripe-webhook-handlers/checkout-session-completed.ts b/packages/billing/stripe-webhook-handlers/checkout-session-completed.ts index 9d7c108758..37a856bbf7 100644 --- a/packages/billing/stripe-webhook-handlers/checkout-session-completed.ts +++ b/packages/billing/stripe-webhook-handlers/checkout-session-completed.ts @@ -1,6 +1,8 @@ import { type DBAdapter } from '@cardstack/runtime-common'; import { addToCreditsLedger, + getMostRecentSubscription, + getMostRecentSubscriptionCycle, getUserByStripeId, insertStripeEvent, markStripeEventAsProcessed, @@ -50,11 +52,27 @@ export async function handleCheckoutSessionCompleted( ); } + let subscription = await getMostRecentSubscription(dbAdapter, user.id); + if (!subscription) { + throw new Error( + `User ${user.id} has no subscription, cannot add extra credits`, + ); + } + let subscriptionCycle = await getMostRecentSubscriptionCycle( + dbAdapter, + subscription!.id, + ); + if (!subscriptionCycle) { + throw new Error( + `User ${user.id} has no subscription cycle, cannot add extra credits`, + ); + } + await addToCreditsLedger(dbAdapter, { userId: user.id, creditAmount: creditReloadAmount, creditType: 'extra_credit', - subscriptionCycleId: null, + subscriptionCycleId: subscriptionCycle.id, }); } }); diff --git a/packages/realm-server/tests/billing-test.ts b/packages/realm-server/tests/billing-test.ts index 4a35f08f0d..60385f7998 100644 --- a/packages/realm-server/tests/billing-test.ts +++ b/packages/realm-server/tests/billing-test.ts @@ -771,6 +771,25 @@ module('billing', function (hooks) { }); test('add extra credits to user ledger when checkout session completed', async function (assert) { + let creatorPlan = await insertPlan( + dbAdapter, + 'Creator', + 12, + 2500, + 'prod_creator', + ); + let subscription = await insertSubscription(dbAdapter, { + user_id: user.id, + plan_id: creatorPlan.id, + started_at: 1, + status: 'active', + stripe_subscription_id: 'sub_1234567890', + }); + await insertSubscriptionCycle(dbAdapter, { + subscriptionId: subscription.id, + periodStart: 1, + periodEnd: 2, + }); let stripeCheckoutSessionCompletedEvent = { id: 'evt_1234567890', object: 'event', From 111020a24ea5bce64f148c3091f0b6dfcc4441e4 Mon Sep 17 00:00:00 2001 From: Matic Jurglic <matic@jurglic.si> Date: Tue, 19 Nov 2024 11:02:58 +0100 Subject: [PATCH 4/9] Update pnpm lock --- pnpm-lock.yaml | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4e9816ecdb..fac05f6088 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -117,8 +117,14 @@ importers: packages/ai-bot: dependencies: + '@cardstack/billing': + specifier: workspace:* + version: link:../billing + '@cardstack/postgres': + specifier: workspace:* + version: link:../postgres '@cardstack/runtime-common': - specifier: workspace:^ + specifier: workspace:* version: link:../runtime-common '@sentry/node': specifier: ^8.31.0 From 6a1713213a4134a8897b5dbfc5632e324b89a6db Mon Sep 17 00:00:00 2001 From: Matic Jurglic <matic@jurglic.si> Date: Tue, 19 Nov 2024 11:34:18 +0100 Subject: [PATCH 5/9] Fixes after upstream merge --- packages/billing/billing-queries.ts | 4 ++-- .../stripe-webhook-handlers/checkout-session-completed.ts | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/billing/billing-queries.ts b/packages/billing/billing-queries.ts index aaed46cc48..07ab2076ea 100644 --- a/packages/billing/billing-queries.ts +++ b/packages/billing/billing-queries.ts @@ -478,9 +478,9 @@ export async function spendCredits( userId: string, creditsToSpend: number, ) { - let subscription = await getMostRecentSubscription(dbAdapter, userId); + let subscription = await getCurrentActiveSubscription(dbAdapter, userId); if (!subscription) { - throw new Error('subscription not found'); + throw new Error('active subscription not found'); } let subscriptionCycle = await getMostRecentSubscriptionCycle( dbAdapter, diff --git a/packages/billing/stripe-webhook-handlers/checkout-session-completed.ts b/packages/billing/stripe-webhook-handlers/checkout-session-completed.ts index 37a856bbf7..d176fa5057 100644 --- a/packages/billing/stripe-webhook-handlers/checkout-session-completed.ts +++ b/packages/billing/stripe-webhook-handlers/checkout-session-completed.ts @@ -1,7 +1,7 @@ import { type DBAdapter } from '@cardstack/runtime-common'; import { addToCreditsLedger, - getMostRecentSubscription, + getCurrentActiveSubscription, getMostRecentSubscriptionCycle, getUserByStripeId, insertStripeEvent, @@ -52,7 +52,7 @@ export async function handleCheckoutSessionCompleted( ); } - let subscription = await getMostRecentSubscription(dbAdapter, user.id); + let subscription = await getCurrentActiveSubscription(dbAdapter, user.id); if (!subscription) { throw new Error( `User ${user.id} has no subscription, cannot add extra credits`, From 3e89ac8215d0a6f546e788b2796ff2f1e28e26ef Mon Sep 17 00:00:00 2001 From: Matic Jurglic <matic@jurglic.si> Date: Tue, 19 Nov 2024 11:48:01 +0100 Subject: [PATCH 6/9] Fix query and comment --- packages/ai-bot/lib/ai-cost.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/ai-bot/lib/ai-cost.ts b/packages/ai-bot/lib/ai-cost.ts index 0e1f7d0bd2..bdbebf2219 100644 --- a/packages/ai-bot/lib/ai-cost.ts +++ b/packages/ai-bot/lib/ai-cost.ts @@ -1,5 +1,5 @@ import { - getMostRecentSubscription, + getCurrentActiveSubscription, getUserByMatrixUserId, spendCredits, } from '@cardstack/billing/billing-queries'; @@ -25,9 +25,9 @@ export async function saveUsageCost( let user = await getUserByMatrixUserId(pgAdapter, matrixUserId); - // This check is for transition period where we don't have subscriptions have the subscription flow fully rolled out. + // This check is for the transition period where we don't have subscriptions fully rolled out yet. // When we have assurance that all users who use the bot have subscriptions, we can remove this subscription check. - let subscription = await getMostRecentSubscription(pgAdapter, user!.id); + let subscription = await getCurrentActiveSubscription(pgAdapter, user!.id); if (!subscription) { log.info( `user ${matrixUserId} has no subscription, skipping credit usage tracking`, From bc5e24fecbf4916bf2fd338665d13fbc97b6fc55 Mon Sep 17 00:00:00 2001 From: Matic Jurglic <matic@jurglic.si> Date: Tue, 19 Nov 2024 13:01:25 +0100 Subject: [PATCH 7/9] Revert default pg config, provide config with the start command --- packages/ai-bot/package.json | 2 +- packages/postgres/pg-config.ts | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/ai-bot/package.json b/packages/ai-bot/package.json index a946eccbce..5eb5768711 100644 --- a/packages/ai-bot/package.json +++ b/packages/ai-bot/package.json @@ -23,7 +23,7 @@ }, "scripts": { "lint": "eslint . --cache --ext ts", - "start": "NODE_NO_WARNINGS=1 ts-node --transpileOnly main", + "start": "NODE_NO_WARNINGS=1 PGDATABASE=boxel PGPORT=5435 ts-node --transpileOnly main", "test": "NODE_NO_WARNINGS=1 qunit --require ts-node/register/transpile-only tests/index.ts", "get-chat": "NODE_NO_WARNINGS=1 ts-node --transpileOnly scripts/get_chat.ts" }, diff --git a/packages/postgres/pg-config.ts b/packages/postgres/pg-config.ts index d9d3939577..a2b7256275 100644 --- a/packages/postgres/pg-config.ts +++ b/packages/postgres/pg-config.ts @@ -3,9 +3,9 @@ import { type ClientConfig } from 'pg'; export function postgresConfig(defaultConfig: ClientConfig = {}) { return Object.assign({}, defaultConfig, { host: process.env.PGHOST || 'localhost', - port: process.env.PGPORT || '5435', + port: process.env.PGPORT || '5432', user: process.env.PGUSER || 'postgres', password: process.env.PGPASSWORD || undefined, - database: process.env.PGDATABASE || 'boxel', + database: process.env.PGDATABASE || 'postgres', }); } From 26e8ba0f644ba2d10fe29bed9848813a2b9c30a8 Mon Sep 17 00:00:00 2001 From: Matic Jurglic <matic@jurglic.si> Date: Tue, 19 Nov 2024 14:22:18 +0100 Subject: [PATCH 8/9] Remove comment that is not relevant anymore --- packages/billing/stripe-webhook-handlers/index.ts | 3 --- 1 file changed, 3 deletions(-) diff --git a/packages/billing/stripe-webhook-handlers/index.ts b/packages/billing/stripe-webhook-handlers/index.ts index 5f343e3d37..4b53bf6c3c 100644 --- a/packages/billing/stripe-webhook-handlers/index.ts +++ b/packages/billing/stripe-webhook-handlers/index.ts @@ -124,9 +124,6 @@ export default async function stripeWebhookHandler( let type = event.type; - // For adding extra credits, we should listen for charge.succeeded, and for - // subsciptions, we should listen for invoice.payment_succeeded (I discovered this when I was - // testing which webhooks arrive for both types of payments) switch (type) { // These handlers should eventually become jobs which workers will process asynchronously case 'invoice.payment_succeeded': From 39db7addaf7027bb31c92718d3b3f54ec1602302 Mon Sep 17 00:00:00 2001 From: Matic Jurglic <matic@jurglic.si> Date: Wed, 20 Nov 2024 14:28:21 +0100 Subject: [PATCH 9/9] Check if user has enough credits before responding with AI generated content --- .../ai-bot/lib/{ai-cost.ts => ai-billing.ts} | 20 +++++++++++++ packages/ai-bot/lib/matrix.ts | 4 +-- packages/ai-bot/main.ts | 29 ++++++++++++++----- 3 files changed, 44 insertions(+), 9 deletions(-) rename packages/ai-bot/lib/{ai-cost.ts => ai-billing.ts} (84%) diff --git a/packages/ai-bot/lib/ai-cost.ts b/packages/ai-bot/lib/ai-billing.ts similarity index 84% rename from packages/ai-bot/lib/ai-cost.ts rename to packages/ai-bot/lib/ai-billing.ts index bdbebf2219..8ef38c4894 100644 --- a/packages/ai-bot/lib/ai-cost.ts +++ b/packages/ai-bot/lib/ai-billing.ts @@ -2,6 +2,7 @@ import { getCurrentActiveSubscription, getUserByMatrixUserId, spendCredits, + sumUpCreditsLedger, } from '@cardstack/billing/billing-queries'; import { PgAdapter, TransactionManager } from '@cardstack/postgres'; import { logger, retry } from '@cardstack/runtime-common'; @@ -58,6 +59,25 @@ export async function saveUsageCost( } } +export async function getAvailableCredits( + pgAdapter: PgAdapter, + matrixUserId: string, +) { + let user = await getUserByMatrixUserId(pgAdapter, matrixUserId); + + if (!user) { + throw new Error( + `should not happen: user with matrix id ${matrixUserId} not found in the users table`, + ); + } + + let availableCredits = await sumUpCreditsLedger(pgAdapter, { + userId: user.id, + }); + + return availableCredits; +} + async function fetchGenerationCost(generationId: string) { let response = await ( await fetch(`https://openrouter.ai/api/v1/generation?id=${generationId}`, { diff --git a/packages/ai-bot/lib/matrix.ts b/packages/ai-bot/lib/matrix.ts index 5d129994e1..dc8399d696 100644 --- a/packages/ai-bot/lib/matrix.ts +++ b/packages/ai-bot/lib/matrix.ts @@ -145,7 +145,7 @@ function getErrorMessage(error: any): string { return `OpenAI error: ${error.name} - ${error.message}`; } if (typeof error === 'string') { - return `Unknown error: ${error}`; + return error; } - return `Unknown error`; + return 'Unknown error'; } diff --git a/packages/ai-bot/main.ts b/packages/ai-bot/main.ts index a8ef6837a4..a7bb0b6843 100644 --- a/packages/ai-bot/main.ts +++ b/packages/ai-bot/main.ts @@ -25,8 +25,12 @@ import { MatrixClient } from './lib/matrix'; import type { MatrixEvent as DiscreteMatrixEvent } from 'https://cardstack.com/base/matrix-event'; import * as Sentry from '@sentry/node'; -import { saveUsageCost } from './lib/ai-cost'; +import { getAvailableCredits, saveUsageCost } from './lib/ai-billing'; import { PgAdapter } from '@cardstack/postgres'; +import { + getUserByMatrixUserId, + sumUpCreditsLedger, +} from '@cardstack/billing/billing-queries'; let log = logger('ai-bot'); @@ -35,7 +39,7 @@ let trackAiUsageCostPromises = new Map<string, Promise<void>>(); class Assistant { private openai: OpenAI; private client: MatrixClient; - private pgAdapter: PgAdapter; + pgAdapter: PgAdapter; id: string; constructor(client: MatrixClient, id: string) { @@ -197,6 +201,12 @@ Common issues are: } const responder = new Responder(client, room.roomId); + if (historyError) { + return responder.finalize( + 'There was an error processing chat history. Please open another session.', + ); + } + await responder.initialize(); // Do not generate new responses if previous ones' cost is still being reported @@ -214,11 +224,17 @@ Common issues are: } } - if (historyError) { - responder.finalize( - 'There was an error processing chat history. Please open another session.', + let availableCredits = await getAvailableCredits( + assistant.pgAdapter, + senderMatrixUserId, + ); + + let minimumCredits = 10; + + if (availableCredits < minimumCredits) { + return responder.onError( + `You need a minimum of ${minimumCredits} credits to continue using the AI bot. Please upgrade to a larger plan, or top up your account.`, ); - return; } let generationId: string | undefined; @@ -238,7 +254,6 @@ Common issues are: await responder.onError(error); }); - // We also need to catch the error when getting the final content let finalContent; try { finalContent = await runner.finalContent();