fix(billing): prefer Goose session cost over token estimate

Billing fell back to inflated token list-price estimates when Finish
frames carried inline accumulatedCost, causing ~70x overcharge vs DeepSeek.
Always fetch session accumulated_cost first; add Aug 4+ compensation script.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
john
2026-08-05 09:52:13 +08:00
parent 1e7004dffe
commit e7f0627dc9
3 changed files with 254 additions and 8 deletions
+5 -7
View File
@@ -38,15 +38,16 @@ export function enrichTokenStateForBilling(
env = process.env,
) {
const state = normalizeTokenState(tokenStateRaw);
if (state.accumulatedCost != null && Number(state.accumulatedCost) >= 0) {
return state;
}
const sessionUsd = pickSessionAccumulatedCost(sessionCost);
if (sessionUsd != null) {
return { ...state, accumulatedCost: sessionUsd };
}
if (state.accumulatedCost != null && Number(state.accumulatedCost) >= 0) {
return state;
}
const estimatedUsd = estimateAccumulatedCostUsd(state, loadCostEstimateConfig(env));
if (estimatedUsd != null) {
return { ...state, accumulatedCost: estimatedUsd };
@@ -61,9 +62,6 @@ export async function resolveBillingTokenState(
env = process.env,
) {
const state = normalizeTokenState(tokenStateRaw);
if (state.accumulatedCost != null && Number(state.accumulatedCost) >= 0) {
return state;
}
if (typeof fetchSession === 'function' && sessionId) {
try {
@@ -71,7 +69,7 @@ export async function resolveBillingTokenState(
const enriched = enrichTokenStateForBilling(state, { sessionCost: session }, env);
if (enriched.accumulatedCost != null) return enriched;
} catch {
// Best-effort: fall through to token estimate.
// Best-effort: fall through to inline cost / token estimate.
}
}
+26 -1
View File
@@ -23,7 +23,16 @@ test('estimateAccumulatedCostUsd uses DeepSeek-ish defaults', () => {
assert.equal(estimate, 0.281);
});
test('enrichTokenStateForBilling prefers upstream cost over estimate', () => {
test('enrichTokenStateForBilling prefers session cost over Finish inline cost', () => {
const enriched = enrichTokenStateForBilling(
{ accumulatedInputTokens: 1000, accumulatedOutputTokens: 100, accumulatedCost: 0.82 },
{ sessionCost: { accumulated_cost: 0.0067 } },
{ H5_USE_BACKEND_COST: '1' },
);
assert.equal(enriched.accumulatedCost, 0.0067);
});
test('enrichTokenStateForBilling keeps Finish inline cost when session cost missing', () => {
const enriched = enrichTokenStateForBilling(
{ accumulatedInputTokens: 1000, accumulatedOutputTokens: 100, accumulatedCost: 0.05 },
{},
@@ -78,6 +87,22 @@ test('resolveBillingTokenState fetches session before estimating', async () => {
assert.equal(resolved.accumulatedCost, 0.42);
});
test('resolveBillingTokenState replaces inflated Finish cost with session cost', async () => {
const resolved = await resolveBillingTokenState(
{
accumulatedInputTokens: 280030,
accumulatedOutputTokens: 5797,
accumulatedCost: 0.082,
},
{
sessionId: '20260804_19',
fetchSession: async () => ({ accumulated_cost: 0.006738208 }),
},
{ H5_USE_BACKEND_COST: '1' },
);
assert.equal(resolved.accumulatedCost, 0.006738208);
});
test('enriched cost drives 1.2x billing instead of flat fallback', () => {
const previous = { lastInputTokens: 224853, lastOutputTokens: 6685, lastAccumulatedCost: null };
const tokenState = enrichTokenStateForBilling(
@@ -0,0 +1,223 @@
#!/usr/bin/env node
/**
* Compensate users overcharged when billing fell back to token estimate
* instead of Goose accumulated_cost (DeepSeek cache-aware upstream cost).
*
* Usage:
* node scripts/compensate-billing-token-estimate-overcharge.mjs
* node scripts/compensate-billing-token-estimate-overcharge.mjs --since=2026-08-04
* node scripts/compensate-billing-token-estimate-overcharge.mjs --since=2026-08-04 --apply
*/
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import mysql from 'mysql2/promise';
import pg from 'pg';
const root = path.join(path.dirname(fileURLToPath(import.meta.url)), '..');
function loadEnvFile(filePath) {
if (!fs.existsSync(filePath)) return;
for (const line of fs.readFileSync(filePath, 'utf8').split('\n')) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith('#')) continue;
const eq = trimmed.indexOf('=');
if (eq < 0) continue;
const key = trimmed.slice(0, eq).trim();
const value = trimmed.slice(eq + 1).trim();
if (!process.env[key]) process.env[key] = value;
}
}
loadEnvFile(path.join(root, '.env'));
const apply = process.argv.includes('--apply');
const sinceArg = process.argv.find((a) => a.startsWith('--since='));
const sinceDate = sinceArg ? sinceArg.slice('--since='.length) : '2026-08-04';
const startMs = new Date(`${sinceDate}T00:00:00+08:00`).getTime();
const DEDupe_NOTE_PREFIX = `补偿:Token估价超扣(${sinceDate}起)`;
function loadBillingConfig() {
const marginMultiplier = Number(process.env.H5_MARGIN_MULTIPLIER ?? 1);
return {
useBackendCost: process.env.H5_USE_BACKEND_COST === '1',
usdCnyRate: Number(process.env.H5_USD_CNY_RATE ?? 7.2),
marginMultiplier: Number.isFinite(marginMultiplier) && marginMultiplier > 0 ? marginMultiplier : 1,
minBillCents: Number(process.env.H5_MIN_BILL_CENTS ?? 1),
};
}
function correctTotalCents(gooseCostUsd, config) {
if (gooseCostUsd == null || gooseCostUsd <= 0) return 0;
return Math.max(
config.minBillCents,
Math.ceil(gooseCostUsd * config.usdCnyRate * 100 * config.marginMultiplier),
);
}
function resolvePgUrl() {
if (process.env.GOOSE_SESSION_DB_URL) return process.env.GOOSE_SESSION_DB_URL;
const host = process.env.GOOSE_SESSION_PG_HOST ?? '127.0.0.1';
const port = process.env.GOOSE_SESSION_PG_PORT ?? '5432';
const db = process.env.GOOSE_SESSION_PG_DATABASE ?? 'memind_sessions';
const user = process.env.GOOSE_SESSION_PG_USER ?? 'john';
return `postgresql://${user}@${host}:${port}/${db}`;
}
async function main() {
if (!process.env.DATABASE_URL) {
console.error('DATABASE_URL is not configured');
process.exit(1);
}
const config = loadBillingConfig();
const pool = mysql.createPool({ uri: process.env.DATABASE_URL, connectionLimit: 4 });
const pgClient = new pg.Client({ connectionString: resolvePgUrl() });
await pgClient.connect();
try {
const [records] = await pool.query(
`SELECT r.user_id, u.username, u.status, r.agent_session_id, r.cost_cents
FROM h5_usage_records r
JOIN h5_users u ON u.id = r.user_id
WHERE r.created_at >= ?
ORDER BY r.created_at ASC`,
[startMs],
);
const bySession = new Map();
for (const row of records) {
const sid = row.agent_session_id;
if (!bySession.has(sid)) {
bySession.set(sid, {
userId: row.user_id,
username: row.username,
status: row.status,
sinceStart: 0,
all: 0,
});
}
bySession.get(sid).sinceStart += Number(row.cost_cents);
}
const sessionIds = [...bySession.keys()];
if (sessionIds.length === 0) {
console.log('No usage records since', sinceDate);
return;
}
const [allRows] = await pool.query(
`SELECT agent_session_id, SUM(cost_cents) AS total
FROM h5_usage_records
WHERE agent_session_id IN (?)
GROUP BY agent_session_id`,
[sessionIds],
);
for (const row of allRows) {
bySession.get(row.agent_session_id).all = Number(row.total);
}
const gooseRes = await pgClient.query(
`SELECT id, accumulated_cost FROM sessions WHERE id = ANY($1::text[])`,
[sessionIds],
);
const gooseCostBySession = new Map(
gooseRes.rows.map((row) => [row.id, Number(row.accumulated_cost)]),
);
const byUser = new Map();
for (const [sid, session] of bySession) {
const correct = correctTotalCents(gooseCostBySession.get(sid), config);
const overcharge = Math.max(0, session.all - correct);
const refund = Math.min(session.sinceStart, overcharge);
if (refund <= 0) continue;
if (!byUser.has(session.userId)) {
byUser.set(session.userId, {
userId: session.userId,
username: session.username,
status: session.status,
refundCents: 0,
sessions: [],
});
}
const user = byUser.get(session.userId);
user.refundCents += refund;
user.sessions.push({ sid, refund, chargedSinceStart: session.sinceStart, correctTotal: correct });
}
const users = [...byUser.values()].sort((a, b) => b.refundCents - a.refundCents);
const totalRefund = users.reduce((sum, user) => sum + user.refundCents, 0);
console.log('Billing config:', config);
console.log('Since:', sinceDate, `(${startMs})`);
console.log('Affected users:', users.length);
console.log('Total refund:', `¥${(totalRefund / 100).toFixed(2)}`, `(${totalRefund} cents)`);
console.log('');
for (const user of users) {
console.log(`- ${user.username}: ¥${(user.refundCents / 100).toFixed(2)} (${user.sessions.length} sessions)`);
}
if (!apply) {
console.log('');
console.log('Dry run only. Re-run with --apply to execute.');
return;
}
const now = Date.now();
for (const user of users) {
const note = `${DEDupe_NOTE_PREFIX} ¥${(user.refundCents / 100).toFixed(2)}`;
const [existing] = await pool.query(
`SELECT id FROM h5_billing_ledger
WHERE user_id = ? AND type = 'adjust' AND note = ?
LIMIT 1`,
[user.userId, note],
);
if (existing.length) {
console.log(`SKIP ${user.username}: already compensated (${note})`);
continue;
}
const conn = await pool.getConnection();
try {
await conn.beginTransaction();
await conn.query(
`INSERT INTO h5_user_wallets (user_id, balance_cents, tokens_used, updated_at)
VALUES (?, ?, 0, ?)
ON DUPLICATE KEY UPDATE
balance_cents = balance_cents + VALUES(balance_cents),
updated_at = VALUES(updated_at)`,
[user.userId, user.refundCents, now],
);
await conn.query(
`INSERT INTO h5_billing_ledger
(user_id, type, amount_cents, tokens, note, operator_id, created_at)
VALUES (?, 'adjust', ?, 0, ?, NULL, ?)`,
[user.userId, user.refundCents, note, now],
);
if (user.status === 'suspended') {
await conn.query(`UPDATE h5_users SET status = 'active', updated_at = ? WHERE id = ?`, [
now,
user.userId,
]);
}
await conn.commit();
console.log(`APPLIED ${user.username}: +¥${(user.refundCents / 100).toFixed(2)}`);
} catch (err) {
await conn.rollback();
throw err;
} finally {
conn.release();
}
}
} finally {
await pgClient.end();
await pool.end();
}
}
main().catch((err) => {
console.error(err);
process.exit(1);
});