Revert "fix(billing): IAM-native, fail-closed AI metering (stop unmetered leak)"
This reverts commit 4233c8760f.
This commit is contained in:
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
};
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 },
|
||||
);
|
||||
|
||||
@@ -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)}¤cy=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,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;
|
||||
|
||||
Reference in New Issue
Block a user