metering: new usage endpoint + puter-js changes for it (#1738)
Docker Image CI / build-and-push-image (push) Has been cancelled
Maintain Release Merge PR / update-release-pr (push) Has been cancelled
release-please / release-please (push) Has been cancelled
test / test (20.x) (push) Has been cancelled
test / test (22.x) (push) Has been cancelled
test / api-test (22.x) (push) Has been cancelled

* metering: new usage endpoint

* metering: new usage endpoint + puter-js changes for it
This commit is contained in:
Daniel Salazar
2025-10-14 12:44:40 -07:00
committed by GitHub
parent 2819ec7b06
commit 96a58ced29
42 changed files with 260 additions and 105 deletions
+1
View File
@@ -0,0 +1 @@
import './routes/usage.js';
+5
View File
@@ -0,0 +1,5 @@
{
"name": "@heyputer/extension-metering-service",
"main": "main.js",
"type": "module"
}
@@ -0,0 +1,35 @@
/** @type {import('@heyputer/backend/src/services/MeteringService/MeteringServiceWrapper.mjs').MeteringAndBillingServiceWrapper} */
const meteringAndBillingServiceWrapper = extension.import('service:meteringService');
// TODO DS: move this to its own router and just use under this path
extension.get('/v2/usage', { subdomain: 'api' }, async (req, res) => {
const meteringAndBillingService = meteringAndBillingServiceWrapper.meteringAndBillingService;
const actor = req.actor;
if ( !actor ) {
throw Error('actor not found in context');
}
const actorUsage = await meteringAndBillingService.getActorCurrentMonthUsageDetails(actor);
res.status(200).json(actorUsage);
return;
});
extension.get('/v2/usage/:appId', { subdomain: 'api' }, async (req, res) => {
const meteringAndBillingService = meteringAndBillingServiceWrapper.meteringAndBillingService;
const actor = req.actor;
if ( !actor ) {
throw Error('actor not found in context');
}
const appId = req.params.appId;
if ( !appId ) {
res.status(400).json({ error: 'appId parameter is required' });
return;
}
const appUsage = await meteringAndBillingService.getActorCurrentMonthAppUsageDetails(actor, appId);
res.status(200).json(appUsage);
return;
});
console.debug('Loaded /v2/usage route');
+1 -1
View File
@@ -412,7 +412,7 @@ const install = async ({ context, services, app, useapi, modapi }) => {
const { WorkerService } = require('./services/worker/WorkerService');
services.registerService("worker-service", WorkerService);
const { MeteringAndBillingServiceWrapper } = require("./services/abuse-prevention/MeteringService/index.mjs");
const { MeteringAndBillingServiceWrapper } = require("./services/MeteringService/MeteringServiceWrapper.mjs");
services.registerService('meteringService', MeteringAndBillingServiceWrapper);
const { PermissionShortcutService } = require('./services/auth/PermissionShortcutService');
@@ -44,7 +44,7 @@ const VALID_ENGINES = ['standard', 'neural', 'long-form', 'generative'];
* @extends BaseService
*/
class AWSPollyService extends BaseService {
/** @type {import('../../services/abuse-prevention/MeteringService/MeteringService').MeteringAndBillingService} */
/** @type {import('../../services/MeteringService/MeteringService').MeteringAndBillingService} */
meteringAndBillingService;
static MODULES = {
@@ -31,7 +31,7 @@ const { Context } = require("../../util/context");
* Handles both S3-stored and buffer-based document processing with automatic region management.
*/
class AWSTextractService extends BaseService {
/** @type {import('../../services/abuse-prevention/MeteringService/MeteringService').MeteringAndBillingService} */
/** @type {import('../../services/MeteringService/MeteringService').MeteringAndBillingService} */
meteringAndBillingService;
/**
* AWS Textract service for OCR functionality
@@ -51,7 +51,7 @@ class ClaudeService extends BaseService {
* @returns {Promise<void>}
*/
/** @type {import('../../services/abuse-prevention/MeteringService/MeteringService').MeteringAndBillingService} */
/** @type {import('../../services/MeteringService/MeteringService').MeteringAndBillingService} */
#meteringAndBillingService;
async _init() {
@@ -36,7 +36,7 @@ class DeepSeekService extends BaseService {
};
/**
* @type {import('../../services/abuse-prevention/MeteringService/MeteringService').MeteringAndBillingService}
* @type {import('../../services/MeteringService/MeteringService').MeteringAndBillingService}
*/
meteringAndBillingService;
/**
@@ -30,7 +30,7 @@ const { GoogleGenAI } = require('@google/genai');
* the puter-image-generation interface.
*/
class GeminiImageGenerationService extends BaseService {
/** @type {import('../../services/abuse-prevention/MeteringService/MeteringService').MeteringAndBillingService} */
/** @type {import('../../services/MeteringService/MeteringService').MeteringAndBillingService} */
meteringAndBillingService;
static MODULES = {
};
@@ -7,7 +7,7 @@ const { Context } = require("../../util/context");
class GeminiService extends BaseService {
/**
* @type {import('../../services/abuse-prevention/MeteringService/MeteringService').MeteringAndBillingService}
* @type {import('../../services/MeteringService/MeteringService').MeteringAndBillingService}
*/
meteringAndBillingService = undefined;
@@ -22,7 +22,7 @@ const BaseService = require("../../services/BaseService");
const { Context } = require("../../util/context");
const OpenAIUtil = require("./lib/OpenAIUtil");
/** @type {import('../../services/abuse-prevention/MeteringService/MeteringService').MeteringAndBillingService} */
/** @type {import('../../services/MeteringService/MeteringService').MeteringAndBillingService} */
/**
* Service class for integrating with Groq AI's language models.
@@ -34,7 +34,7 @@ const OpenAIUtil = require("./lib/OpenAIUtil");
* @extends BaseService
*/
class GroqAIService extends BaseService {
/** @type {import('../../services/abuse-prevention/MeteringService/MeteringService').MeteringAndBillingService} */
/** @type {import('../../services/MeteringService/MeteringService').MeteringAndBillingService} */
meteringAndBillingService;
static MODULES = {
Groq: require('groq-sdk'),
@@ -31,7 +31,7 @@ const { Context } = require("../../util/context");
* for different models and implements the puter-chat-completion interface.
*/
class MistralAIService extends BaseService {
/** @type {import('../../services/abuse-prevention/MeteringService/MeteringService').MeteringAndBillingService} */
/** @type {import('../../services/MeteringService/MeteringService').MeteringAndBillingService} */
meteringAndBillingService;
static MODULES = {
'@mistralai/mistralai': require('@mistralai/mistralai'),
@@ -31,7 +31,7 @@ const { Context } = require("../../util/context");
* validation, and spending tracking.
*/
class OpenAIImageGenerationService extends BaseService {
/** @type {import('../../services/abuse-prevention/MeteringService/MeteringService').MeteringAndBillingService} */
/** @type {import('../../services/MeteringService/MeteringService').MeteringAndBillingService} */
meteringAndBillingService;
static MODULES = {
@@ -48,7 +48,7 @@ export class OpenAICompletionService {
#models;
/** @type {import('../../../services/abuse-prevention/MeteringService/MeteringService').MeteringAndBillingService} */
/** @type {import('../../../services/MeteringService/MeteringService.js').MeteringAndBillingService} */
#meteringAndBillingService;
constructor({ serviceName, config, globalConfig, aiChatService, meteringAndBillingService, models = OPEN_AI_MODELS, defaultModel = 'gpt-4.1-nano' }) {
@@ -46,7 +46,7 @@ class OpenRouterService extends BaseService {
return model;
}
/** @type {import('../../services/abuse-prevention/MeteringService/MeteringService').MeteringAndBillingService} */
/** @type {import('../../services/MeteringService/MeteringService').MeteringAndBillingService} */
meteringAndBillingService;
/**
@@ -36,7 +36,7 @@ const { Context } = require("../../util/context");
*/
class TogetherAIService extends BaseService {
/**
* @type {import('../../services/abuse-prevention/MeteringService/MeteringService').MeteringAndBillingService}
* @type {import('../../services/MeteringService/MeteringService').MeteringAndBillingService}
*/
meteringAndBillingService;
static MODULES = {
@@ -33,7 +33,7 @@ class XAIService extends BaseService {
static MODULES = {
openai: require('openai'),
};
/** @type {import('../../services/abuse-prevention/MeteringService/MeteringService').MeteringAndBillingService} */
/** @type {import('../../services/MeteringService/MeteringService').MeteringAndBillingService} */
meteringAndBillingService;
adapt_model(model) {
@@ -1,12 +1,13 @@
// @ts-ignore
import type KVStoreInterface from "../../../modules/kvstore/KVStoreInterfaceService.js";
import { SystemActorType, type Actor } from "../auth/Actor.js";
// @ts-ignore
import { SystemActorType, type Actor } from "../../auth/Actor.js";
import type { AlarmService } from "../../modules/core/AlarmService.js";
// @ts-ignore
import type { AlarmService } from "../../../modules/core/AlarmService.js";
import type { DBKVStore } from '../repositories/DBKVStore/DBKVStore.mjs';
// @ts-ignore
import type { SUService } from "../../SUService.js";
import type { SUService } from "../SUService.js";
import { COST_MAPS } from "./costMaps/index.js";
import { SUB_POLICIES } from "./subPolicies/index.js";
interface ActorWithType extends Actor {
type: {
app: { uid: string }
@@ -26,14 +27,6 @@ interface UsageByType {
[serviceName: string]: number
}
// NOTE: create daily and hourly entry buckets that expire at given ranges 2 days for hours, 6 months for daily
// Store consumed microcents whenever a consumption event goes through
// keep timestamp of consumption last updated to limit burst usage
const POLICY_TYPES = {
'free': {} // TODO DS: define what needs to go here
}
const GLOBAL_APP_KEY = 'os-global'; // TODO DS: this should be loaded from config or db eventually
const METRICS_PREFIX = 'metering';
const POLICY_PREFIX = 'policy';
@@ -44,16 +37,16 @@ const PERIOD_ESCAPE = '_dot_'; // to replace dots in usage types for kvstore pat
*/
export class MeteringAndBillingService {
#kvClientWrapper: KVStoreInterface
#kvClientWrapper: DBKVStore
#superUserService: SUService
#alarmService: AlarmService
constructor({ kvClientWrapper, superUserService, alarmService }: { kvClientWrapper: KVStoreInterface, superUserService: SUService, alarmService: AlarmService }) {
constructor({ kvClientWrapper, superUserService, alarmService }: { kvClientWrapper: DBKVStore, superUserService: SUService, alarmService: AlarmService }) {
this.#superUserService = superUserService;
this.#kvClientWrapper = kvClientWrapper;
this.#alarmService = alarmService;
}
utilRecordUsageObject(trackedUsageObject: Record<string, number>, actor: Actor, modelPrefix: string) {
utilRecordUsageObject(trackedUsageObject: Record<string, number>, actor: ActorWithType, modelPrefix: string) {
Object.entries(trackedUsageObject).forEach(([usageKind, amount]) => {
this.incrementUsage(actor, `${modelPrefix}:${usageKind}`, amount);
});
@@ -146,7 +139,6 @@ export class MeteringAndBillingService {
});
return { total: 0 } as UsageByType;
}
// TODO DS: this should increment the cost for the given type of operation, and the total cost for daily, weekly and monthly usage
}
async getActorCurrentMonthUsageDetails(actor: ActorWithType) {
@@ -159,54 +151,71 @@ export class MeteringAndBillingService {
`${METRICS_PREFIX}:actor:${actor.type.user.uuid}:${currentMonth}`,
`${METRICS_PREFIX}:actor:${actor.type.user.uuid}:apps:${currentMonth}`
]
return this.#superUserService.sudo(async () => {
return await this.#superUserService.sudo(async () => {
const [usage, appTotals] = await this.#kvClientWrapper.get({ key: keys }) as [UsageByType | null, Record<string, UsageByType> | null];
return {
usage: usage || { total: 0 },
appTotals: appTotals || {},
// only show details of app based on actor, aggregate all as others, except if app is global one or null, then show all
const appId = actor.type?.app?.uid
if (appTotals && appId) {
const filteredAppTotals: Record<string, UsageByType> = {};
let othersTotal: UsageByType | null = null;
Object.entries(appTotals).forEach(([appKey, appUsage]) => {
if (appKey === appId) {
filteredAppTotals[appKey] = appUsage;
} else {
Object.entries(appUsage).forEach(([usageKind, amount]) => {
if (!othersTotal![usageKind]) {
othersTotal![usageKind] = 0;
}
othersTotal![usageKind] += amount;
})
}
});
if (othersTotal) {
filteredAppTotals['others'] = othersTotal;
}
return {
usage: usage || { total: 0 },
appTotals: filteredAppTotals,
}
} else {
return {
usage: usage || { total: 0 },
appTotals: appTotals || {},
}
}
})
}
async getActorCurrentMonthAppUsageDetails(actor: ActorWithType, appId: string) {
async getActorCurrentMonthAppUsageDetails(actor: ActorWithType, appId?: string) {
if (!actor.type?.user?.uuid) {
throw new Error('Actor must be a user to get usage details');
}
appId = appId || actor.type?.app?.uid || GLOBAL_APP_KEY;
// batch get actor usage, per app usage, and actor app totals for the month
const currentMonth = this.#getMonthYearString();
const key = `${METRICS_PREFIX}:actor:${actor.type.user.uuid}:app:${appId}:${currentMonth}`
return this.#superUserService.sudo(async () => {
return await this.#superUserService.sudo(async () => {
const usage = await this.#kvClientWrapper.get({ key }) as UsageByType | null;
// only show usage if actor app is the same or if global app ( null appId )
const actorAppId = actor.type?.app?.uid
if (actorAppId && actorAppId !== appId && appId !== GLOBAL_APP_KEY) {
throw new Error('Actor can only get usage details for their own app or global app');
}
return usage || { total: 0 };
})
}
async getCurrentMonthsConsumedCredit(actor: ActorWithType) {
if (!actor.type?.user?.uuid) {
throw new Error('Actor must be a user to get consumed credits');
}
const currentMonth = this.#getMonthYearString();
// batch get actor usage for the month, and actor policy, and actor policy addons to then compute cost
const keys = [
`${METRICS_PREFIX}:actor:${actor.type.user.uuid}:${currentMonth}`,
`${POLICY_PREFIX}:actor:${actor.type.user.uuid}:addons`,
]
return this.#superUserService.sudo(async () => {
const [usage, addons] = await this.#kvClientWrapper.get({ key: keys }) as [UsageByType | null, PolicyAddOns | null];
return usage?.total || 0;
})
}
async getActorPolicy(actor: ActorWithType) {
async getActorPolicy(actor: ActorWithType): Promise<(keyof typeof SUB_POLICIES) | null> {
if (!actor.type?.user.uuid) {
throw new Error('Actor must be a user to get policy');
}
const key = `${POLICY_PREFIX}:actor:${actor.type.user.uuid}`;
return this.#superUserService.sudo(async () => {
const policy = await this.#kvClientWrapper.get({ key });
policy
return (policy || 'free') as keyof typeof POLICY_TYPES;
return policy as (keyof typeof SUB_POLICIES) || null;
})
}
@@ -236,7 +245,7 @@ export class MeteringAndBillingService {
})
}
handlePolicyPurchase(actor: ActorWithType, policyType: keyof typeof POLICY_TYPES) {
handlePolicyPurchase(actor: ActorWithType, policyType: keyof typeof SUB_POLICIES) {
// TODO DS: this should leverage extensions to call billing implementations
@@ -1,9 +1,9 @@
import BaseService from '../../BaseService.js';
import BaseService from '../BaseService.js';
import { MeteringAndBillingService } from "./MeteringService.js";
export class MeteringAndBillingServiceWrapper extends BaseService {
/** @type {import('./MeteringService').MeteringAndBillingService} */
/** @type {import('./MeteringService.js').MeteringAndBillingService} */
meteringAndBillingService = undefined;
_init() {
this.meteringAndBillingService = new MeteringAndBillingService({
@@ -0,0 +1,7 @@
import { REGISTERED_USER_FREE } from "./registeredUserFreePolicy";
import { TEMP_USER_FREE } from "./tempUserFreePolicy";
export const SUB_POLICIES = {
TEMP_USER_FREE,
REGISTERED_USER_FREE,
}
@@ -0,0 +1,6 @@
import { toMicroCents } from "../utils";
export const REGISTERED_USER_FREE = {
monthUsageAllowence: toMicroCents(0.50),
monthlyStorageAllowence: 100 * 1024 * 1024, // 100MiB
};
@@ -0,0 +1,6 @@
import { toMicroCents } from "../utils";
export const TEMP_USER_FREE = {
monthUsageAllowence: toMicroCents(0.25),
monthlyStorageAllowence: 100 * 1024 * 1024, // 100MiB
};
@@ -5,7 +5,7 @@ import { Context } from "../../../util/context.js";
const GLOBAL_APP_KEY = 'global';
export class DBKVStore {
#db;
/** @type {import('../../abuse-prevention/MeteringService/MeteringService').MeteringAndBillingService} */
/** @type {import('../../MeteringService/MeteringService.js').MeteringAndBillingService} */
#meteringService;
#global_config = {};
+127 -42
View File
@@ -1,11 +1,10 @@
import * as utils from '../lib/utils.js'
import * as utils from '../lib/utils.js';
class Auth{
// Used to generate a unique message id for each message sent to the host environment
// we start from 1 because 0 is falsy and we want to avoid that for the message id
#messageID = 1;
/**
* Creates a new instance with the given authentication token, API origin, and app ID,
*
@@ -14,7 +13,7 @@ class Auth{
* @param {string} APIOrigin - Origin of the API server. Used to build the API endpoint URLs.
* @param {string} appID - ID of the app to use.
*/
constructor (context) {
constructor(context) {
this.authToken = context.authToken;
this.APIOrigin = context.APIOrigin;
this.appID = context.appID;
@@ -27,22 +26,22 @@ class Auth{
* @memberof [Auth]
* @returns {void}
*/
setAuthToken (authToken) {
setAuthToken(authToken) {
this.authToken = authToken;
}
/**
* Sets the API origin.
*
*
* @param {string} APIOrigin - The new API origin.
* @memberof [Auth]
* @returns {void}
*/
setAPIOrigin (APIOrigin) {
setAPIOrigin(APIOrigin) {
this.APIOrigin = APIOrigin;
}
signIn = (options) =>{
signIn = (options) => {
options = options || {};
return new Promise((resolve, reject) => {
@@ -50,17 +49,17 @@ class Auth{
let w = 600;
let h = 600;
let title = 'Puter';
var left = (screen.width/2)-(w/2);
var top = (screen.height/2)-(h/2);
var left = (screen.width / 2) - (w / 2);
var top = (screen.height / 2) - (h / 2);
// Store reference to the popup window
const popup = window.open(puter.defaultGUIOrigin + '/action/sign-in?embedded_in_popup=true&msg_id=' + msg_id + (window.crossOriginIsolated ? '&cross_origin_isolated=true' : '') +(options.attempt_temp_user_creation ? '&attempt_temp_user_creation=true' : ''),
title,
'toolbar=no, location=no, directories=no, status=no, menubar=no, scrollbars=no, resizable=no, copyhistory=no, width='+w+', height='+h+', top='+top+', left='+left);
const popup = window.open(`${puter.defaultGUIOrigin}/action/sign-in?embedded_in_popup=true&msg_id=${msg_id}${window.crossOriginIsolated ? '&cross_origin_isolated=true' : ''}${options.attempt_temp_user_creation ? '&attempt_temp_user_creation=true' : ''}`,
title,
`toolbar=no, location=no, directories=no, status=no, menubar=no, scrollbars=no, resizable=no, copyhistory=no, width=${w}, height=${h}, top=${top}, left=${left}`);
// Set up interval to check if popup was closed
const checkClosed = setInterval(() => {
if (popup.closed) {
if ( popup.closed ) {
clearInterval(checkClosed);
// Remove the message listener
window.removeEventListener('message', messageHandler);
@@ -69,21 +68,23 @@ class Auth{
}, 100);
function messageHandler(e) {
if(e.data.msg_id == msg_id){
if ( e.data.msg_id == msg_id ){
// Clear the interval since we got a response
clearInterval(checkClosed);
// remove redundant attributes
delete e.data.msg_id;
delete e.data.msg;
if(e.data.success){
if ( e.data.success ){
// set the auth token
puter.setAuthToken(e.data.token);
resolve(e.data);
}else
} else
{
reject(e.data);
}
// delete the listener
window.removeEventListener('message', messageHandler);
@@ -92,20 +93,24 @@ class Auth{
window.addEventListener('message', messageHandler);
});
}
};
isSignedIn = () =>{
if(puter.authToken)
isSignedIn = () => {
if ( puter.authToken )
{
return true;
}
else
{
return false;
}
}
};
getUser = function(...args){
let options;
// If first argument is an object, it's the options
if (typeof args[0] === 'object' && args[0] !== null) {
if ( typeof args[0] === 'object' && args[0] !== null ) {
options = args[0];
} else {
// Otherwise, we assume separate arguments are provided
@@ -122,45 +127,125 @@ class Auth{
utils.setupXhrEventHandlers(xhr, options.success, options.error, resolve, reject);
xhr.send();
})
}
});
};
signOut = () =>{
signOut = () => {
puter.resetAuthToken();
}
};
async whoami () {
async whoami() {
try {
const resp = await fetch(this.APIOrigin + '/whoami', {
const resp = await fetch(`${this.APIOrigin}/whoami`, {
headers: {
Authorization: `Bearer ${this.authToken}`
}
Authorization: `Bearer ${this.authToken}`,
},
});
const result = await resp.json();
// Log the response
if (globalThis.puter?.apiCallLogger?.isEnabled()) {
if ( globalThis.puter?.apiCallLogger?.isEnabled() ) {
globalThis.puter.apiCallLogger.logRequest({
service: 'auth',
operation: 'whoami',
params: {},
result: result
result: result,
});
}
return result;
} catch (error) {
} catch( error ) {
// Log the error
if (globalThis.puter?.apiCallLogger?.isEnabled()) {
if ( globalThis.puter?.apiCallLogger?.isEnabled() ) {
globalThis.puter.apiCallLogger.logRequest({
service: 'auth',
operation: 'whoami',
params: {},
error: {
message: error.message || error.toString(),
stack: error.stack
}
stack: error.stack,
},
});
}
throw error;
}
}
async getMonthlyUsage() {
try {
const resp = await fetch(`${this.APIOrigin}/v2/usage`, {
headers: {
Authorization: `Bearer ${this.authToken}`,
},
});
const result = await resp.json();
// Log the response
if ( globalThis.puter?.apiCallLogger?.isEnabled() ) {
globalThis.puter.apiCallLogger.logRequest({
service: 'auth',
operation: 'usage',
params: {},
result: result,
});
}
return result;
} catch( error ) {
// Log the error
if ( globalThis.puter?.apiCallLogger?.isEnabled() ) {
globalThis.puter.apiCallLogger.logRequest({
service: 'auth',
operation: 'usage',
params: {},
error: {
message: error.message || error.toString(),
stack: error.stack,
},
});
}
throw error;
}
}
async getDetailedAppUsage(appId) {
if ( !appId ) {
throw new Error('appId is required');
}
try {
const resp = await fetch(`${this.APIOrigin}/v2/usage/${appId}`, {
headers: {
Authorization: `Bearer ${this.authToken}`,
},
});
const result = await resp.json();
// Log the response
if ( globalThis.puter?.apiCallLogger?.isEnabled() ) {
globalThis.puter.apiCallLogger.logRequest({
service: 'auth',
operation: 'detailed_app_usage',
params: { appId },
result: result,
});
}
return result;
} catch( error ) {
// Log the error
if ( globalThis.puter?.apiCallLogger?.isEnabled() ) {
globalThis.puter.apiCallLogger.logRequest({
service: 'auth',
operation: 'detailed_app_usage',
params: { appId },
error: {
message: error.message || error.toString(),
stack: error.stack,
},
});
}
throw error;
@@ -168,4 +253,4 @@ class Auth{
}
}
export default Auth
export default Auth;
+4 -3
View File
@@ -1,8 +1,8 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "CommonJS",
"moduleResolution": "node10",
"module": "node16",
"moduleResolution": "node16",
"rootDir": ".",
"strict": true,
"esModuleInterop": true,
@@ -15,6 +15,7 @@
"**/test/**",
"**/tests/**",
"node_modules",
"dist"
"dist",
"extensions"
]
}