forked from boos/BoosAPI
feat: add proxy log billing details handling and schema management
This commit is contained in:
@@ -31,6 +31,7 @@ export let runtimeDbDialect: RuntimeDbDialect = config.dbType;
|
|||||||
let sqliteConnection: Database.Database | null = null;
|
let sqliteConnection: Database.Database | null = null;
|
||||||
let mysqlPool: mysql.Pool | null = null;
|
let mysqlPool: mysql.Pool | null = null;
|
||||||
let pgPool: pg.Pool | null = null;
|
let pgPool: pg.Pool | null = null;
|
||||||
|
let proxyLogBillingDetailsColumnAvailable: boolean | null = null;
|
||||||
|
|
||||||
function resolveSqlitePath(): string {
|
function resolveSqlitePath(): string {
|
||||||
const raw = (config.dbUrl || '').trim();
|
const raw = (config.dbUrl || '').trim();
|
||||||
@@ -272,6 +273,84 @@ function ensureProxyLogBillingDetailsSchema() {
|
|||||||
if (!tableColumnExists('proxy_logs', 'billing_details')) {
|
if (!tableColumnExists('proxy_logs', 'billing_details')) {
|
||||||
sqlite.exec('ALTER TABLE proxy_logs ADD COLUMN billing_details text;');
|
sqlite.exec('ALTER TABLE proxy_logs ADD COLUMN billing_details text;');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
proxyLogBillingDetailsColumnAvailable = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeSchemaErrorMessage(error: unknown): string {
|
||||||
|
if (typeof error === 'object' && error && 'message' in error) {
|
||||||
|
return String((error as { message?: unknown }).message || '');
|
||||||
|
}
|
||||||
|
return String(error || '');
|
||||||
|
}
|
||||||
|
|
||||||
|
function isDuplicateColumnError(error: unknown): boolean {
|
||||||
|
const lowered = normalizeSchemaErrorMessage(error).toLowerCase();
|
||||||
|
return lowered.includes('duplicate column') || lowered.includes('already exists');
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function hasProxyLogBillingDetailsColumn(): Promise<boolean> {
|
||||||
|
if (proxyLogBillingDetailsColumnAvailable !== null) {
|
||||||
|
return proxyLogBillingDetailsColumnAvailable;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (runtimeDbDialect === 'sqlite') {
|
||||||
|
proxyLogBillingDetailsColumnAvailable = tableExists('proxy_logs')
|
||||||
|
&& tableColumnExists('proxy_logs', 'billing_details');
|
||||||
|
return proxyLogBillingDetailsColumnAvailable;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (runtimeDbDialect === 'mysql') {
|
||||||
|
if (!mysqlPool) return false;
|
||||||
|
const [rows] = await mysqlPool.query('SHOW COLUMNS FROM `proxy_logs` LIKE ?', ['billing_details']);
|
||||||
|
proxyLogBillingDetailsColumnAvailable = Array.isArray(rows) && rows.length > 0;
|
||||||
|
return proxyLogBillingDetailsColumnAvailable;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!pgPool) return false;
|
||||||
|
const result = await pgPool.query(
|
||||||
|
'SELECT 1 FROM information_schema.columns WHERE table_schema = current_schema() AND table_name = $1 AND column_name = $2 LIMIT 1',
|
||||||
|
['proxy_logs', 'billing_details'],
|
||||||
|
);
|
||||||
|
proxyLogBillingDetailsColumnAvailable = Number(result.rowCount || 0) > 0;
|
||||||
|
return proxyLogBillingDetailsColumnAvailable;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function ensureProxyLogBillingDetailsColumn(): Promise<boolean> {
|
||||||
|
if (runtimeDbDialect === 'sqlite') {
|
||||||
|
ensureProxyLogBillingDetailsSchema();
|
||||||
|
proxyLogBillingDetailsColumnAvailable = tableExists('proxy_logs')
|
||||||
|
&& tableColumnExists('proxy_logs', 'billing_details');
|
||||||
|
return proxyLogBillingDetailsColumnAvailable;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (await hasProxyLogBillingDetailsColumn()) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (runtimeDbDialect === 'mysql') {
|
||||||
|
if (!mysqlPool) return false;
|
||||||
|
await mysqlPool.query('ALTER TABLE `proxy_logs` ADD COLUMN `billing_details` TEXT NULL');
|
||||||
|
} else {
|
||||||
|
if (!pgPool) return false;
|
||||||
|
await pgPool.query('ALTER TABLE "proxy_logs" ADD COLUMN "billing_details" TEXT');
|
||||||
|
}
|
||||||
|
proxyLogBillingDetailsColumnAvailable = true;
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
if (isDuplicateColumnError(error)) {
|
||||||
|
proxyLogBillingDetailsColumnAvailable = true;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
proxyLogBillingDetailsColumnAvailable = false;
|
||||||
|
console.warn('[db] failed to ensure proxy_logs.billing_details column', error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetSchemaCapabilityCache() {
|
||||||
|
proxyLogBillingDetailsColumnAvailable = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function sqliteProxyQuery(sqlText: string, params: unknown[], method: SqlMethod) {
|
async function sqliteProxyQuery(sqlText: string, params: unknown[], method: SqlMethod) {
|
||||||
@@ -628,6 +707,7 @@ export const db: any = new Proxy({}, {
|
|||||||
export { schema };
|
export { schema };
|
||||||
|
|
||||||
export async function closeDbConnections(): Promise<void> {
|
export async function closeDbConnections(): Promise<void> {
|
||||||
|
resetSchemaCapabilityCache();
|
||||||
if (mysqlPool) {
|
if (mysqlPool) {
|
||||||
await mysqlPool.end();
|
await mysqlPool.end();
|
||||||
mysqlPool = null;
|
mysqlPool = null;
|
||||||
|
|||||||
+2
-1
@@ -25,7 +25,7 @@ import { repairStoredCreatedAtValues } from './services/storedTimestampRepairSer
|
|||||||
import { isPublicApiRoute, registerDesktopRoutes } from './desktop.js';
|
import { isPublicApiRoute, registerDesktopRoutes } from './desktop.js';
|
||||||
import { existsSync } from 'fs';
|
import { existsSync } from 'fs';
|
||||||
import { normalize, resolve, sep } from 'path';
|
import { normalize, resolve, sep } from 'path';
|
||||||
import { db, runtimeDbDialect, schema, switchRuntimeDatabase, type RuntimeDbDialect } from './db/index.js';
|
import { db, ensureProxyLogBillingDetailsColumn, runtimeDbDialect, schema, switchRuntimeDatabase, type RuntimeDbDialect } from './db/index.js';
|
||||||
|
|
||||||
function toSettingsMap(rows: Array<{ key: string; value: string }>) {
|
function toSettingsMap(rows: Array<{ key: string; value: string }>) {
|
||||||
return new Map(rows.map((row) => [row.key, row.value]));
|
return new Map(rows.map((row) => [row.key, row.value]));
|
||||||
@@ -189,6 +189,7 @@ try {
|
|||||||
const finalRows = await db.select().from(schema.settings).all();
|
const finalRows = await db.select().from(schema.settings).all();
|
||||||
const finalMap = toSettingsMap(finalRows);
|
const finalMap = toSettingsMap(finalRows);
|
||||||
applyRuntimeSettings(finalMap);
|
applyRuntimeSettings(finalMap);
|
||||||
|
await ensureProxyLogBillingDetailsColumn();
|
||||||
await repairStoredCreatedAtValues();
|
await repairStoredCreatedAtValues();
|
||||||
|
|
||||||
console.log('Loaded runtime settings overrides');
|
console.log('Loaded runtime settings overrides');
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
import { FastifyInstance } from 'fastify';
|
import { FastifyInstance } from 'fastify';
|
||||||
import { db, schema } from '../../db/index.js';
|
import { db, schema } from '../../db/index.js';
|
||||||
import { and, like, desc, eq } from 'drizzle-orm';
|
import { and, like, desc, eq } from 'drizzle-orm';
|
||||||
|
import { getProxyLogBaseSelectFields } from '../../services/proxyLogStore.js';
|
||||||
|
|
||||||
export async function searchRoutes(app: FastifyInstance) {
|
export async function searchRoutes(app: FastifyInstance) {
|
||||||
|
const proxyLogBaseFields = getProxyLogBaseSelectFields();
|
||||||
|
|
||||||
app.post<{ Body: { query: string; limit?: number } }>('/api/search', async (request) => {
|
app.post<{ Body: { query: string; limit?: number } }>('/api/search', async (request) => {
|
||||||
const { query, limit = 20 } = request.body;
|
const { query, limit = 20 } = request.body;
|
||||||
if (!query || query.trim().length === 0) {
|
if (!query || query.trim().length === 0) {
|
||||||
@@ -40,7 +43,7 @@ export async function searchRoutes(app: FastifyInstance) {
|
|||||||
.map(r => ({ ...r.checkin_logs, account: r.accounts }));
|
.map(r => ({ ...r.checkin_logs, account: r.accounts }));
|
||||||
|
|
||||||
// Search proxy logs (by model name)
|
// Search proxy logs (by model name)
|
||||||
const proxyLogs = await db.select().from(schema.proxyLogs)
|
const proxyLogs = await db.select(proxyLogBaseFields).from(schema.proxyLogs)
|
||||||
.where(like(schema.proxyLogs.modelRequested, q))
|
.where(like(schema.proxyLogs.modelRequested, q))
|
||||||
.orderBy(desc(schema.proxyLogs.createdAt))
|
.orderBy(desc(schema.proxyLogs.createdAt))
|
||||||
.limit(perCategory).all();
|
.limit(perCategory).all();
|
||||||
|
|||||||
@@ -12,6 +12,11 @@ import { getUpstreamModelDescriptionsCached } from '../../services/upstreamModel
|
|||||||
import { getRunningTaskByDedupeKey, startBackgroundTask } from '../../services/backgroundTaskService.js';
|
import { getRunningTaskByDedupeKey, startBackgroundTask } from '../../services/backgroundTaskService.js';
|
||||||
import { parseCheckinRewardAmount } from '../../services/checkinRewardParser.js';
|
import { parseCheckinRewardAmount } from '../../services/checkinRewardParser.js';
|
||||||
import { estimateRewardWithTodayIncomeFallback } from '../../services/todayIncomeRewardService.js';
|
import { estimateRewardWithTodayIncomeFallback } from '../../services/todayIncomeRewardService.js';
|
||||||
|
import {
|
||||||
|
getProxyLogBaseSelectFields,
|
||||||
|
parseProxyLogBillingDetails,
|
||||||
|
withProxyLogSelectFields,
|
||||||
|
} from '../../services/proxyLogStore.js';
|
||||||
import {
|
import {
|
||||||
formatUtcSqlDateTime,
|
formatUtcSqlDateTime,
|
||||||
getLocalDayRangeUtc,
|
getLocalDayRangeUtc,
|
||||||
@@ -25,16 +30,6 @@ function parseBooleanFlag(raw?: string): boolean {
|
|||||||
return normalized === '1' || normalized === 'true' || normalized === 'yes';
|
return normalized === '1' || normalized === 'true' || normalized === 'yes';
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseProxyLogBillingDetails(value: unknown): Record<string, unknown> | null {
|
|
||||||
if (typeof value !== 'string' || value.trim().length === 0) return null;
|
|
||||||
try {
|
|
||||||
const parsed = JSON.parse(value);
|
|
||||||
return parsed && typeof parsed === 'object' ? parsed as Record<string, unknown> : null;
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const MODELS_MARKETPLACE_BASE_TTL_MS = 15_000;
|
const MODELS_MARKETPLACE_BASE_TTL_MS = 15_000;
|
||||||
const MODELS_MARKETPLACE_PRICING_TTL_MS = 90_000;
|
const MODELS_MARKETPLACE_PRICING_TTL_MS = 90_000;
|
||||||
|
|
||||||
@@ -79,6 +74,8 @@ function proxyCostSqlExpression() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function statsRoutes(app: FastifyInstance) {
|
export async function statsRoutes(app: FastifyInstance) {
|
||||||
|
const proxyLogBaseFields = getProxyLogBaseSelectFields();
|
||||||
|
|
||||||
// Dashboard summary
|
// Dashboard summary
|
||||||
app.get('/api/stats/dashboard', async () => {
|
app.get('/api/stats/dashboard', async () => {
|
||||||
const accountRows = await db.select().from(schema.accounts)
|
const accountRows = await db.select().from(schema.accounts)
|
||||||
@@ -119,7 +116,11 @@ export async function statsRoutes(app: FastifyInstance) {
|
|||||||
const nowTs = Date.now();
|
const nowTs = Date.now();
|
||||||
const last24hDate = formatUtcSqlDateTime(new Date(nowTs - 86400000));
|
const last24hDate = formatUtcSqlDateTime(new Date(nowTs - 86400000));
|
||||||
const last7dDate = getLocalRangeStartUtc(7);
|
const last7dDate = getLocalRangeStartUtc(7);
|
||||||
const recentProxyLogs = (await db.select().from(schema.proxyLogs)
|
const recentProxyLogs = (await db.select({
|
||||||
|
proxy_logs: proxyLogBaseFields,
|
||||||
|
accounts: schema.accounts,
|
||||||
|
sites: schema.sites,
|
||||||
|
}).from(schema.proxyLogs)
|
||||||
.leftJoin(schema.accounts, eq(schema.proxyLogs.accountId, schema.accounts.id))
|
.leftJoin(schema.accounts, eq(schema.proxyLogs.accountId, schema.accounts.id))
|
||||||
.leftJoin(schema.sites, eq(schema.accounts.siteId, schema.sites.id))
|
.leftJoin(schema.sites, eq(schema.accounts.siteId, schema.sites.id))
|
||||||
.where(and(gte(schema.proxyLogs.createdAt, last7dDate), eq(schema.sites.status, 'active')))
|
.where(and(gte(schema.proxyLogs.createdAt, last7dDate), eq(schema.sites.status, 'active')))
|
||||||
@@ -189,11 +190,17 @@ export async function statsRoutes(app: FastifyInstance) {
|
|||||||
app.get<{ Querystring: { limit?: string; offset?: string } }>('/api/stats/proxy-logs', async (request) => {
|
app.get<{ Querystring: { limit?: string; offset?: string } }>('/api/stats/proxy-logs', async (request) => {
|
||||||
const limit = parseInt(request.query.limit || '50', 10);
|
const limit = parseInt(request.query.limit || '50', 10);
|
||||||
const offset = parseInt(request.query.offset || '0', 10);
|
const offset = parseInt(request.query.offset || '0', 10);
|
||||||
const rows = await db.select().from(schema.proxyLogs)
|
const rows = await withProxyLogSelectFields(({ fields }) => (
|
||||||
.leftJoin(schema.accounts, eq(schema.proxyLogs.accountId, schema.accounts.id))
|
db.select({
|
||||||
.leftJoin(schema.sites, eq(schema.accounts.siteId, schema.sites.id))
|
proxy_logs: fields,
|
||||||
.orderBy(desc(schema.proxyLogs.createdAt))
|
accounts: schema.accounts,
|
||||||
.limit(limit).offset(offset).all();
|
sites: schema.sites,
|
||||||
|
}).from(schema.proxyLogs)
|
||||||
|
.leftJoin(schema.accounts, eq(schema.proxyLogs.accountId, schema.accounts.id))
|
||||||
|
.leftJoin(schema.sites, eq(schema.accounts.siteId, schema.sites.id))
|
||||||
|
.orderBy(desc(schema.proxyLogs.createdAt))
|
||||||
|
.limit(limit).offset(offset).all()
|
||||||
|
), { includeBillingDetails: true });
|
||||||
|
|
||||||
return rows.map((row) => ({
|
return rows.map((row) => ({
|
||||||
...row.proxy_logs,
|
...row.proxy_logs,
|
||||||
@@ -273,7 +280,7 @@ export async function statsRoutes(app: FastifyInstance) {
|
|||||||
.all();
|
.all();
|
||||||
|
|
||||||
const last7d = getLocalRangeStartUtc(7);
|
const last7d = getLocalRangeStartUtc(7);
|
||||||
const recentLogs = await db.select().from(schema.proxyLogs)
|
const recentLogs = await db.select(proxyLogBaseFields).from(schema.proxyLogs)
|
||||||
.where(gte(schema.proxyLogs.createdAt, last7d))
|
.where(gte(schema.proxyLogs.createdAt, last7d))
|
||||||
.all();
|
.all();
|
||||||
|
|
||||||
@@ -807,7 +814,11 @@ export async function statsRoutes(app: FastifyInstance) {
|
|||||||
const days = Math.max(1, parseInt(request.query.days || '7', 10));
|
const days = Math.max(1, parseInt(request.query.days || '7', 10));
|
||||||
const sinceDate = getLocalRangeStartUtc(days);
|
const sinceDate = getLocalRangeStartUtc(days);
|
||||||
|
|
||||||
const rows = await db.select().from(schema.proxyLogs)
|
const rows = await db.select({
|
||||||
|
proxy_logs: proxyLogBaseFields,
|
||||||
|
accounts: schema.accounts,
|
||||||
|
sites: schema.sites,
|
||||||
|
}).from(schema.proxyLogs)
|
||||||
.leftJoin(schema.accounts, eq(schema.proxyLogs.accountId, schema.accounts.id))
|
.leftJoin(schema.accounts, eq(schema.proxyLogs.accountId, schema.accounts.id))
|
||||||
.leftJoin(schema.sites, eq(schema.accounts.siteId, schema.sites.id))
|
.leftJoin(schema.sites, eq(schema.accounts.siteId, schema.sites.id))
|
||||||
.where(and(gte(schema.proxyLogs.createdAt, sinceDate), eq(schema.sites.status, 'active')))
|
.where(and(gte(schema.proxyLogs.createdAt, sinceDate), eq(schema.sites.status, 'active')))
|
||||||
@@ -863,7 +874,11 @@ export async function statsRoutes(app: FastifyInstance) {
|
|||||||
accountIds = new Set(siteAccounts.map((a) => a.id));
|
accountIds = new Set(siteAccounts.map((a) => a.id));
|
||||||
}
|
}
|
||||||
|
|
||||||
const rows = await db.select().from(schema.proxyLogs)
|
const rows = await db.select({
|
||||||
|
proxy_logs: proxyLogBaseFields,
|
||||||
|
accounts: schema.accounts,
|
||||||
|
sites: schema.sites,
|
||||||
|
}).from(schema.proxyLogs)
|
||||||
.leftJoin(schema.accounts, eq(schema.proxyLogs.accountId, schema.accounts.id))
|
.leftJoin(schema.accounts, eq(schema.proxyLogs.accountId, schema.accounts.id))
|
||||||
.leftJoin(schema.sites, eq(schema.accounts.siteId, schema.sites.id))
|
.leftJoin(schema.sites, eq(schema.accounts.siteId, schema.sites.id))
|
||||||
.where(and(gte(schema.proxyLogs.createdAt, sinceDate), eq(schema.sites.status, 'active')))
|
.where(and(gte(schema.proxyLogs.createdAt, sinceDate), eq(schema.sites.status, 'active')))
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { db, schema } from '../db/index.js';
|
|||||||
import { getLocalDayRangeUtc, formatLocalDateTime, getResolvedTimeZone } from './localTimeService.js';
|
import { getLocalDayRangeUtc, formatLocalDateTime, getResolvedTimeZone } from './localTimeService.js';
|
||||||
import { parseCheckinRewardAmount } from './checkinRewardParser.js';
|
import { parseCheckinRewardAmount } from './checkinRewardParser.js';
|
||||||
import { estimateRewardWithTodayIncomeFallback } from './todayIncomeRewardService.js';
|
import { estimateRewardWithTodayIncomeFallback } from './todayIncomeRewardService.js';
|
||||||
|
import { getProxyLogBaseSelectFields } from './proxyLogStore.js';
|
||||||
|
|
||||||
export type DailySummaryMetrics = {
|
export type DailySummaryMetrics = {
|
||||||
localDay: string;
|
localDay: string;
|
||||||
@@ -28,6 +29,7 @@ function round6(value: number): number {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function collectDailySummaryMetrics(now = new Date()): Promise<DailySummaryMetrics> {
|
export async function collectDailySummaryMetrics(now = new Date()): Promise<DailySummaryMetrics> {
|
||||||
|
const proxyLogBaseFields = getProxyLogBaseSelectFields();
|
||||||
const { localDay, startUtc, endUtc } = getLocalDayRangeUtc(now);
|
const { localDay, startUtc, endUtc } = getLocalDayRangeUtc(now);
|
||||||
|
|
||||||
const accountRows = await db.select().from(schema.accounts)
|
const accountRows = await db.select().from(schema.accounts)
|
||||||
@@ -67,7 +69,11 @@ export async function collectDailySummaryMetrics(now = new Date()): Promise<Dail
|
|||||||
parsedRewardCountByAccount[accountId] = (parsedRewardCountByAccount[accountId] || 0) + 1;
|
parsedRewardCountByAccount[accountId] = (parsedRewardCountByAccount[accountId] || 0) + 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
const todayProxyRows = await db.select().from(schema.proxyLogs)
|
const todayProxyRows = await db.select({
|
||||||
|
proxy_logs: proxyLogBaseFields,
|
||||||
|
accounts: schema.accounts,
|
||||||
|
sites: schema.sites,
|
||||||
|
}).from(schema.proxyLogs)
|
||||||
.leftJoin(schema.accounts, eq(schema.proxyLogs.accountId, schema.accounts.id))
|
.leftJoin(schema.accounts, eq(schema.proxyLogs.accountId, schema.accounts.id))
|
||||||
.leftJoin(schema.sites, eq(schema.accounts.siteId, schema.sites.id))
|
.leftJoin(schema.sites, eq(schema.accounts.siteId, schema.sites.id))
|
||||||
.where(and(
|
.where(and(
|
||||||
|
|||||||
@@ -0,0 +1,82 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
|
||||||
|
const { fetchMock, withSiteProxyRequestInitMock } = vi.hoisted(() => ({
|
||||||
|
fetchMock: vi.fn(),
|
||||||
|
withSiteProxyRequestInitMock: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const ANYROUTER_CHALLENGE_HTML = readFileSync(
|
||||||
|
new URL('./platforms/__fixtures__/anyrouter-challenge.html', import.meta.url),
|
||||||
|
'utf8',
|
||||||
|
);
|
||||||
|
const ANYROUTER_CHALLENGE_ACW = '699dbedad126579b6bc0ebb91eaae8d7af3548b5';
|
||||||
|
|
||||||
|
vi.mock('undici', () => ({
|
||||||
|
fetch: (...args: unknown[]) => fetchMock(...args),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('./siteProxy.js', () => ({
|
||||||
|
withSiteProxyRequestInit: (...args: unknown[]) => withSiteProxyRequestInitMock(...args),
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { fetchModelPricingCatalog } from './modelPricingService.js';
|
||||||
|
|
||||||
|
describe('modelPricingService anyrouter pricing', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
fetchMock.mockReset();
|
||||||
|
withSiteProxyRequestInitMock.mockReset();
|
||||||
|
withSiteProxyRequestInitMock.mockImplementation(async (_url: string, init: Record<string, unknown>) => init);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reuses anyrouter shield challenge flow when fetching pricing', async () => {
|
||||||
|
fetchMock
|
||||||
|
.mockResolvedValueOnce(new Response(ANYROUTER_CHALLENGE_HTML, {
|
||||||
|
status: 200,
|
||||||
|
headers: {
|
||||||
|
'content-type': 'text/html; charset=utf-8',
|
||||||
|
'set-cookie': 'cdn_sec_tc=challenge-seed; Path=/; HttpOnly',
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
.mockResolvedValueOnce(new Response(JSON.stringify({
|
||||||
|
data: [{
|
||||||
|
model_name: 'claude-haiku-4-5-20251001',
|
||||||
|
quota_type: 0,
|
||||||
|
model_ratio: 2.5,
|
||||||
|
completion_ratio: 5,
|
||||||
|
cache_ratio: 0.1,
|
||||||
|
create_cache_ratio: 1.25,
|
||||||
|
enable_groups: ['default'],
|
||||||
|
}],
|
||||||
|
group_ratio: { default: 1 },
|
||||||
|
}), {
|
||||||
|
status: 200,
|
||||||
|
headers: { 'content-type': 'application/json; charset=utf-8' },
|
||||||
|
}));
|
||||||
|
|
||||||
|
const catalog = await fetchModelPricingCatalog({
|
||||||
|
site: {
|
||||||
|
id: 902,
|
||||||
|
url: 'https://anyrouter.example.com',
|
||||||
|
platform: 'anyrouter',
|
||||||
|
},
|
||||||
|
account: {
|
||||||
|
id: 77,
|
||||||
|
accessToken: 'challenge-seed',
|
||||||
|
},
|
||||||
|
modelName: 'claude-haiku-4-5-20251001',
|
||||||
|
totalTokens: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(catalog?.models[0]?.groupPricing?.default).toMatchObject({
|
||||||
|
inputPerMillion: 5,
|
||||||
|
outputPerMillion: 25,
|
||||||
|
cacheReadPerMillion: 0.5,
|
||||||
|
cacheCreationPerMillion: 6.25,
|
||||||
|
});
|
||||||
|
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||||
|
expect(fetchMock.mock.calls[1][1]?.headers?.Cookie || '').toContain('session=challenge-seed');
|
||||||
|
expect(fetchMock.mock.calls[1][1]?.headers?.Cookie || '').toContain('cdn_sec_tc=challenge-seed');
|
||||||
|
expect(fetchMock.mock.calls[1][1]?.headers?.Cookie || '').toContain(`acw_sc__v2=${ANYROUTER_CHALLENGE_ACW}`);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,5 +1,9 @@
|
|||||||
import type { RequestInit as UndiciRequestInit } from 'undici';
|
import type { RequestInit as UndiciRequestInit } from 'undici';
|
||||||
import { withSiteProxyRequestInit } from './siteProxy.js';
|
import { withSiteProxyRequestInit } from './siteProxy.js';
|
||||||
|
import {
|
||||||
|
buildNewApiCookieCandidates,
|
||||||
|
fetchJsonWithShieldCookieRetry,
|
||||||
|
} from './platforms/newApiShield.js';
|
||||||
|
|
||||||
const PRICE_CACHE_TTL_MS = 10 * 60 * 1000;
|
const PRICE_CACHE_TTL_MS = 10 * 60 * 1000;
|
||||||
const PRICE_CACHE_FAILURE_TTL_MS = 60 * 1000;
|
const PRICE_CACHE_FAILURE_TTL_MS = 60 * 1000;
|
||||||
@@ -376,7 +380,15 @@ function buildTokenCandidates(input: EstimateProxyCostInput): string[] {
|
|||||||
return Array.from(new Set(candidates));
|
return Array.from(new Set(candidates));
|
||||||
}
|
}
|
||||||
|
|
||||||
async function fetchCommonPricing(baseUrl: string, token?: string): Promise<PricingData | null> {
|
async function fetchCommonPricing(baseUrl: string, token?: string, sitePlatform?: string): Promise<PricingData | null> {
|
||||||
|
const normalizedPlatform = (sitePlatform || '').trim().toLowerCase();
|
||||||
|
const shouldTryShieldCookie = !!token && (normalizedPlatform === 'anyrouter' || token.includes('='));
|
||||||
|
if (shouldTryShieldCookie) {
|
||||||
|
const payload = await fetchJsonViaNewApiShield(`${baseUrl}/api/pricing`, token!);
|
||||||
|
const data = normalizeCommonPricingPayload(payload);
|
||||||
|
if (data) return data;
|
||||||
|
}
|
||||||
|
|
||||||
const headers: Record<string, string> = {};
|
const headers: Record<string, string> = {};
|
||||||
if (token) headers.Authorization = `Bearer ${token}`;
|
if (token) headers.Authorization = `Bearer ${token}`;
|
||||||
const payload = await fetchJson(`${baseUrl}/api/pricing`, { headers });
|
const payload = await fetchJson(`${baseUrl}/api/pricing`, { headers });
|
||||||
@@ -436,8 +448,8 @@ async function fetchPricingData(input: EstimateProxyCostInput): Promise<PricingD
|
|||||||
const tokenCandidates = buildTokenCandidates(input);
|
const tokenCandidates = buildTokenCandidates(input);
|
||||||
|
|
||||||
const fetcher = input.site.platform === 'one-hub' || input.site.platform === 'done-hub'
|
const fetcher = input.site.platform === 'one-hub' || input.site.platform === 'done-hub'
|
||||||
? fetchOneHubPricing
|
? (baseUrl: string, token?: string) => fetchOneHubPricing(baseUrl, token)
|
||||||
: fetchCommonPricing;
|
: (baseUrl: string, token?: string) => fetchCommonPricing(baseUrl, token, input.site.platform);
|
||||||
|
|
||||||
for (const token of tokenCandidates) {
|
for (const token of tokenCandidates) {
|
||||||
try {
|
try {
|
||||||
@@ -757,6 +769,17 @@ export async function estimateProxyCost(input: EstimateProxyCostInput): Promise<
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function fetchJsonViaNewApiShield(url: string, token: string): Promise<unknown> {
|
||||||
|
for (const cookie of buildNewApiCookieCandidates(token)) {
|
||||||
|
const result = await fetchJsonWithShieldCookieRetry(url, {
|
||||||
|
headers: { Cookie: cookie },
|
||||||
|
});
|
||||||
|
if (result.data) return result.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
export async function buildProxyBillingDetails(input: EstimateProxyCostInput): Promise<ProxyBillingDetails | null> {
|
export async function buildProxyBillingDetails(input: EstimateProxyCostInput): Promise<ProxyBillingDetails | null> {
|
||||||
const promptTokens = toPositiveInt(input.promptTokens);
|
const promptTokens = toPositiveInt(input.promptTokens);
|
||||||
const completionTokens = toPositiveInt(input.completionTokens);
|
const completionTokens = toPositiveInt(input.completionTokens);
|
||||||
|
|||||||
@@ -0,0 +1,255 @@
|
|||||||
|
import type { RequestInit as UndiciRequestInit } from 'undici';
|
||||||
|
import { createContext, runInContext } from 'node:vm';
|
||||||
|
import { withSiteProxyRequestInit } from '../siteProxy.js';
|
||||||
|
|
||||||
|
const SHIELD_USER_AGENT = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/132.0.0.0 Safari/537.36';
|
||||||
|
|
||||||
|
export function buildNewApiCookieCandidates(token: string): string[] {
|
||||||
|
const trimmed = (token || '').trim();
|
||||||
|
if (!trimmed) return [];
|
||||||
|
|
||||||
|
const raw = trimmed.startsWith('Bearer ') ? trimmed.slice(7).trim() : trimmed;
|
||||||
|
const candidates: string[] = [];
|
||||||
|
|
||||||
|
if (raw.includes('=')) {
|
||||||
|
candidates.push(raw);
|
||||||
|
}
|
||||||
|
|
||||||
|
candidates.push(`session=${raw}`);
|
||||||
|
candidates.push(`token=${raw}`);
|
||||||
|
|
||||||
|
return Array.from(new Set(candidates));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hasUsableSessionCookie(cookieHeader: string): boolean {
|
||||||
|
if (!cookieHeader) return false;
|
||||||
|
const ignored = new Set(['acw_tc', 'acw_sc__v2', 'cdn_sec_tc']);
|
||||||
|
const pairs = cookieHeader.split(';').map((part) => part.trim()).filter(Boolean);
|
||||||
|
for (const pair of pairs) {
|
||||||
|
const eq = pair.indexOf('=');
|
||||||
|
if (eq <= 0) continue;
|
||||||
|
const name = pair.slice(0, eq).trim().toLowerCase();
|
||||||
|
if (!name || ignored.has(name)) continue;
|
||||||
|
if (
|
||||||
|
name === 'session'
|
||||||
|
|| name === 'token'
|
||||||
|
|| name === 'auth_token'
|
||||||
|
|| name === 'access_token'
|
||||||
|
|| name === 'jwt'
|
||||||
|
|| name === 'jwt_token'
|
||||||
|
|| name.includes('session')
|
||||||
|
|| name.includes('token')
|
||||||
|
|| name.includes('auth')
|
||||||
|
) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseChallengeArg1(html: string): string | null {
|
||||||
|
const match = html.match(/var\s+arg1\s*=\s*['"]([0-9a-fA-F]+)['"]/);
|
||||||
|
return match?.[1]?.toUpperCase() || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseChallengeMapping(html: string): number[] | null {
|
||||||
|
const match = html.match(/for\(var m=\[([^\]]+)\],p=L\(0x115\)/);
|
||||||
|
if (!match?.[1]) return null;
|
||||||
|
|
||||||
|
const values = match[1].split(',').map((raw) => {
|
||||||
|
const value = raw.trim().toLowerCase();
|
||||||
|
if (!value) return Number.NaN;
|
||||||
|
if (value.startsWith('0x')) return Number.parseInt(value.slice(2), 16);
|
||||||
|
return Number.parseInt(value, 10);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (values.some((value) => Number.isNaN(value))) return null;
|
||||||
|
return values;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseChallengeXorSeed(html: string): string | null {
|
||||||
|
const fnStart = html.indexOf('function a0i()');
|
||||||
|
const bStart = html.indexOf('function b(');
|
||||||
|
const rotateStart = html.indexOf('(function(a,c){');
|
||||||
|
const rotateEnd = html.indexOf('),!(function', rotateStart);
|
||||||
|
if (fnStart < 0 || bStart < 0 || bStart <= fnStart || rotateStart < 0 || rotateEnd < 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const helperCode = html.slice(fnStart, bStart);
|
||||||
|
const rotateCode = `${html.slice(rotateStart, rotateEnd + 1)})`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const sandbox: Record<string, unknown> = { decodeURIComponent };
|
||||||
|
createContext(sandbox);
|
||||||
|
runInContext(helperCode, sandbox, { timeout: 100 });
|
||||||
|
runInContext(rotateCode, sandbox, { timeout: 100 });
|
||||||
|
const decoder = sandbox.a0j;
|
||||||
|
if (typeof decoder !== 'function') return null;
|
||||||
|
const seed = (decoder as (index: number) => unknown)(0x115);
|
||||||
|
if (typeof seed !== 'string' || !/^[0-9a-f]+$/i.test(seed)) return null;
|
||||||
|
return seed;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function solveNewApiAcwScV2(html: string): string | null {
|
||||||
|
const arg1 = parseChallengeArg1(html);
|
||||||
|
const mapping = parseChallengeMapping(html);
|
||||||
|
const xorSeed = parseChallengeXorSeed(html);
|
||||||
|
if (!arg1 || !mapping || !xorSeed) return null;
|
||||||
|
|
||||||
|
const reordered: string[] = [];
|
||||||
|
for (let i = 0; i < arg1.length; i += 1) {
|
||||||
|
const ch = arg1[i];
|
||||||
|
for (let j = 0; j < mapping.length; j += 1) {
|
||||||
|
if (mapping[j] === i + 1) {
|
||||||
|
reordered[j] = ch;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const source = reordered.join('');
|
||||||
|
let out = '';
|
||||||
|
for (let i = 0; i < source.length && i < xorSeed.length; i += 2) {
|
||||||
|
const left = Number.parseInt(source.slice(i, i + 2), 16);
|
||||||
|
const right = Number.parseInt(xorSeed.slice(i, i + 2), 16);
|
||||||
|
if (Number.isNaN(left) || Number.isNaN(right)) return null;
|
||||||
|
out += (left ^ right).toString(16).padStart(2, '0');
|
||||||
|
}
|
||||||
|
|
||||||
|
return out || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isShieldChallenge(contentType: string, text: string): boolean {
|
||||||
|
const normalizedType = (contentType || '').toLowerCase();
|
||||||
|
if (normalizedType.includes('text/html') && /var\s+arg1\s*=|acw_sc__v2|cdn_sec_tc|<script/i.test(text)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return /var\s+arg1\s*=/.test(text);
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeHeaders(headers?: UndiciRequestInit['headers']): Record<string, string> {
|
||||||
|
const output: Record<string, string> = {};
|
||||||
|
if (!headers) return output;
|
||||||
|
|
||||||
|
if (Array.isArray(headers)) {
|
||||||
|
for (const [key, value] of headers) {
|
||||||
|
output[String(key)] = String(value);
|
||||||
|
}
|
||||||
|
return output;
|
||||||
|
}
|
||||||
|
|
||||||
|
const maybeIterable = headers as { forEach?: (fn: (value: string, key: string) => void) => void };
|
||||||
|
if (typeof maybeIterable.forEach === 'function') {
|
||||||
|
maybeIterable.forEach((value, key) => {
|
||||||
|
output[key] = value;
|
||||||
|
});
|
||||||
|
return output;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [key, value] of Object.entries(headers as Record<string, unknown>)) {
|
||||||
|
if (value === undefined || value === null) continue;
|
||||||
|
output[key] = String(value);
|
||||||
|
}
|
||||||
|
return output;
|
||||||
|
}
|
||||||
|
|
||||||
|
function upsertCookie(cookieHeader: string, name: string, value: string): string {
|
||||||
|
const parts = cookieHeader.split(';').map((part) => part.trim()).filter(Boolean);
|
||||||
|
let replaced = false;
|
||||||
|
const next = parts.map((part) => {
|
||||||
|
const eq = part.indexOf('=');
|
||||||
|
if (eq < 0) return part;
|
||||||
|
const key = part.slice(0, eq).trim();
|
||||||
|
if (key !== name) return part;
|
||||||
|
replaced = true;
|
||||||
|
return `${name}=${value}`;
|
||||||
|
});
|
||||||
|
if (!replaced) next.push(`${name}=${value}`);
|
||||||
|
return next.join('; ');
|
||||||
|
}
|
||||||
|
|
||||||
|
function mergeSetCookiePairs(cookieHeader: string, setCookieHeaders: string[]): string {
|
||||||
|
let merged = cookieHeader;
|
||||||
|
for (const raw of setCookieHeaders) {
|
||||||
|
if (!raw) continue;
|
||||||
|
const firstPair = raw.split(';')[0]?.trim();
|
||||||
|
if (!firstPair) continue;
|
||||||
|
const eq = firstPair.indexOf('=');
|
||||||
|
if (eq <= 0) continue;
|
||||||
|
const name = firstPair.slice(0, eq).trim();
|
||||||
|
const value = firstPair.slice(eq + 1);
|
||||||
|
merged = upsertCookie(merged, name, value);
|
||||||
|
}
|
||||||
|
return merged;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseJsonSafe<T>(text: string): T | null {
|
||||||
|
try {
|
||||||
|
return JSON.parse(text) as T;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function collectSetCookieHeaders(headers: Headers): string[] {
|
||||||
|
const getSetCookie = (headers as unknown as { getSetCookie?: () => string[] }).getSetCookie;
|
||||||
|
if (typeof getSetCookie === 'function') {
|
||||||
|
return getSetCookie.call(headers) || [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const single = headers.get('set-cookie');
|
||||||
|
return single ? [single] : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchJsonWithShieldCookieRetry<T>(
|
||||||
|
url: string,
|
||||||
|
options?: UndiciRequestInit,
|
||||||
|
): Promise<{ data: T | null; cookieHeader: string }> {
|
||||||
|
const { fetch } = await import('undici');
|
||||||
|
const headers: Record<string, string> = {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'User-Agent': SHIELD_USER_AGENT,
|
||||||
|
...normalizeHeaders(options?.headers),
|
||||||
|
};
|
||||||
|
|
||||||
|
let cookieHeader = headers.Cookie || headers.cookie || '';
|
||||||
|
if (cookieHeader) {
|
||||||
|
headers.Cookie = cookieHeader;
|
||||||
|
delete headers.cookie;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let attempt = 0; attempt < 3; attempt += 1) {
|
||||||
|
const requestOptions: UndiciRequestInit = {
|
||||||
|
...options,
|
||||||
|
body: options?.body ?? undefined,
|
||||||
|
headers,
|
||||||
|
};
|
||||||
|
const proxiedRequestOptions = await withSiteProxyRequestInit(url, requestOptions);
|
||||||
|
const response = await fetch(url, proxiedRequestOptions);
|
||||||
|
const text = await response.text();
|
||||||
|
|
||||||
|
cookieHeader = mergeSetCookiePairs(cookieHeader, collectSetCookieHeaders(response.headers));
|
||||||
|
const parsed = parseJsonSafe<T>(text);
|
||||||
|
if (parsed) return { data: parsed, cookieHeader };
|
||||||
|
|
||||||
|
if (!isShieldChallenge(response.headers.get('content-type') || '', text)) {
|
||||||
|
return { data: null, cookieHeader };
|
||||||
|
}
|
||||||
|
if (!cookieHeader) {
|
||||||
|
return { data: null, cookieHeader };
|
||||||
|
}
|
||||||
|
|
||||||
|
const acwScV2 = solveNewApiAcwScV2(text);
|
||||||
|
if (!acwScV2) {
|
||||||
|
return { data: null, cookieHeader };
|
||||||
|
}
|
||||||
|
|
||||||
|
cookieHeader = upsertCookie(cookieHeader, 'acw_sc__v2', acwScV2);
|
||||||
|
headers.Cookie = cookieHeader;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { data: null, cookieHeader };
|
||||||
|
}
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
const {
|
||||||
|
hasProxyLogBillingDetailsColumnMock,
|
||||||
|
dbInsertMock,
|
||||||
|
dbInsertValuesMock,
|
||||||
|
dbInsertRunMock,
|
||||||
|
proxyLogsSchema,
|
||||||
|
} = vi.hoisted(() => ({
|
||||||
|
hasProxyLogBillingDetailsColumnMock: vi.fn(),
|
||||||
|
dbInsertMock: vi.fn(),
|
||||||
|
dbInsertValuesMock: vi.fn(),
|
||||||
|
dbInsertRunMock: vi.fn(),
|
||||||
|
proxyLogsSchema: {
|
||||||
|
id: 'id',
|
||||||
|
routeId: 'route_id',
|
||||||
|
channelId: 'channel_id',
|
||||||
|
accountId: 'account_id',
|
||||||
|
modelRequested: 'model_requested',
|
||||||
|
modelActual: 'model_actual',
|
||||||
|
status: 'status',
|
||||||
|
httpStatus: 'http_status',
|
||||||
|
latencyMs: 'latency_ms',
|
||||||
|
promptTokens: 'prompt_tokens',
|
||||||
|
completionTokens: 'completion_tokens',
|
||||||
|
totalTokens: 'total_tokens',
|
||||||
|
estimatedCost: 'estimated_cost',
|
||||||
|
billingDetails: 'billing_details',
|
||||||
|
errorMessage: 'error_message',
|
||||||
|
retryCount: 'retry_count',
|
||||||
|
createdAt: 'created_at',
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../db/index.js', () => ({
|
||||||
|
db: {
|
||||||
|
insert: (...args: unknown[]) => dbInsertMock(...args),
|
||||||
|
},
|
||||||
|
schema: {
|
||||||
|
proxyLogs: proxyLogsSchema,
|
||||||
|
},
|
||||||
|
hasProxyLogBillingDetailsColumn: (...args: unknown[]) => hasProxyLogBillingDetailsColumnMock(...args),
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { insertProxyLog, withProxyLogSelectFields } from './proxyLogStore.js';
|
||||||
|
|
||||||
|
describe('proxyLogStore', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
hasProxyLogBillingDetailsColumnMock.mockReset();
|
||||||
|
dbInsertMock.mockReset();
|
||||||
|
dbInsertValuesMock.mockReset();
|
||||||
|
dbInsertRunMock.mockReset();
|
||||||
|
|
||||||
|
dbInsertMock.mockReturnValue({
|
||||||
|
values: (...args: unknown[]) => dbInsertValuesMock(...args),
|
||||||
|
});
|
||||||
|
dbInsertValuesMock.mockReturnValue({
|
||||||
|
run: (...args: unknown[]) => dbInsertRunMock(...args),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('retries proxy log selects without billing details when the column is missing', async () => {
|
||||||
|
hasProxyLogBillingDetailsColumnMock.mockResolvedValue(true);
|
||||||
|
const runner = vi.fn()
|
||||||
|
.mockRejectedValueOnce(new Error('column proxy_logs.billing_details does not exist'))
|
||||||
|
.mockResolvedValueOnce([{ id: 1 }]);
|
||||||
|
|
||||||
|
await expect(withProxyLogSelectFields(runner, { includeBillingDetails: true })).resolves.toEqual([{ id: 1 }]);
|
||||||
|
|
||||||
|
expect(runner).toHaveBeenCalledTimes(2);
|
||||||
|
expect(runner.mock.calls[0][0].includeBillingDetails).toBe(true);
|
||||||
|
expect(runner.mock.calls[0][0].fields.billingDetails).toBe('billing_details');
|
||||||
|
expect(runner.mock.calls[1][0].includeBillingDetails).toBe(false);
|
||||||
|
expect(runner.mock.calls[1][0].fields.billingDetails).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('retries proxy log inserts without billing details when the column is missing', async () => {
|
||||||
|
hasProxyLogBillingDetailsColumnMock.mockResolvedValue(true);
|
||||||
|
dbInsertRunMock
|
||||||
|
.mockRejectedValueOnce(new Error('column proxy_logs.billing_details does not exist'))
|
||||||
|
.mockResolvedValueOnce(undefined);
|
||||||
|
|
||||||
|
await insertProxyLog({
|
||||||
|
modelRequested: 'gpt-5',
|
||||||
|
billingDetails: { total: 1 },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(dbInsertValuesMock).toHaveBeenCalledTimes(2);
|
||||||
|
expect(dbInsertValuesMock.mock.calls[0][0]).toMatchObject({
|
||||||
|
modelRequested: 'gpt-5',
|
||||||
|
billingDetails: JSON.stringify({ total: 1 }),
|
||||||
|
});
|
||||||
|
expect(dbInsertValuesMock.mock.calls[1][0]).toMatchObject({
|
||||||
|
modelRequested: 'gpt-5',
|
||||||
|
});
|
||||||
|
expect(dbInsertValuesMock.mock.calls[1][0].billingDetails).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,148 @@
|
|||||||
|
import { db, schema, hasProxyLogBillingDetailsColumn } from '../db/index.js';
|
||||||
|
|
||||||
|
export type ProxyLogInsertInput = {
|
||||||
|
routeId?: number | null;
|
||||||
|
channelId?: number | null;
|
||||||
|
accountId?: number | null;
|
||||||
|
modelRequested?: string | null;
|
||||||
|
modelActual?: string | null;
|
||||||
|
status?: string | null;
|
||||||
|
httpStatus?: number | null;
|
||||||
|
latencyMs?: number | null;
|
||||||
|
promptTokens?: number | null;
|
||||||
|
completionTokens?: number | null;
|
||||||
|
totalTokens?: number | null;
|
||||||
|
estimatedCost?: number | null;
|
||||||
|
billingDetails?: unknown;
|
||||||
|
errorMessage?: string | null;
|
||||||
|
retryCount?: number | null;
|
||||||
|
createdAt?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
function buildProxyLogBaseSelectFields() {
|
||||||
|
return {
|
||||||
|
id: schema.proxyLogs.id,
|
||||||
|
routeId: schema.proxyLogs.routeId,
|
||||||
|
channelId: schema.proxyLogs.channelId,
|
||||||
|
accountId: schema.proxyLogs.accountId,
|
||||||
|
modelRequested: schema.proxyLogs.modelRequested,
|
||||||
|
modelActual: schema.proxyLogs.modelActual,
|
||||||
|
status: schema.proxyLogs.status,
|
||||||
|
httpStatus: schema.proxyLogs.httpStatus,
|
||||||
|
latencyMs: schema.proxyLogs.latencyMs,
|
||||||
|
promptTokens: schema.proxyLogs.promptTokens,
|
||||||
|
completionTokens: schema.proxyLogs.completionTokens,
|
||||||
|
totalTokens: schema.proxyLogs.totalTokens,
|
||||||
|
estimatedCost: schema.proxyLogs.estimatedCost,
|
||||||
|
errorMessage: schema.proxyLogs.errorMessage,
|
||||||
|
retryCount: schema.proxyLogs.retryCount,
|
||||||
|
createdAt: schema.proxyLogs.createdAt,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getProxyLogBaseSelectFields() {
|
||||||
|
return buildProxyLogBaseSelectFields();
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ProxyLogSelectFields = ReturnType<typeof buildProxyLogBaseSelectFields> & {
|
||||||
|
billingDetails?: typeof schema.proxyLogs.billingDetails;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ResolvedProxyLogSelectFields = {
|
||||||
|
includeBillingDetails: boolean;
|
||||||
|
fields: ProxyLogSelectFields;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function resolveProxyLogSelectFields(options?: { includeBillingDetails?: boolean }) {
|
||||||
|
const includeBillingDetails = options?.includeBillingDetails === true
|
||||||
|
&& await hasProxyLogBillingDetailsColumn();
|
||||||
|
|
||||||
|
return {
|
||||||
|
includeBillingDetails,
|
||||||
|
fields: includeBillingDetails
|
||||||
|
? { ...buildProxyLogBaseSelectFields(), billingDetails: schema.proxyLogs.billingDetails }
|
||||||
|
: buildProxyLogBaseSelectFields(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function withProxyLogSelectFields<T>(
|
||||||
|
runner: (selection: ResolvedProxyLogSelectFields) => Promise<T>,
|
||||||
|
options?: { includeBillingDetails?: boolean },
|
||||||
|
): Promise<T> {
|
||||||
|
const selection = await resolveProxyLogSelectFields(options);
|
||||||
|
|
||||||
|
try {
|
||||||
|
return await runner(selection);
|
||||||
|
} catch (error) {
|
||||||
|
if (selection.includeBillingDetails && isMissingBillingDetailsColumnError(error)) {
|
||||||
|
return await runner({
|
||||||
|
includeBillingDetails: false,
|
||||||
|
fields: buildProxyLogBaseSelectFields(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseProxyLogBillingDetails(value: unknown): Record<string, unknown> | null {
|
||||||
|
if (typeof value !== 'string' || value.trim().length === 0) return null;
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(value);
|
||||||
|
return parsed && typeof parsed === 'object' ? parsed as Record<string, unknown> : null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isMissingBillingDetailsColumnError(error: unknown): boolean {
|
||||||
|
const message = typeof error === 'object' && error && 'message' in error
|
||||||
|
? String((error as { message?: unknown }).message || '')
|
||||||
|
: String(error || '');
|
||||||
|
const lowered = message.toLowerCase();
|
||||||
|
return lowered.includes('billing_details')
|
||||||
|
&& (
|
||||||
|
lowered.includes('does not exist')
|
||||||
|
|| lowered.includes('unknown column')
|
||||||
|
|| lowered.includes('no such column')
|
||||||
|
|| lowered.includes('has no column named')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function insertProxyLog(input: ProxyLogInsertInput): Promise<void> {
|
||||||
|
const baseValues = {
|
||||||
|
routeId: input.routeId ?? null,
|
||||||
|
channelId: input.channelId ?? null,
|
||||||
|
accountId: input.accountId ?? null,
|
||||||
|
modelRequested: input.modelRequested ?? null,
|
||||||
|
modelActual: input.modelActual ?? null,
|
||||||
|
status: input.status ?? null,
|
||||||
|
httpStatus: input.httpStatus ?? null,
|
||||||
|
latencyMs: input.latencyMs ?? null,
|
||||||
|
promptTokens: input.promptTokens ?? 0,
|
||||||
|
completionTokens: input.completionTokens ?? 0,
|
||||||
|
totalTokens: input.totalTokens ?? 0,
|
||||||
|
estimatedCost: input.estimatedCost ?? 0,
|
||||||
|
errorMessage: input.errorMessage ?? null,
|
||||||
|
retryCount: input.retryCount ?? 0,
|
||||||
|
createdAt: input.createdAt ?? null,
|
||||||
|
};
|
||||||
|
const serializedBillingDetails = input.billingDetails == null
|
||||||
|
? null
|
||||||
|
: JSON.stringify(input.billingDetails);
|
||||||
|
const includeBillingDetails = serializedBillingDetails !== null
|
||||||
|
&& await hasProxyLogBillingDetailsColumn();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await db.insert(schema.proxyLogs).values(
|
||||||
|
includeBillingDetails
|
||||||
|
? { ...baseValues, billingDetails: serializedBillingDetails }
|
||||||
|
: baseValues,
|
||||||
|
).run();
|
||||||
|
} catch (error) {
|
||||||
|
if (includeBillingDetails && isMissingBillingDetailsColumnError(error)) {
|
||||||
|
await db.insert(schema.proxyLogs).values(baseValues).run();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -39,6 +39,52 @@ describe('proxyUsageFallbackService', () => {
|
|||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('extracts cache billing metadata from self-log other payload', () => {
|
||||||
|
const payload = {
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
model_name: 'claude-haiku-4-5-20251001',
|
||||||
|
token_name: 'anyrouter',
|
||||||
|
prompt_tokens: 146638,
|
||||||
|
completion_tokens: 172,
|
||||||
|
quota: 41528,
|
||||||
|
created_at: 1_772_790_705,
|
||||||
|
request_time: 1700,
|
||||||
|
other: JSON.stringify({
|
||||||
|
cache_creation_ratio: 1.25,
|
||||||
|
cache_creation_tokens: 945,
|
||||||
|
cache_ratio: 0.1,
|
||||||
|
cache_tokens: 145692,
|
||||||
|
completion_ratio: 5,
|
||||||
|
group_ratio: 1,
|
||||||
|
model_ratio: 2.5,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const logs = extractSelfLogItems(payload);
|
||||||
|
expect(logs[0]).toMatchObject({
|
||||||
|
modelName: 'claude-haiku-4-5-20251001',
|
||||||
|
tokenName: 'anyrouter',
|
||||||
|
promptTokens: 146638,
|
||||||
|
completionTokens: 172,
|
||||||
|
quota: 41528,
|
||||||
|
billingMeta: {
|
||||||
|
cacheReadTokens: 145692,
|
||||||
|
cacheCreationTokens: 945,
|
||||||
|
cacheRatio: 0.1,
|
||||||
|
cacheCreationRatio: 1.25,
|
||||||
|
completionRatio: 5,
|
||||||
|
groupRatio: 1,
|
||||||
|
modelRatio: 2.5,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it('matches best log by model + time window + request time', () => {
|
it('matches best log by model + time window + request time', () => {
|
||||||
const logs: SelfLogItem[] = [
|
const logs: SelfLogItem[] = [
|
||||||
{
|
{
|
||||||
@@ -137,11 +183,13 @@ describe('proxyUsageFallbackService', () => {
|
|||||||
expect(shouldLookupSelfLog('one-hub', { promptTokens: 1, completionTokens: 1, totalTokens: 2 })).toBe(true);
|
expect(shouldLookupSelfLog('one-hub', { promptTokens: 1, completionTokens: 1, totalTokens: 2 })).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('only enables self-log lookup for new-api/anyrouter when usage is missing', () => {
|
it('only enables self-log lookup for new-api when usage is missing', () => {
|
||||||
expect(shouldLookupSelfLog('new-api', { promptTokens: 0, completionTokens: 0, totalTokens: 0 })).toBe(true);
|
expect(shouldLookupSelfLog('new-api', { promptTokens: 0, completionTokens: 0, totalTokens: 0 })).toBe(true);
|
||||||
expect(shouldLookupSelfLog('anyrouter', { promptTokens: 0, completionTokens: 0, totalTokens: 0 })).toBe(true);
|
|
||||||
|
|
||||||
expect(shouldLookupSelfLog('new-api', { promptTokens: 12, completionTokens: 3, totalTokens: 15 })).toBe(false);
|
expect(shouldLookupSelfLog('new-api', { promptTokens: 12, completionTokens: 3, totalTokens: 15 })).toBe(false);
|
||||||
expect(shouldLookupSelfLog('anyrouter', { promptTokens: 12, completionTokens: 3, totalTokens: 15 })).toBe(false);
|
});
|
||||||
|
|
||||||
|
it('always enables self-log lookup for anyrouter to recover exact billing metadata', () => {
|
||||||
|
expect(shouldLookupSelfLog('anyrouter', { promptTokens: 0, completionTokens: 0, totalTokens: 0 })).toBe(true);
|
||||||
|
expect(shouldLookupSelfLog('anyrouter', { promptTokens: 12, completionTokens: 3, totalTokens: 15 })).toBe(true);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user