Revert "fix(billing): IAM-native, fail-closed AI metering (stop unmetered leak)"

This reverts commit 4233c8760f.
This commit is contained in:
Hanzo AI
2026-06-27 12:21:31 -07:00
parent 4233c8760f
commit 04210dea16
8 changed files with 351 additions and 547 deletions
+58 -60
View File
@@ -4,11 +4,7 @@ const { createAutoRefillTransaction } = require('./Transaction');
const { logViolation } = require('~/cache');
const { getMultiplier } = require('./tx');
const { Balance } = require('~/db/models');
const {
getCommerceClient,
getIamToken,
getBillingOrg,
} = require('~/server/services/CommerceClient');
const { getCommerceClient } = require('~/server/services/CommerceClient');
/**
* Default minimum balance in tokenCredits.
@@ -45,6 +41,29 @@ function isInvalidDate(date) {
return isNaN(date);
}
/**
* Check if a specific model is allowed for the user's billing tier.
* Fails open: if Commerce is not configured or unreachable, all models allowed.
*
* @param {string} userId - MongoDB user ID
* @param {string} model - Model identifier
* @returns {Promise<{allowed: boolean, tier: string, allowedModels: string[]}>}
*/
const checkModelAccess = async function (userId, model) {
const commerceClient = getCommerceClient();
if (!commerceClient) {
return { allowed: true, tier: 'unknown', allowedModels: ['*'] };
}
// Look up the Commerce user ID from the balance record
const record = await Balance.findOne({ user: userId }, 'commerceUserId').lean();
if (!record?.commerceUserId) {
return { allowed: true, tier: 'unknown', allowedModels: ['*'] };
}
return commerceClient.isModelAllowed(record.commerceUserId, model);
};
/**
* Calculates token cost and decides whether the request may proceed.
*
@@ -76,32 +95,13 @@ const checkBalanceRecord = async function ({
const tokenCost = amount * multiplier;
const commerceClient = getCommerceClient();
const token = getIamToken(req);
const billingOrg = getBillingOrg(req);
const billingOrg = (req?.user?.organization ?? '').toString().trim();
// IAM-native money gate — Commerce is authoritative and the path FAILS CLOSED.
// We confirm a sufficient balance against the user's OWN org (commerce derives
// the tenant from their IAM JWT). Anything we cannot positively confirm —
// missing IAM identity, commerce unreachable, balance below minimum — REFUSES
// the call. We never fall through to a permissive local gate here, so unmetered
// AI can't leak. A funded user always passes; only no-credit/unconfirmable is
// blocked.
if (commerceClient && (token || billingOrg)) {
// No IAM token ⇒ we cannot prove who the tenant is or read their balance.
// Block (clean "sign in to continue" UX) rather than serve unmetered tokens.
if (!token) {
logger.warn('[Balance.check] No IAM token on request — blocking (fail-closed)', {
user,
billingOrg,
});
return { canSpend: false, balance: 0, tokenCost, reason: 'no_billing_identity' };
}
const minCents = Math.max(getMinBalanceCents(), 1);
let balance;
// Commerce-first authoritative gate (per-org, fail closed).
if (commerceClient && billingOrg) {
let commerceBalance;
try {
balance = await commerceClient.getMyBalance(token, billingOrg);
commerceBalance = await commerceClient.checkBalance(billingOrg);
} catch (err) {
logger.error('[Balance.check] Commerce unreachable — blocking (fail-closed)', {
user,
@@ -111,44 +111,41 @@ const checkBalanceRecord = async function ({
return { canSpend: false, balance: 0, tokenCost, reason: 'commerce_unavailable' };
}
if ((balance.available || 0) >= minCents) {
// Decisive: Commerce confirms funds → allow. Do not consult local credits.
return { canSpend: true, balance: balance.available, tokenCost };
}
// Below minimum. Self-heal: grant the idempotent $5 welcome credit once and
// re-read. This funds brand-new accounts (and any pre-existing user who was
// created before this shipped) on their first message WITHOUT letting a user
// who already spent their credit top themselves up — commerce dedupes the
// grant by tag, so a returning empty wallet stays empty and stays blocked.
await commerceClient.grantWelcome(token);
try {
balance = await commerceClient.getMyBalance(token, billingOrg);
} catch (err) {
logger.error('[Balance.check] Commerce re-read failed after grant — blocking', {
const available = commerceBalance.available || 0;
const minCents = Math.max(getMinBalanceCents(), 1);
if (available < minCents) {
logger.debug('[Balance.check] Commerce balance insufficient', {
user,
billingOrg,
error: err.message,
available,
minCents,
});
return { canSpend: false, balance: 0, tokenCost, reason: 'commerce_unavailable' };
return { canSpend: false, balance: available, tokenCost, reason: 'commerce_insufficient' };
}
if ((balance.available || 0) >= minCents) {
return { canSpend: true, balance: balance.available, tokenCost };
// Tier/model access (fail-open: a tier hiccup must not block a funded user).
if (model) {
try {
const modelAccess = await commerceClient.isModelAllowed(billingOrg, model);
if (!modelAccess.allowed) {
return {
canSpend: false,
balance: available,
tokenCost,
reason: 'model_not_allowed',
tier: modelAccess.tier,
allowedModels: modelAccess.allowedModels,
};
}
} catch (err) {
logger.warn('[Balance.check] Tier check failed, allowing (balance ok)', {
error: err.message,
});
}
}
logger.debug('[Balance.check] Commerce balance insufficient', {
user,
billingOrg,
available: balance.available,
minCents,
});
return {
canSpend: false,
balance: balance.available || 0,
tokenCost,
reason: 'commerce_insufficient',
};
// Decisive: Commerce says funded → allow. Do not consult local credits.
return { canSpend: true, balance: available, tokenCost };
}
// ── Legacy local-credit gate (Commerce not configured / no billing identity) ──
@@ -304,4 +301,5 @@ const checkBalance = async ({ req, res, txData }) => {
module.exports = {
checkBalance,
checkModelAccess,
};
-103
View File
@@ -1,103 +0,0 @@
// Fail-closed/fail-open decision matrix for the IAM-native Commerce money gate.
// Proves BOTH directions: unmetered AI never leaks (fail closed), and funded /
// freshly-credited users are never wrongly blocked (good fail closed).
const mockClient = {
getMyBalance: jest.fn(),
grantWelcome: jest.fn(),
};
let mockToken = 'iam.jwt.token';
let mockOrg = 'acme';
jest.mock('@librechat/data-schemas', () => ({
logger: { info: jest.fn(), warn: jest.fn(), error: jest.fn(), debug: jest.fn() },
}));
jest.mock('~/server/services/CommerceClient', () => ({
getCommerceClient: () => mockClient,
getIamToken: () => mockToken,
getBillingOrg: () => mockOrg,
}));
jest.mock('~/cache', () => ({ logViolation: jest.fn().mockResolvedValue(undefined) }));
jest.mock('./tx', () => ({ getMultiplier: () => 1 }));
jest.mock('./Transaction', () => ({ createAutoRefillTransaction: jest.fn() }));
jest.mock('~/db/models', () => ({ Balance: { findOne: jest.fn() } }));
jest.mock('librechat-data-provider', () => ({
ViolationTypes: { TOKEN_BALANCE: 'token_balance' },
}));
const { checkBalance } = require('./balanceMethods');
const baseArgs = () => ({
req: { user: { id: 'u1', organization: 'acme' } },
res: {},
txData: { user: 'u1', tokenType: 'prompt', amount: 100, model: 'gpt' },
});
// checkBalance resolves true when allowed and throws (rejects) when blocked.
const decide = async () => {
try {
const ok = await checkBalance(baseArgs());
return { allowed: ok, reason: null };
} catch (err) {
let reason = null;
try {
reason = JSON.parse(err.message).reason;
} catch (_) {
reason = err.message;
}
return { allowed: false, reason };
}
};
describe('checkBalance — IAM-native Commerce money gate', () => {
beforeEach(() => {
jest.clearAllMocks();
mockToken = 'iam.jwt.token';
mockOrg = 'acme';
process.env.HANZO_MIN_BALANCE = '1.00'; // 100 cents minimum
});
it('ALLOWS a funded user (available >= min) without granting credit', async () => {
mockClient.getMyBalance.mockResolvedValueOnce({ available: 500 });
const { allowed } = await decide();
expect(allowed).toBe(true);
expect(mockClient.grantWelcome).not.toHaveBeenCalled();
});
it('BLOCKS (fail closed) when there is no IAM token — never serves unmetered AI', async () => {
mockToken = null;
const { allowed, reason } = await decide();
expect(allowed).toBe(false);
expect(reason).toBe('no_billing_identity');
expect(mockClient.getMyBalance).not.toHaveBeenCalled();
});
it('BLOCKS (fail closed) when Commerce is unreachable — does NOT fall through to allow', async () => {
mockClient.getMyBalance.mockRejectedValueOnce(new Error('ECONNREFUSED'));
const { allowed, reason } = await decide();
expect(allowed).toBe(false);
expect(reason).toBe('commerce_unavailable');
});
it('SELF-HEALS a brand-new account: below-min -> idempotent $5 grant -> re-read -> ALLOW', async () => {
mockClient.getMyBalance
.mockResolvedValueOnce({ available: 0 }) // first read: empty
.mockResolvedValueOnce({ available: 500 }); // after grant: funded
mockClient.grantWelcome.mockResolvedValueOnce({ granted: true });
const { allowed } = await decide();
expect(allowed).toBe(true);
expect(mockClient.grantWelcome).toHaveBeenCalledTimes(1);
expect(mockClient.getMyBalance).toHaveBeenCalledTimes(2);
});
it('BLOCKS a drained wallet: the idempotent grant does NOT refill a spent user', async () => {
mockClient.getMyBalance
.mockResolvedValueOnce({ available: 0 }) // empty
.mockResolvedValueOnce({ available: 0 }); // still empty after dedup-no-op grant
mockClient.grantWelcome.mockResolvedValueOnce({ granted: false });
const { allowed, reason } = await decide();
expect(allowed).toBe(false);
expect(reason).toBe('commerce_insufficient');
expect(mockClient.grantWelcome).toHaveBeenCalledTimes(1);
});
});
+4 -53
View File
@@ -1,37 +1,5 @@
const { logger } = require('@librechat/data-schemas');
const { createTransaction, createStructuredTransaction } = require('./Transaction');
const { recordCommerceSpend } = require('~/server/services/CommerceClient');
/**
* Mirror a completed spend to Hanzo Commerce, decrementing the user's real
* per-org balance under their IAM identity. `txData.commerce` carries the
* request ({ req }) so the IAM token + org are threaded per-request. No-ops
* when commerce/IAM identity is absent. Never throws — the local ledger above
* is the redundant record and the pre-flight gate (balanceMethods.checkBalance)
* is the fail-closed guard.
*
* @param {Object} txData
* @param {number} promptTokens
* @param {number} completionTokens
*/
const decrementCommerce = async (txData, promptTokens, completionTokens) => {
const req = txData?.commerce?.req;
if (!req) {
return;
}
try {
await recordCommerceSpend({
req,
model: txData.model,
endpoint: txData.endpoint,
endpointTokenConfig: txData.endpointTokenConfig,
promptTokens,
completionTokens,
});
} catch (err) {
logger.warn('[spendTokens] commerce decrement failed', err?.message);
}
};
/**
* Creates up to two transactions to record the spending of tokens.
*
@@ -57,15 +25,10 @@ const spendTokens = async (txData, tokenUsage) => {
);
let prompt, completion;
const normalizedPromptTokens = Math.max(promptTokens ?? 0, 0);
// `commerce` carries the request for the per-org IAM decrement; keep it out of
// the local transaction record.
// Exclude the commerce request from the local transaction record; the
// decrement consumes it via txData below.
const { commerce: _commerce, ...txBase } = txData;
try {
if (promptTokens !== undefined) {
prompt = await createTransaction({
...txBase,
...txData,
tokenType: 'prompt',
rawAmount: promptTokens === 0 ? 0 : -normalizedPromptTokens,
inputTokenCount: normalizedPromptTokens,
@@ -74,7 +37,7 @@ const spendTokens = async (txData, tokenUsage) => {
if (completionTokens !== undefined) {
completion = await createTransaction({
...txBase,
...txData,
tokenType: 'completion',
rawAmount: completionTokens === 0 ? 0 : -Math.max(completionTokens, 0),
inputTokenCount: normalizedPromptTokens,
@@ -96,9 +59,6 @@ const spendTokens = async (txData, tokenUsage) => {
} catch (err) {
logger.error('[spendTokens]', err);
}
// Decrement the authoritative Commerce balance (per-org, IAM-native).
await decrementCommerce(txData, promptTokens, completionTokens);
};
/**
@@ -128,19 +88,14 @@ const spendStructuredTokens = async (txData, tokenUsage) => {
},
);
let prompt, completion;
// Exclude the commerce request from the local transaction record; the
// decrement consumes it via txData below.
const { commerce: _commerce, ...txBase } = txData;
let totalPromptTokens = 0;
try {
if (promptTokens) {
const input = Math.max(promptTokens.input ?? 0, 0);
const write = Math.max(promptTokens.write ?? 0, 0);
const read = Math.max(promptTokens.read ?? 0, 0);
const totalInputTokens = input + write + read;
totalPromptTokens = totalInputTokens;
prompt = await createStructuredTransaction({
...txBase,
...txData,
tokenType: 'prompt',
inputTokens: -input,
writeTokens: -write,
@@ -156,7 +111,7 @@ const spendStructuredTokens = async (txData, tokenUsage) => {
Math.max(promptTokens.read ?? 0, 0)
: undefined;
completion = await createTransaction({
...txBase,
...txData,
tokenType: 'completion',
rawAmount: -Math.max(completionTokens, 0),
inputTokenCount: totalInputTokens,
@@ -179,10 +134,6 @@ const spendStructuredTokens = async (txData, tokenUsage) => {
logger.error('[spendStructuredTokens]', err);
}
// Decrement the authoritative Commerce balance (per-org, IAM-native). Cache
// reads/writes count as input tokens for the cost mirror.
await decrementCommerce(txData, totalPromptTokens, completionTokens || 0);
return { prompt, completion };
};
+20 -17
View File
@@ -1,14 +1,10 @@
const { Balance } = require('~/db/models');
const {
getCommerceClient,
getIamToken,
getBillingOrg,
} = require('~/server/services/CommerceClient');
const { getCommerceClient } = require('~/server/services/CommerceClient');
async function balanceController(req, res) {
const balanceData = await Balance.findOne(
{ user: req.user.id },
'-_id tokenCredits autoRefillEnabled refillIntervalValue refillIntervalUnit lastRefill refillAmount expiresAt creditsGrantedAt creditType tierId',
'-_id tokenCredits autoRefillEnabled refillIntervalValue refillIntervalUnit lastRefill refillAmount expiresAt creditsGrantedAt creditType tierId commerceUserId',
).lean();
if (!balanceData) {
@@ -28,19 +24,26 @@ async function balanceController(req, res) {
balanceData.tokenCredits = 0;
}
// Enrich with the authoritative Commerce balance (the real money, in cents),
// read under the user's own IAM identity. Fail-open for DISPLAY only — this is
// a UI read, not the spend gate (the gate is balanceMethods.checkBalance, which
// fails closed). The $5 signup credit lives here and in billing.hanzo.ai.
// Enrich with Commerce tier and credit breakdown if available
const commerceClient = getCommerceClient();
const token = getIamToken(req);
if (commerceClient && token) {
if (commerceClient && balanceData.commerceUserId) {
try {
const balance = await commerceClient.getMyBalance(token, getBillingOrg(req));
balanceData.commerceAvailableCents = balance.available;
balanceData.commerceBalanceCents = balance.balance;
} catch {
// Fail-open: return local data without Commerce enrichment (display only).
const [tierConfig, breakdown] = await Promise.all([
commerceClient.getTierConfig(balanceData.commerceUserId),
commerceClient.getCreditBreakdown(balanceData.commerceUserId),
]);
if (tierConfig) {
balanceData.tierId = tierConfig.name || balanceData.tierId;
balanceData.allowedModels = tierConfig.allowedModels || ['*'];
}
if (breakdown) {
balanceData.trialCredits = breakdown.trial?.cents || 0;
balanceData.paidCredits = breakdown.paid?.cents || 0;
}
} catch (err) {
// Fail-open: return local data without Commerce enrichment
}
}
-5
View File
@@ -667,9 +667,6 @@ class AgentClient extends BaseClient {
user: this.user ?? this.options.req.user?.id,
endpointTokenConfig: this.options.endpointTokenConfig,
model: usage.model ?? model ?? this.model ?? this.options.agent.model_parameters.model,
// Thread the request so spendTokens can decrement the user's per-org
// Commerce balance under their IAM identity (IAM-native metering).
commerce: { req: this.options.req },
};
if (cache_creation > 0 || cache_read > 0) {
@@ -1202,7 +1199,6 @@ class AgentClient extends BaseClient {
conversationId: this.conversationId,
user: this.user ?? this.options.req.user?.id,
endpointTokenConfig: this.options.endpointTokenConfig,
commerce: { req: this.options.req },
},
{ promptTokens, completionTokens },
);
@@ -1221,7 +1217,6 @@ class AgentClient extends BaseClient {
conversationId: this.conversationId,
user: this.user ?? this.options.req.user?.id,
endpointTokenConfig: this.options.endpointTokenConfig,
commerce: { req: this.options.req },
},
{ completionTokens: usage.reasoning_tokens },
);
+269 -238
View File
@@ -1,188 +1,307 @@
const { logger } = require('@librechat/data-schemas');
const { getMultiplier } = require('~/models/tx');
/**
* CommerceClient is the IAM-native, multi-tenant transport to Hanzo Commerce
* billing. Every call carries the LOGGED-IN USER'S hanzo.id IAM access token
* (a real JWT) as `Authorization: Bearer <jwt>` — never a static service token.
* Commerce's EdgeAuth validates the JWT against the IAM JWKS and derives the
* tenant/org FROM the token (claims.owner), so reads/writes are scoped to the
* caller's own org with no chance of cross-tenant leakage.
*
* The money read (`getMyBalance`) FAILS CLOSED: any failure to confirm a
* sufficient balance throws, and the caller (BaseClient.checkBalance via
* balanceMethods) refuses the AI call rather than serving unmetered tokens.
*
* Canonical commerce endpoints (all amounts in CENTS, identity from the JWT):
* - GET /v1/billing/me/balance → { balance, holds, available }
* - POST /v1/billing/me/welcome → idempotent $5 starter credit (deposit)
* - POST /v1/billing/usage → withdraw (decrements the org balance)
* CommerceClient provides a cached, fail-open interface to Hanzo Commerce
* billing APIs. Pattern follows cloud-api's filter_balance.go:
* - 30s TTL balance/tier cache with async refresh
* - Fire-and-forget usage recording queue
* - All errors fail-open (local MongoDB is authoritative fallback)
*
* @example
* const client = getCommerceClient();
* const { available } = await client.getMyBalance(getIamToken(req), org);
* const client = new CommerceClient({
* endpoint: 'http://commerce.hanzo.svc:8001',
* token: process.env.COMMERCE_API_TOKEN || process.env.COMMERCE_TOKEN,
* });
* const { sufficient } = await client.checkBalance('hanzo/alice');
*/
class CommerceClient {
/**
* @param {Object} opts
* @param {string} opts.endpoint - Commerce base URL (e.g. http://commerce.hanzo.svc:8001)
* @param {number} [opts.timeout] - HTTP timeout in ms (default 5000)
* @param {number} [opts.cacheTTL] - Balance cache TTL in ms (default 10000)
* @param {string} opts.endpoint - Commerce base URL (e.g. http://commerce.hanzo.svc:8001)
* @param {string} [opts.token] - Bearer token for admin endpoints
* @param {number} [opts.timeout] - HTTP timeout in ms (default 5000)
* @param {number} [opts.cacheTTL] - Cache TTL in ms (default 30000)
*/
constructor({ endpoint, timeout = 5000, cacheTTL = 10000 }) {
constructor({ endpoint, token, timeout = 5000, cacheTTL = 30000 }) {
this.endpoint = endpoint.replace(/\/+$/, '');
this.token = token;
this.timeout = timeout;
this.cacheTTL = cacheTTL;
/** Balance cache: orgKey -> { data, fetchedAt } (short TTL, invalidated on spend) */
// Balance cache: userId -> { data, fetchedAt, refreshing }
this._balanceCache = new Map();
// Tier cache: userId -> { data, fetchedAt, refreshing }
this._tierCache = new Map();
// Usage recording queue
this._usageQueue = [];
this._usageFlushing = false;
this._usageFlushInterval = setInterval(() => this._flushUsageQueue(), 5000);
// Cache cleanup every 5 minutes
this._cleanupInterval = setInterval(() => this._cleanupCaches(), 300000);
}
/**
* Read the caller's org balance. Identity is taken ENTIRELY from the IAM JWT
* (commerce derives the org from claims.owner); there is no user/org query
* param to forge. FAILS CLOSED: throws on a missing token or any commerce
* error so the caller blocks the request.
* Check user's balance. Returns cached result if fresh, triggers async
* refresh if stale, synchronous fetch on cache miss. Fails open on error.
*
* @param {string} token - The user's hanzo.id IAM access token (JWT)
* @param {string} [orgKey] - Org slug used only as the cache key
* @returns {Promise<{available: number, balance: number, holds: number}>} cents
* @param {string} userId - Commerce user ID (e.g. "hanzo/alice")
* @returns {Promise<{sufficient: boolean, available: number}>}
*/
async getMyBalance(token, orgKey) {
if (!token) {
throw new Error('CommerceClient.getMyBalance: missing IAM token');
}
const cached = orgKey && this._balanceCache.get(orgKey);
if (cached && Date.now() - cached.fetchedAt < this.cacheTTL) {
async checkBalance(userId) {
const cached = this._balanceCache.get(userId);
const now = Date.now();
if (cached) {
const age = now - cached.fetchedAt;
if (age < this.cacheTTL) {
return cached.data;
}
// Stale: serve cached, refresh async
if (!cached.refreshing) {
cached.refreshing = true;
this._fetchBalance(userId).catch(() => {});
}
return cached.data;
}
const resp = await this._request('GET', '/v1/billing/me/balance?currency=usd', { token });
const data = {
available: Number(resp.available) || 0,
balance: Number(resp.balance) || 0,
holds: Number(resp.holds) || 0,
};
if (orgKey) {
this._balanceCache.set(orgKey, { data, fetchedAt: Date.now() });
}
return data;
// Cache miss: synchronous fetch
return this._fetchBalance(userId);
}
/**
* Grant the idempotent $5 welcome credit to the caller's org under their IAM
* identity. Safe to call repeatedly — commerce dedupes by the starter-credit
* tag and returns granted:false if it already exists. Best-effort (never
* throws): a failed grant must not crash login or the balance gate.
* Get tier configuration for a user.
*
* @param {string} token - The user's hanzo.id IAM access token (JWT)
* @returns {Promise<Object|null>}
* @param {string} userId
* @param {string} [tierName] - Optional tier override
* @returns {Promise<{name: string, displayName: string, allowedModels: string[], maxAgents: number}|null>}
*/
async grantWelcome(token) {
if (!token) {
return null;
}
try {
const resp = await this._request('POST', '/v1/billing/me/welcome', { token });
this._balanceCache.clear();
return resp;
} catch (err) {
logger.error('[CommerceClient] welcome credit grant failed', err);
return null;
async getTierConfig(userId, tierName) {
const cached = this._tierCache.get(userId);
const now = Date.now();
if (cached) {
const age = now - cached.fetchedAt;
if (age < this.cacheTTL * 10) {
// Tier changes rarely, cache 5min
return cached.data;
}
if (!cached.refreshing) {
cached.refreshing = true;
this._fetchTier(userId, tierName).catch(() => {});
}
return cached.data;
}
return this._fetchTier(userId, tierName);
}
/**
* Record a completed AI call as a withdraw that DECREMENTS the org balance.
* Awaited by spendTokens so the decrement is durable and the next pre-flight
* balance check sees it. Best-effort against commerce errors — the local
* MongoDB ledger is the redundant record, and the fail-closed pre-flight gate
* prevents runaway while commerce is unreachable.
* Check if a model is allowed for a user's tier.
*
* @param {string} userId
* @param {string} model
* @returns {Promise<{allowed: boolean, tier: string, allowedModels: string[]}>}
*/
async isModelAllowed(userId, model) {
const tier = await this.getTierConfig(userId);
if (!tier) {
return { allowed: true, tier: 'unknown', allowedModels: ['*'] };
}
const allowed =
tier.allowedModels.includes('*') ||
tier.allowedModels.some((prefix) => model.startsWith(prefix));
return { allowed, tier: tier.name, allowedModels: tier.allowedModels };
}
/**
* Enqueue usage recording (fire-and-forget). Commerce calls BurnCredits
* internally to burn trial grants first, then paid.
*
* @param {string} token
* @param {Object} usage
* @param {string} usage.org - Billing org slug (== balance key)
* @param {string} usage.userId
* @param {string} usage.model
* @param {number} usage.promptTokens
* @param {number} usage.completionTokens
* @param {number} usage.amountCents - Cost in cents (> 0)
* @param {string} [usage.provider]
* @param {string} [usage.requestId]
* @param {number} usage.amountCents - Cost in cents
*/
async recordUsage(
token,
{ org, model, promptTokens, completionTokens, amountCents, provider, requestId },
) {
if (!token || !org || !(amountCents > 0)) {
return;
}
await this._request('POST', '/v1/billing/usage', {
token,
body: {
user: org,
amount: Math.round(amountCents),
currency: 'usd',
model,
provider,
promptTokens: promptTokens || 0,
completionTokens: completionTokens || 0,
totalTokens: (promptTokens || 0) + (completionTokens || 0),
requestId,
status: 'completed',
},
recordUsage({ userId, model, promptTokens, completionTokens, amountCents }) {
this._usageQueue.push({
user: userId,
model,
promptTokens: promptTokens || 0,
completionTokens: completionTokens || 0,
amount: amountCents || 0,
currency: 'usd',
status: 'completed',
});
// Next pre-flight read must reflect the spend immediately.
this._balanceCache.delete(org);
// Flush immediately if queue is large
if (this._usageQueue.length >= 50) {
this._flushUsageQueue().catch(() => {});
}
}
/**
* Credit balance breakdown (trial vs paid) for the billing UI. Best-effort.
* Create a trial credit grant for a new user.
*
* @param {string} token
* @returns {Promise<{trial: {cents: number}, paid: {cents: number}, total: {cents: number}}|null>}
* @param {string} userId - Commerce user ID
* @param {number} amountCents - Grant amount in cents (e.g. 500 = $5)
* @param {number} expiryDays - Days until expiry
* @param {string[]} [eligibility] - Meter IDs (empty = all meters)
* @returns {Promise<Object|null>} Grant object or null on failure
*/
async getCreditBreakdown(token) {
if (!token) {
async createTrialGrant(userId, amountCents, expiryDays, eligibility = []) {
try {
const expiresIn = `${expiryDays * 24}h`;
const resp = await this._request('POST', '/v1/billing/credit-grants', {
userId,
name: 'Trial Credit',
amountCents,
currency: 'usd',
expiresIn,
priority: 100, // Trial burns before purchased (200)
eligibility,
tags: 'trial',
});
return resp;
} catch (err) {
logger.error('[CommerceClient] Failed to create trial grant', err);
return null;
}
}
/**
* Get credit balance breakdown by tag (trial vs purchased).
*
* @param {string} userId
* @returns {Promise<{trial: {cents: number, expiresAt?: string}, paid: {cents: number}, total: {cents: number}}|null>}
*/
async getCreditBreakdown(userId) {
try {
// userId is locked to the caller's org by commerce EdgeAuth; value is a placeholder.
const resp = await this._request('GET', '/v1/billing/credit-balance/breakdown?userId=me', {
token,
});
const resp = await this._request(
'GET',
`/v1/billing/credit-balance/breakdown?userId=${encodeURIComponent(userId)}`,
);
const breakdown = resp.breakdown || {};
return {
trial: breakdown['starter-credit'] || breakdown.trial || { cents: 0 },
paid: breakdown.purchased || breakdown.paid || { cents: 0 },
trial: breakdown.trial || { cents: 0 },
paid: breakdown.purchased || { cents: 0 },
total: resp.total || { cents: 0 },
};
} catch (err) {
logger.warn('[CommerceClient] credit breakdown failed', { error: err.message });
logger.error('[CommerceClient] Failed to get credit breakdown', err);
return null;
}
}
// ── Internal methods ──
async _fetchBalance(userId) {
// FAIL CLOSED: this is the money gate. On error we THROW so the caller blocks
// the request rather than letting unfunded/unknown users spend. The cache
// (serve-stale on refresh) smooths transient blips for already-known users;
// only a cold miss + error propagates. `userId` is the billing ORG slug —
// we stamp it as both the `user=` selector and the X-Hanzo-Org namespace so
// the read is correctly scoped per-org (matches cloud-api's keying).
const resp = await this._request(
'GET',
`/v1/billing/balance?user=${encodeURIComponent(userId)}&currency=usd`,
undefined,
userId,
);
const data = {
sufficient: (resp.available || 0) > 0,
available: resp.available || 0,
};
this._balanceCache.set(userId, {
data,
fetchedAt: Date.now(),
refreshing: false,
});
return data;
}
async _fetchTier(userId, tierName) {
try {
let url = `/v1/billing/tier-check?user=${encodeURIComponent(userId)}`;
if (tierName) {
url += `&tier=${encodeURIComponent(tierName)}`;
}
const resp = await this._request('GET', url);
const tier = resp.tier || null;
if (tier) {
this._tierCache.set(userId, {
data: tier,
fetchedAt: Date.now(),
refreshing: false,
});
}
return tier;
} catch (err) {
logger.warn('[CommerceClient] Tier check failed, failing open', { userId, error: err.message });
return null; // Caller treats null as "allow all"
}
}
async _flushUsageQueue() {
if (this._usageFlushing || this._usageQueue.length === 0) {
return;
}
this._usageFlushing = true;
const batch = this._usageQueue.splice(0, 100);
for (const usage of batch) {
try {
await this._request('POST', '/v1/billing/usage', usage);
} catch (err) {
logger.warn('[CommerceClient] Usage recording failed', {
user: usage.user,
model: usage.model,
error: err.message,
});
// Don't retry — usage is also tracked locally in MongoDB
}
}
this._usageFlushing = false;
}
/**
* @param {string} method
* @param {string} path
* @param {Object} [opts]
* @param {string} [opts.token] - User IAM JWT for Authorization: Bearer
* @param {Object} [opts.body]
* @param {Object} [body]
* @returns {Promise<Object>}
*/
async _request(method, path, { token, body } = {}) {
async _request(method, path, body, orgId) {
const url = `${this.endpoint}${path}`;
const headers = { 'Content-Type': 'application/json' };
if (token) {
headers['Authorization'] = `Bearer ${token}`;
if (this.token) {
headers['Authorization'] = `Bearer ${this.token}`;
}
// Scope the service-token call to the tenant's commerce namespace so reads/
// writes are correctly per-org (not the service token's default namespace).
if (orgId) {
headers['X-Hanzo-Org'] = orgId;
}
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), this.timeout);
try {
const opts = { method, headers, signal: controller.signal };
const opts = {
method,
headers,
signal: controller.signal,
};
if (body && method !== 'GET') {
opts.body = JSON.stringify(body);
}
const resp = await fetch(url, opts);
if (!resp.ok) {
const text = await resp.text().catch(() => '');
@@ -193,145 +312,57 @@ class CommerceClient {
clearTimeout(timeoutId);
}
}
}
/**
* Extract the logged-in user's hanzo.id IAM access token (a JWT) from the
* request. Populated by the OpenID JWT strategy when OPENID_REUSE_TOKENS is
* enabled (req.user.federatedTokens). The id_token is preferred: it is always
* a JWKS-signed JWT with the chat client_id as audience and carries the
* `owner` (org) claim commerce keys billing on. Returns null when no IAM
* identity is present — callers MUST treat that as "cannot confirm balance"
* and fail closed.
*
* @param {import('express').Request} req
* @returns {string|null}
*/
function getIamToken(req) {
const ft = req?.user?.federatedTokens;
return ft?.id_token || ft?.access_token || null;
}
_cleanupCaches() {
const now = Date.now();
const maxAge = this.cacheTTL * 20; // 10 minutes
/**
* The caller's billing org slug (lowercased to match commerce's org.Name key).
* Sourced from the IAM `owner` claim persisted on the user at OIDC login.
*
* @param {import('express').Request} req
* @returns {string}
*/
function getBillingOrg(req) {
return (req?.user?.organization ?? '').toString().trim().toLowerCase();
}
// 1,000,000 tokenCredits = $1.00 USD = 100 cents → 10,000 credits = 1 cent.
const CREDITS_PER_CENT = 10000;
/**
* Cost of a completed call in commerce cents, using the SAME multiplier table
* the local ledger uses (models/tx) so commerce mirrors the local charge.
* Rounds up so any non-zero usage decrements at least one cent.
*/
function computeUsageCents({
model,
endpoint,
endpointTokenConfig,
promptTokens,
completionTokens,
}) {
const p = Math.max(promptTokens || 0, 0);
const c = Math.max(completionTokens || 0, 0);
const promptMult = getMultiplier({ tokenType: 'prompt', model, endpoint, endpointTokenConfig });
const completionMult = getMultiplier({
tokenType: 'completion',
model,
endpoint,
endpointTokenConfig,
});
const credits = p * promptMult + c * completionMult;
return credits > 0 ? Math.ceil(credits / CREDITS_PER_CENT) : 0;
}
/**
* Decrement the user's commerce balance for a completed AI call. Called from
* spendTokens with the request so the IAM token + org are threaded per-request.
* No-ops cleanly when commerce is unconfigured or there is no IAM identity
* (the pre-flight gate has already fail-closed in that case).
*
* @param {Object} args
* @param {import('express').Request} args.req
* @param {string} args.model
* @param {string} [args.endpoint]
* @param {Object} [args.endpointTokenConfig]
* @param {number} args.promptTokens
* @param {number} args.completionTokens
*/
async function recordCommerceSpend({
req,
model,
endpoint,
endpointTokenConfig,
promptTokens,
completionTokens,
}) {
const client = getCommerceClient();
if (!client) {
return;
for (const [key, entry] of this._balanceCache) {
if (now - entry.fetchedAt > maxAge) {
this._balanceCache.delete(key);
}
}
for (const [key, entry] of this._tierCache) {
if (now - entry.fetchedAt > maxAge) {
this._tierCache.delete(key);
}
}
}
const token = getIamToken(req);
const org = getBillingOrg(req);
if (!token || !org) {
return;
}
const amountCents = computeUsageCents({
model,
endpoint,
endpointTokenConfig,
promptTokens,
completionTokens,
});
if (amountCents <= 0) {
return;
}
try {
await client.recordUsage(token, {
org,
model,
promptTokens,
completionTokens,
amountCents,
});
} catch (err) {
logger.warn('[CommerceClient] usage decrement failed (kept in local ledger)', {
org,
error: err.message,
});
destroy() {
clearInterval(this._usageFlushInterval);
clearInterval(this._cleanupInterval);
// Flush remaining usage
this._flushUsageQueue().catch(() => {});
}
}
/**
* Singleton Commerce client. Initialized lazily from COMMERCE_API_URL.
* Returns null when Commerce integration is not configured.
* Singleton Commerce client instance. Initialized lazily from env vars.
* Returns null if Commerce integration is not configured.
*/
let _instance = null;
function getCommerceClient() {
if (_instance) {
if (_instance !== undefined && _instance !== null) {
return _instance;
}
const endpoint = process.env.COMMERCE_API_URL || process.env.COMMERCE_ENDPOINT || '';
const endpoint =
process.env.COMMERCE_API_URL ||
process.env.COMMERCE_ENDPOINT ||
'';
if (!endpoint) {
_instance = null;
return null;
}
_instance = new CommerceClient({ endpoint });
logger.info('[CommerceClient] Initialized (IAM-native, per-request user JWT)', { endpoint });
const token = process.env.COMMERCE_API_TOKEN || process.env.COMMERCE_TOKEN || '';
_instance = new CommerceClient({ endpoint, token });
logger.info('[CommerceClient] Initialized', { endpoint });
return _instance;
}
module.exports = {
CommerceClient,
getCommerceClient,
getIamToken,
getBillingOrg,
computeUsageCents,
recordCommerceSpend,
};
module.exports = { CommerceClient, getCommerceClient };
@@ -1,52 +0,0 @@
jest.mock('@librechat/data-schemas', () => ({
logger: { info: jest.fn(), warn: jest.fn(), error: jest.fn(), debug: jest.fn() },
}));
// Fixed multiplier table: prompt=6 credits/token, completion=12 credits/token.
jest.mock('~/models/tx', () => ({
getMultiplier: ({ tokenType }) => (tokenType === 'completion' ? 12 : 6),
}));
const { getIamToken, getBillingOrg, computeUsageCents } = require('./CommerceClient');
describe('getIamToken', () => {
it('prefers the id_token (JWKS-validatable, carries owner claim)', () => {
const req = { user: { federatedTokens: { id_token: 'ID', access_token: 'AC' } } };
expect(getIamToken(req)).toBe('ID');
});
it('falls back to the access_token when no id_token', () => {
const req = { user: { federatedTokens: { access_token: 'AC' } } };
expect(getIamToken(req)).toBe('AC');
});
it('returns null when there is no IAM identity (caller must fail closed)', () => {
expect(getIamToken({ user: {} })).toBeNull();
expect(getIamToken({})).toBeNull();
expect(getIamToken(undefined)).toBeNull();
});
});
describe('getBillingOrg', () => {
it('lowercases and trims the IAM owner/org so it matches commerce org.Name', () => {
expect(getBillingOrg({ user: { organization: ' ACME ' } })).toBe('acme');
expect(getBillingOrg({ user: { organization: 'Hanzo' } })).toBe('hanzo');
expect(getBillingOrg({ user: {} })).toBe('');
});
});
describe('computeUsageCents', () => {
it('mirrors the local ledger cost (1e6 credits = $1) and rounds up', () => {
// 1000*6 + 500*12 = 12000 credits = 1.2 cents -> ceil = 2
expect(computeUsageCents({ model: 'gpt', promptTokens: 1000, completionTokens: 500 })).toBe(2);
});
it('rounds any non-zero usage up to at least 1 cent (never under-charges)', () => {
// 100*6 = 600 credits = 0.06 cents -> ceil = 1
expect(computeUsageCents({ model: 'gpt', promptTokens: 100, completionTokens: 0 })).toBe(1);
});
it('is zero for zero usage', () => {
expect(computeUsageCents({ model: 'gpt', promptTokens: 0, completionTokens: 0 })).toBe(0);
});
});
-19
View File
@@ -19,7 +19,6 @@ const {
const { getStrategyFunctions } = require('~/server/services/Files/strategies');
const { findUser, createUser, updateUser } = require('~/models');
const { getAppConfig } = require('~/server/services/Config');
const { getCommerceClient } = require('~/server/services/CommerceClient');
const getLogStores = require('~/cache/getLogStores');
/**
@@ -522,24 +521,6 @@ async function processOpenIDAuth(tokenset, existingUsersOnly = false) {
const balanceConfig = getBalanceConfig(appConfig);
user = await createUser(user, balanceConfig, true, true);
// Fund the new account with the idempotent $5 welcome credit, under the
// user's OWN IAM identity, synchronously — so the credit lands BEFORE their
// first message and a funded new user can chat immediately. Commerce derives
// the org from the JWT and dedupes by the starter-credit tag. Best-effort: a
// commerce hiccup must never fail signup/login (the balance gate self-heals
// the grant on the first message as a backstop).
try {
const commerceClient = getCommerceClient();
if (commerceClient) {
const grant = await commerceClient.grantWelcome(tokenset.id_token || tokenset.access_token);
if (grant?.granted) {
logger.info(`[openidStrategy] granted $5 welcome credit to new user ${user.email}`);
}
}
} catch (err) {
logger.warn(`[openidStrategy] welcome credit grant failed (non-fatal): ${err?.message}`);
}
} else {
user.provider = 'openid';
user.openidId = userinfo.sub;