feat: add proxy log client detection filters
This commit is contained in:
@@ -75,6 +75,10 @@ describe('stats proxy logs routes', () => {
|
||||
modelRequested: 'gpt-4o',
|
||||
modelActual: 'gpt-4o',
|
||||
status: 'success',
|
||||
clientFamily: 'generic',
|
||||
clientAppId: 'cherry_studio',
|
||||
clientAppName: 'Cherry Studio',
|
||||
clientConfidence: 'exact',
|
||||
promptTokens: 10,
|
||||
completionTokens: 5,
|
||||
totalTokens: 15,
|
||||
@@ -88,6 +92,7 @@ describe('stats proxy logs routes', () => {
|
||||
modelRequested: 'gpt-4o-mini',
|
||||
modelActual: 'gpt-4o-mini',
|
||||
status: 'failed',
|
||||
clientFamily: 'codex',
|
||||
promptTokens: 8,
|
||||
completionTokens: 2,
|
||||
totalTokens: 10,
|
||||
@@ -139,6 +144,10 @@ describe('stats proxy logs routes', () => {
|
||||
totalCost: number;
|
||||
totalTokensAll: number;
|
||||
};
|
||||
clientOptions: Array<{
|
||||
value: string;
|
||||
label: string;
|
||||
}>;
|
||||
};
|
||||
|
||||
expect(body.page).toBe(2);
|
||||
@@ -150,7 +159,14 @@ describe('stats proxy logs routes', () => {
|
||||
expect(body.items[0]?.downstreamKeyName).toBe('项目A-Key');
|
||||
expect(body.items[0]?.downstreamKeyGroupName).toBe('项目A');
|
||||
expect(body.items[0]?.downstreamKeyTags).toEqual(['VIP', '灰度']);
|
||||
expect(body.items[0]?.clientFamily).toBe('codex');
|
||||
expect(body.items[0]?.clientAppId).toBe(null);
|
||||
expect(body.items[0]?.clientAppName).toBe(null);
|
||||
expect(body.items[0]?.clientConfidence).toBe(null);
|
||||
expect(body.items[0]).not.toHaveProperty('billingDetails');
|
||||
expect(body.clientOptions).toEqual([
|
||||
{ value: 'family:codex', label: '协议 · Codex' },
|
||||
]);
|
||||
expect(body.summary).toEqual({
|
||||
totalCount: 3,
|
||||
successCount: 1,
|
||||
@@ -188,6 +204,10 @@ describe('stats proxy logs routes', () => {
|
||||
modelRequested: 'gpt-5',
|
||||
modelActual: 'gpt-5',
|
||||
status: 'success',
|
||||
clientFamily: 'codex',
|
||||
clientAppId: 'cherry_studio',
|
||||
clientAppName: 'Cherry Studio',
|
||||
clientConfidence: 'exact',
|
||||
promptTokens: 100,
|
||||
completionTokens: 20,
|
||||
totalTokens: 120,
|
||||
@@ -214,6 +234,10 @@ describe('stats proxy logs routes', () => {
|
||||
downstreamKeyName: string | null;
|
||||
downstreamKeyGroupName: string | null;
|
||||
downstreamKeyTags: string[];
|
||||
clientFamily: string | null;
|
||||
clientAppId: string | null;
|
||||
clientAppName: string | null;
|
||||
clientConfidence: string | null;
|
||||
billingDetails: Record<string, unknown> | null;
|
||||
};
|
||||
|
||||
@@ -223,6 +247,10 @@ describe('stats proxy logs routes', () => {
|
||||
expect(body.downstreamKeyName).toBe('detail-key');
|
||||
expect(body.downstreamKeyGroupName).toBe('测试项目');
|
||||
expect(body.downstreamKeyTags).toEqual(['回归', '日志']);
|
||||
expect(body.clientFamily).toBe('codex');
|
||||
expect(body.clientAppId).toBe('cherry_studio');
|
||||
expect(body.clientAppName).toBe('Cherry Studio');
|
||||
expect(body.clientConfidence).toBe('exact');
|
||||
expect(body.billingDetails).toMatchObject({
|
||||
breakdown: { totalCost: 0.12 },
|
||||
usage: { promptTokens: 100, completionTokens: 20 },
|
||||
@@ -392,4 +420,118 @@ describe('stats proxy logs routes', () => {
|
||||
totalTokensAll: 30,
|
||||
});
|
||||
});
|
||||
|
||||
it('filters proxy logs by app id while keeping client options scoped only by the other filters', async () => {
|
||||
const site = await db.insert(schema.sites).values({
|
||||
name: 'client-filter-site',
|
||||
url: 'https://client-filter.example.com',
|
||||
platform: 'new-api',
|
||||
}).returning().get();
|
||||
|
||||
const account = await db.insert(schema.accounts).values({
|
||||
siteId: site.id,
|
||||
username: 'client-filter-user',
|
||||
accessToken: 'client-filter-token',
|
||||
status: 'active',
|
||||
}).returning().get();
|
||||
|
||||
await db.insert(schema.proxyLogs).values([
|
||||
{
|
||||
accountId: account.id,
|
||||
modelRequested: 'gpt-4o',
|
||||
modelActual: 'gpt-4o',
|
||||
status: 'success',
|
||||
clientFamily: 'generic',
|
||||
clientAppId: 'cherry_studio',
|
||||
clientAppName: 'Cherry Studio',
|
||||
clientConfidence: 'exact',
|
||||
totalTokens: 12,
|
||||
estimatedCost: 0.12,
|
||||
createdAt: formatUtcSqlDateTime(new Date('2026-03-09T11:00:00.000Z')),
|
||||
},
|
||||
{
|
||||
accountId: account.id,
|
||||
modelRequested: 'gpt-4.1',
|
||||
modelActual: 'gpt-4.1',
|
||||
status: 'failed',
|
||||
clientFamily: 'codex',
|
||||
totalTokens: 22,
|
||||
estimatedCost: 0.22,
|
||||
createdAt: formatUtcSqlDateTime(new Date('2026-03-09T11:05:00.000Z')),
|
||||
},
|
||||
]).run();
|
||||
|
||||
const response = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/stats/proxy-logs?client=app%3Acherry_studio',
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
const body = response.json() as {
|
||||
total: number;
|
||||
items: Array<Record<string, unknown>>;
|
||||
clientOptions: Array<{
|
||||
value: string;
|
||||
label: string;
|
||||
}>;
|
||||
};
|
||||
|
||||
expect(body.total).toBe(1);
|
||||
expect(body.items[0]?.clientAppId).toBe('cherry_studio');
|
||||
expect(body.clientOptions).toEqual([
|
||||
{ value: 'app:cherry_studio', label: '应用 · Cherry Studio' },
|
||||
{ value: 'family:codex', label: '协议 · Codex' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('falls back to legacy client prefixes for old logs without inferring an app fingerprint', async () => {
|
||||
const site = await db.insert(schema.sites).values({
|
||||
name: 'legacy-site',
|
||||
url: 'https://legacy.example.com',
|
||||
platform: 'new-api',
|
||||
}).returning().get();
|
||||
|
||||
const account = await db.insert(schema.accounts).values({
|
||||
siteId: site.id,
|
||||
username: 'legacy-user',
|
||||
accessToken: 'legacy-token',
|
||||
status: 'active',
|
||||
}).returning().get();
|
||||
|
||||
const inserted = await db.insert(schema.proxyLogs).values({
|
||||
accountId: account.id,
|
||||
modelRequested: 'gpt-4o',
|
||||
modelActual: 'gpt-4o',
|
||||
status: 'failed',
|
||||
errorMessage: '[client:codex] [session:turn-123] [downstream:/v1/responses] upstream error',
|
||||
totalTokens: 9,
|
||||
estimatedCost: 0.09,
|
||||
createdAt: formatUtcSqlDateTime(new Date('2026-03-09T12:00:00.000Z')),
|
||||
}).run();
|
||||
|
||||
const logId = Number(inserted.lastInsertRowid || 0);
|
||||
const listResponse = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/stats/proxy-logs',
|
||||
});
|
||||
const detailResponse = await app.inject({
|
||||
method: 'GET',
|
||||
url: `/api/stats/proxy-logs/${logId}`,
|
||||
});
|
||||
|
||||
expect(listResponse.statusCode).toBe(200);
|
||||
expect(detailResponse.statusCode).toBe(200);
|
||||
|
||||
const listBody = listResponse.json() as {
|
||||
items: Array<Record<string, unknown>>;
|
||||
};
|
||||
const detailBody = detailResponse.json() as Record<string, unknown>;
|
||||
|
||||
expect(listBody.items[0]?.clientFamily).toBe('codex');
|
||||
expect(listBody.items[0]?.clientAppId).toBe(null);
|
||||
expect(listBody.items[0]?.clientAppName).toBe(null);
|
||||
expect(detailBody.clientFamily).toBe('codex');
|
||||
expect(detailBody.clientAppId).toBe(null);
|
||||
expect(detailBody.clientAppName).toBe(null);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
parseProxyLogBillingDetails,
|
||||
withProxyLogSelectFields,
|
||||
} from '../../services/proxyLogStore.js';
|
||||
import { parseProxyLogMessageMeta } from '../proxy/logPathMeta.js';
|
||||
import { requiresManagedAccountTokens } from '../../services/accountExtraConfig.js';
|
||||
import { ACCOUNT_TOKEN_VALUE_STATUS_READY } from '../../services/accountTokenService.js';
|
||||
import {
|
||||
@@ -85,6 +86,22 @@ function proxyCostSqlExpression() {
|
||||
}
|
||||
|
||||
type ProxyLogStatusFilter = 'all' | 'success' | 'failed';
|
||||
type ProxyLogClientFilter = {
|
||||
kind: 'app' | 'family';
|
||||
value: string;
|
||||
} | null;
|
||||
|
||||
type ProxyLogClientOption = {
|
||||
value: string;
|
||||
label: string;
|
||||
};
|
||||
|
||||
const PROXY_LOG_CLIENT_FAMILY_LABELS: Record<string, string> = {
|
||||
codex: 'Codex',
|
||||
claude_code: 'Claude Code',
|
||||
gemini_cli: 'Gemini CLI',
|
||||
generic: '通用',
|
||||
};
|
||||
|
||||
function normalizeProxyLogPageSize(raw?: string): number {
|
||||
const parsed = Number.parseInt(raw || '50', 10);
|
||||
@@ -115,6 +132,20 @@ function normalizeProxyLogSiteId(raw?: string): number | null {
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function normalizeProxyLogClientFilter(raw?: string): ProxyLogClientFilter {
|
||||
const text = (raw || '').trim();
|
||||
if (!text) return null;
|
||||
const separatorIndex = text.indexOf(':');
|
||||
if (separatorIndex <= 0) return null;
|
||||
const kind = text.slice(0, separatorIndex).trim().toLowerCase();
|
||||
const value = text.slice(separatorIndex + 1).trim().toLowerCase();
|
||||
if (!value) return null;
|
||||
if (kind === 'app' || kind === 'family') {
|
||||
return { kind, value };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function normalizeProxyLogTimeBoundary(raw?: string): string | null {
|
||||
const text = (raw || '').trim();
|
||||
if (!text) return null;
|
||||
@@ -166,9 +197,18 @@ function buildProxyLogStatusCondition(status: ProxyLogStatusFilter) {
|
||||
return null;
|
||||
}
|
||||
|
||||
function buildProxyLogClientCondition(client: ProxyLogClientFilter) {
|
||||
if (!client) return null;
|
||||
if (client.kind === 'app') {
|
||||
return eq(schema.proxyLogs.clientAppId, client.value);
|
||||
}
|
||||
return eq(schema.proxyLogs.clientFamily, client.value);
|
||||
}
|
||||
|
||||
function buildProxyLogWhereClause(params: {
|
||||
status?: ProxyLogStatusFilter;
|
||||
search?: string;
|
||||
client?: ProxyLogClientFilter;
|
||||
siteId?: number | null;
|
||||
fromUtc?: string | null;
|
||||
toUtc?: string | null;
|
||||
@@ -176,6 +216,7 @@ function buildProxyLogWhereClause(params: {
|
||||
const conditions = [
|
||||
params.status ? buildProxyLogStatusCondition(params.status) : null,
|
||||
params.search ? buildProxyLogSearchCondition(params.search) : null,
|
||||
params.client ? buildProxyLogClientCondition(params.client) : null,
|
||||
params.siteId ? eq(schema.sites.id, params.siteId) : null,
|
||||
params.fromUtc ? gte(schema.proxyLogs.createdAt, params.fromUtc) : null,
|
||||
params.toUtc ? lt(schema.proxyLogs.createdAt, params.toUtc) : null,
|
||||
@@ -189,6 +230,83 @@ function toRoundedMicroNumber(value: number | null | undefined): number {
|
||||
return Math.round(Number(value || 0) * 1_000_000) / 1_000_000;
|
||||
}
|
||||
|
||||
function normalizeNullableText(value: unknown): string | null {
|
||||
if (typeof value !== 'string') return null;
|
||||
const trimmed = value.trim();
|
||||
return trimmed || null;
|
||||
}
|
||||
|
||||
function normalizeClientConfidence(value: unknown): string | null {
|
||||
const normalized = normalizeNullableText(value)?.toLowerCase() || null;
|
||||
if (normalized === 'exact' || normalized === 'heuristic' || normalized === 'unknown') {
|
||||
return normalized;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function displayProxyLogClientFamily(value: string | null): string | null {
|
||||
if (!value) return null;
|
||||
return PROXY_LOG_CLIENT_FAMILY_LABELS[value] || value;
|
||||
}
|
||||
|
||||
function resolveProxyLogClientMeta(proxyLog: Record<string, unknown>) {
|
||||
const clientFamily = normalizeNullableText(proxyLog.clientFamily)?.toLowerCase() || null;
|
||||
const clientAppId = normalizeNullableText(proxyLog.clientAppId)?.toLowerCase() || null;
|
||||
const clientAppName = normalizeNullableText(proxyLog.clientAppName) || null;
|
||||
const clientConfidence = normalizeClientConfidence(proxyLog.clientConfidence);
|
||||
|
||||
if (clientFamily || clientAppId || clientAppName || clientConfidence) {
|
||||
return {
|
||||
clientFamily,
|
||||
clientAppId,
|
||||
clientAppName,
|
||||
clientConfidence,
|
||||
};
|
||||
}
|
||||
|
||||
const legacyMeta = parseProxyLogMessageMeta(typeof proxyLog.errorMessage === 'string' ? proxyLog.errorMessage : '');
|
||||
return {
|
||||
clientFamily: normalizeNullableText(legacyMeta.clientKind)?.toLowerCase() || null,
|
||||
clientAppId: null,
|
||||
clientAppName: null,
|
||||
clientConfidence: null,
|
||||
};
|
||||
}
|
||||
|
||||
function buildProxyLogClientOptions(rows: Array<{
|
||||
clientFamily?: string | null;
|
||||
clientAppId?: string | null;
|
||||
clientAppName?: string | null;
|
||||
}>): ProxyLogClientOption[] {
|
||||
const appOptions = new Map<string, ProxyLogClientOption>();
|
||||
const familyOptions = new Map<string, ProxyLogClientOption>();
|
||||
|
||||
for (const row of rows) {
|
||||
const clientAppId = normalizeNullableText(row.clientAppId)?.toLowerCase() || null;
|
||||
const clientAppName = normalizeNullableText(row.clientAppName) || null;
|
||||
const clientFamily = normalizeNullableText(row.clientFamily)?.toLowerCase() || null;
|
||||
|
||||
if (clientAppId && clientAppName && !appOptions.has(clientAppId)) {
|
||||
appOptions.set(clientAppId, {
|
||||
value: `app:${clientAppId}`,
|
||||
label: `应用 · ${clientAppName}`,
|
||||
});
|
||||
}
|
||||
|
||||
if (clientFamily && clientFamily !== 'generic' && !familyOptions.has(clientFamily)) {
|
||||
familyOptions.set(clientFamily, {
|
||||
value: `family:${clientFamily}`,
|
||||
label: `协议 · ${displayProxyLogClientFamily(clientFamily) || clientFamily}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
...Array.from(appOptions.values()).sort((left, right) => left.label.localeCompare(right.label, 'zh-CN')),
|
||||
...Array.from(familyOptions.values()).sort((left, right) => left.label.localeCompare(right.label, 'zh-CN')),
|
||||
];
|
||||
}
|
||||
|
||||
const SITE_AVAILABILITY_BUCKET_COUNT = 24;
|
||||
const SITE_AVAILABILITY_BUCKET_MS = 60 * 60 * 1000;
|
||||
|
||||
@@ -351,11 +469,16 @@ function mapProxyLogRow(
|
||||
},
|
||||
options?: { includeBillingDetails?: boolean },
|
||||
) {
|
||||
const clientMeta = resolveProxyLogClientMeta(row.proxy_logs);
|
||||
return {
|
||||
...row.proxy_logs,
|
||||
...(options?.includeBillingDetails
|
||||
? { billingDetails: parseProxyLogBillingDetails(row.proxy_logs.billingDetails) }
|
||||
: {}),
|
||||
clientFamily: clientMeta.clientFamily,
|
||||
clientAppId: clientMeta.clientAppId,
|
||||
clientAppName: clientMeta.clientAppName,
|
||||
clientConfidence: clientMeta.clientConfidence,
|
||||
username: row.accounts?.username || null,
|
||||
siteId: row.sites?.id || null,
|
||||
siteName: row.sites?.name || null,
|
||||
@@ -547,6 +670,7 @@ export async function statsRoutes(app: FastifyInstance) {
|
||||
offset?: string;
|
||||
status?: string;
|
||||
search?: string;
|
||||
client?: string;
|
||||
siteId?: string;
|
||||
from?: string;
|
||||
to?: string;
|
||||
@@ -555,11 +679,13 @@ export async function statsRoutes(app: FastifyInstance) {
|
||||
const offset = normalizeProxyLogOffset(request.query.offset);
|
||||
const status = normalizeProxyLogStatusFilter(request.query.status);
|
||||
const search = normalizeProxyLogSearch(request.query.search);
|
||||
const client = normalizeProxyLogClientFilter(request.query.client);
|
||||
const siteId = normalizeProxyLogSiteId(request.query.siteId);
|
||||
const fromUtc = normalizeProxyLogTimeBoundary(request.query.from);
|
||||
const toUtc = normalizeProxyLogTimeBoundary(request.query.to);
|
||||
const listWhere = buildProxyLogWhereClause({ status, search, siteId, fromUtc, toUtc });
|
||||
const summaryWhere = buildProxyLogWhereClause({ search, siteId, fromUtc, toUtc });
|
||||
const listWhere = buildProxyLogWhereClause({ status, search, client, siteId, fromUtc, toUtc });
|
||||
const summaryWhere = buildProxyLogWhereClause({ search, client, siteId, fromUtc, toUtc });
|
||||
const clientOptionsWhere = buildProxyLogWhereClause({ status, search, siteId, fromUtc, toUtc });
|
||||
|
||||
const listRows = await withProxyLogSelectFields(({ fields }) => {
|
||||
let query = db.select({
|
||||
@@ -604,6 +730,31 @@ export async function statsRoutes(app: FastifyInstance) {
|
||||
}
|
||||
const totalRow = await totalQuery.get();
|
||||
|
||||
const clientOptionRows = await withProxyLogSelectFields(({ fields, includeClientFields }) => {
|
||||
if (!includeClientFields) {
|
||||
return Promise.resolve([]);
|
||||
}
|
||||
|
||||
let query = db.select({
|
||||
clientFamily: fields.clientFamily!,
|
||||
clientAppId: fields.clientAppId!,
|
||||
clientAppName: fields.clientAppName!,
|
||||
}).from(schema.proxyLogs)
|
||||
.leftJoin(schema.accounts, eq(schema.proxyLogs.accountId, schema.accounts.id))
|
||||
.leftJoin(schema.sites, eq(schema.accounts.siteId, schema.sites.id))
|
||||
.leftJoin(schema.downstreamApiKeys, eq(schema.proxyLogs.downstreamApiKeyId, schema.downstreamApiKeys.id));
|
||||
|
||||
if (clientOptionsWhere) {
|
||||
query = query.where(clientOptionsWhere) as typeof query;
|
||||
}
|
||||
|
||||
return query.all();
|
||||
}, { includeBillingDetails: false, includeClientFields: true }) as Array<{
|
||||
clientFamily?: string | null;
|
||||
clientAppId?: string | null;
|
||||
clientAppName?: string | null;
|
||||
}>;
|
||||
|
||||
let summaryQuery = db.select({
|
||||
totalCount: sql<number>`count(*)`,
|
||||
successCount: sql<number>`coalesce(sum(case when ${schema.proxyLogs.status} = 'success' then 1 else 0 end), 0)`,
|
||||
@@ -624,6 +775,7 @@ export async function statsRoutes(app: FastifyInstance) {
|
||||
total: Number(totalRow?.total || 0),
|
||||
page: Math.floor(offset / limit) + 1,
|
||||
pageSize: limit,
|
||||
clientOptions: buildProxyLogClientOptions(clientOptionRows),
|
||||
summary: {
|
||||
totalCount: Number(summaryRow?.totalCount || 0),
|
||||
successCount: Number(summaryRow?.successCount || 0),
|
||||
|
||||
Reference in New Issue
Block a user