fix: ai metering (#2393)
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-backend (24.x) (push) Has been cancelled
test / API tests (node env, api-test) (24.x) (push) Has been cancelled
test / puterjs (node env, vitest) (24.x) (push) Has been cancelled

* fix: expose getUserService in extension typings

* fix: ai metering
This commit is contained in:
Daniel Salazar
2026-02-01 18:14:14 -08:00
committed by GitHub
parent b15b466d36
commit afbb76f95f
7 changed files with 45 additions and 38 deletions
+1 -1
View File
@@ -3,9 +3,9 @@ import type { WebServerService } from '@heyputer/backend/src/modules/web/WebServ
import type query from '@heyputer/backend/src/om/query/query';
import type { Actor } from '@heyputer/backend/src/services/auth/Actor.js';
import type { BaseDatabaseAccessService } from '@heyputer/backend/src/services/database/BaseDatabaseAccessService.d.ts';
import type { GetUserService } from '@heyputer/backend/src/services/GetUserService.js';
import type { EmailService } from '@heyputer/backend/src/services/EmailService.js';
import type { EntityStoreService } from '@heyputer/backend/src/services/EntityStoreService.js';
import type { GetUserService } from '@heyputer/backend/src/services/GetUserService.js';
import type { MeteringService } from '@heyputer/backend/src/services/MeteringService/MeteringService.ts';
import type { MeteringServiceWrapper } from '@heyputer/backend/src/services/MeteringService/MeteringServiceWrapper.mjs';
import type { DynamoKVStore } from '@heyputer/backend/src/services/repositories/DynamoKVStore/DynamoKVStore.ts';
@@ -27,11 +27,14 @@ export class MeteringService {
}
utilRecordUsageObject<T extends Record<string, number>>(trackedUsageObject: T, actor: Actor, modelPrefix: string, costsOverrides?: Partial<Record<keyof T, number>>) {
this.batchIncrementUsages(actor, Object.entries(trackedUsageObject).map(([usageKind, amount]) => ({
usageType: `${modelPrefix}:${usageKind}`,
usageAmount: amount,
costOverride: costsOverrides?.[usageKind as keyof T] || undefined,
})));
this.batchIncrementUsages(actor, Object.entries(trackedUsageObject).map(([usageKind, amount]) => {
const hasOverride = !!costsOverrides && Object.prototype.hasOwnProperty.call(costsOverrides, usageKind);
return {
usageType: `${modelPrefix}:${usageKind}`,
usageAmount: amount,
costOverride: hasOverride ? costsOverrides![usageKind as keyof T] : undefined,
};
}));
}
#getMonthYearString () {
@@ -454,14 +454,6 @@ export class AIChatService extends BaseService {
const fallback = this.getFallbackModel(model.id, tried, triedProviders);
tried.push(model.id);
triedProviders.push(model.provider!);
if ( tried.length >= MAX_FALLBACKS ) {
console.error('max fallbacks reached', { tried, triedProviders });
break;
}
if ( ! fallback ) {
throw new Error('no fallback model available');
}
@@ -478,6 +470,14 @@ export class AIChatService extends BaseService {
let fallBackModel = this.getModel({ modelId: fallbackModelId, provider: fallbackProvider });
tried.push(fallbackModelId);
triedProviders.push(fallbackProvider);
if ( tried.length > MAX_FALLBACKS ) {
console.error('max fallbacks reached', { tried, triedProviders });
break;
}
const fallbackUsageAllowed = await this.meteringService.hasEnoughCredits(actor, 1); // we checked earlier, assume same costs
if ( ! fallbackUsageAllowed ) {
@@ -655,7 +655,7 @@ export class AIChatService extends BaseService {
if ( targetModel.id.startsWith('openrouter:') || targetModel.id.startsWith('togetherai:') ) {
[aiProvider, modelToSearch] = targetModel.id.replace('openrouter:', '').replace('togetherai:', '').toLowerCase().split('/');
} else {
[aiProvider, modelToSearch] = targetModel.provider!.toLowerCase().replace('gemini', 'google').replace('openai-completion', 'openai'), targetModel.id.toLowerCase();
[aiProvider, modelToSearch] = targetModel.provider!.toLowerCase().replace('gemini', 'google').replace('openai-completion', 'openai').replace('openai-responses', 'openai'), targetModel.id.toLowerCase();
}
const potentialMatches = models.filter(model => {
@@ -107,9 +107,10 @@ export class MistralAIProvider implements IChatProvider {
stream,
usage_calculator: ({ usage }) => {
const trackedUsage = OpenAIUtil.extractMeteredUsage(usage);
this.#meteringService.utilRecordUsageObject(trackedUsage, actor, `mistral:${selectedModel.id}`);
// Still return legacy cost calculation for compatibility
const costsOverrideFromModel = Object.fromEntries(Object.entries(trackedUsage).map(([k, v]) => {
return [k, v * (selectedModel.costs[k] || 0)];
}));
this.#meteringService.utilRecordUsageObject(trackedUsage, actor, `mistral:${selectedModel.id}`, costsOverrideFromModel);
return trackedUsage;
},
});
@@ -2,7 +2,7 @@ import { IChatModel } from '../types';
export const MISTRAL_MODELS: IChatModel[] = [
{
puterId: "mistralai:mistralai/mistral-medium-2508",
puterId: 'mistralai:mistralai/mistral-medium-2508',
id: 'mistral-medium-2508',
name: 'mistral-medium-2508',
aliases: [
@@ -23,7 +23,7 @@ export const MISTRAL_MODELS: IChatModel[] = [
},
},
{
puterId: "mistralai:mistralai/open-mistral-7b",
puterId: 'mistralai:mistralai/open-mistral-7b',
id: 'open-mistral-7b',
name: 'open-mistral-7b',
aliases: [
@@ -44,7 +44,7 @@ export const MISTRAL_MODELS: IChatModel[] = [
},
},
{
puterId: "mistralai:mistralai/open-mistral-nemo",
puterId: 'mistralai:mistralai/open-mistral-nemo',
id: 'open-mistral-nemo',
name: 'open-mistral-nemo',
aliases: [
@@ -66,7 +66,7 @@ export const MISTRAL_MODELS: IChatModel[] = [
},
},
{
puterId: "mistralai:mistralai/pixtral-large-2411",
puterId: 'mistralai:mistralai/pixtral-large-2411',
id: 'pixtral-large-2411',
name: 'pixtral-large-2411',
aliases: [
@@ -87,7 +87,7 @@ export const MISTRAL_MODELS: IChatModel[] = [
},
},
{
puterId: "mistralai:mistralai/codestral-2508",
puterId: 'mistralai:mistralai/codestral-2508',
id: 'codestral-2508',
name: 'codestral-2508',
aliases: [
@@ -107,7 +107,7 @@ export const MISTRAL_MODELS: IChatModel[] = [
},
},
{
puterId: "mistralai:mistralai/devstral-small-2507",
puterId: 'mistralai:mistralai/devstral-small-2507',
id: 'devstral-small-2507',
name: 'devstral-small-2507',
aliases: [
@@ -128,7 +128,7 @@ export const MISTRAL_MODELS: IChatModel[] = [
},
},
{
puterId: "mistralai:mistralai/devstral-medium-2507",
puterId: 'mistralai:mistralai/devstral-medium-2507',
id: 'devstral-medium-2507',
name: 'devstral-medium-2507',
aliases: [
@@ -149,7 +149,7 @@ export const MISTRAL_MODELS: IChatModel[] = [
},
},
{
puterId: "mistralai:mistralai/mistral-small-2506",
puterId: 'mistralai:mistralai/mistral-small-2506',
id: 'mistral-small-2506',
name: 'mistral-small-2506',
aliases: [
@@ -169,7 +169,7 @@ export const MISTRAL_MODELS: IChatModel[] = [
},
},
{
puterId: "mistralai:mistralai/magistral-medium-2509",
puterId: 'mistralai:mistralai/magistral-medium-2509',
id: 'magistral-medium-2509',
name: 'magistral-medium-2509',
aliases: [
@@ -189,7 +189,7 @@ export const MISTRAL_MODELS: IChatModel[] = [
},
},
{
puterId: "mistralai:mistralai/magistral-small-2509",
puterId: 'mistralai:mistralai/magistral-small-2509',
id: 'magistral-small-2509',
name: 'magistral-small-2509',
aliases: [
@@ -209,7 +209,7 @@ export const MISTRAL_MODELS: IChatModel[] = [
},
},
{
puterId: "mistralai:mistralai/voxtral-mini-2507",
puterId: 'mistralai:mistralai/voxtral-mini-2507',
id: 'voxtral-mini-2507',
name: 'voxtral-mini-2507',
aliases: [
@@ -229,7 +229,7 @@ export const MISTRAL_MODELS: IChatModel[] = [
},
},
{
puterId: "mistralai:mistralai/voxtral-small-2507",
puterId: 'mistralai:mistralai/voxtral-small-2507',
id: 'voxtral-small-2507',
name: 'voxtral-small-2507',
aliases: [
@@ -249,7 +249,7 @@ export const MISTRAL_MODELS: IChatModel[] = [
},
},
{
puterId: "mistralai:mistralai/mistral-large-2512",
puterId: 'mistralai:mistralai/mistral-large-2512',
id: 'mistral-large-latest',
name: 'mistral-large-2512',
aliases: [
@@ -269,7 +269,7 @@ export const MISTRAL_MODELS: IChatModel[] = [
},
},
{
puterId: "mistralai:mistralai/ministral-3b-2512",
puterId: 'mistralai:mistralai/ministral-3b-2512',
id: 'ministral-3b-2512',
name: 'ministral-3b-2512',
aliases: [
@@ -289,7 +289,7 @@ export const MISTRAL_MODELS: IChatModel[] = [
},
},
{
puterId: "mistralai:mistralai/ministral-8b-2512",
puterId: 'mistralai:mistralai/ministral-8b-2512',
id: 'ministral-8b-2512',
name: 'ministral-8b-2512',
aliases: [
@@ -309,7 +309,7 @@ export const MISTRAL_MODELS: IChatModel[] = [
},
},
{
puterId: "mistralai:mistralai/ministral-14b-2512",
puterId: 'mistralai:mistralai/ministral-14b-2512',
id: 'ministral-14b-2512',
name: 'ministral-14b-2512',
aliases: [
@@ -125,6 +125,7 @@ export class OllamaChatProvider implements IChatProvider {
} as ChatCompletionCreateParams) ;
const modelDetails = (await this.models()).find(m => m.id === `ollama:${model}`);
const modelIdForMetering = modelDetails?.id ?? (model ? (model.startsWith('ollama/') ? `ollama:${model}` : `ollama:ollama/${model}`) : undefined);
return OpenAIUtil.handle_completion_output({
usage_calculator: ({ usage }) => {
@@ -136,7 +137,9 @@ export class OllamaChatProvider implements IChatProvider {
const costOverwrites = Object.fromEntries(Object.keys(trackedUsage).map((k) => {
return [k, 0]; // override to 0 since local is free
}));
this.#meteringService.utilRecordUsageObject(trackedUsage, actor, modelDetails!.id, costOverwrites);
if ( modelIdForMetering ) {
this.#meteringService.utilRecordUsageObject(trackedUsage, actor, modelIdForMetering, costOverwrites);
}
return trackedUsage;
},
stream,
@@ -154,4 +157,4 @@ export class OllamaChatProvider implements IChatProvider {
getDefaultModel () {
return 'gpt-oss:20b';
}
}
}
@@ -28,7 +28,7 @@ const POLL_INTERVAL_MS = 5_000;
const DEFAULT_TIMEOUT_MS = 5 * 60 * 1000; // 5 minutes
const DEFAULT_MODEL = 'minimax/video-01-director';
const DEFAULT_DURATION_SECONDS = 6;
const DEFAULT_USAGE_KEY = 'togetherai:default';
const DEFAULT_USAGE_KEY = 'together-video:default';
let models = [];
@@ -253,7 +253,7 @@ class TogetherVideoGenerationService extends BaseService {
#determineUsageKey (model) {
if ( typeof model === 'string' && model.trim() ) {
return `togetherai:${model}`;
return `together-video:${model}`;
}
return DEFAULT_USAGE_KEY;
}