feat: add proxy log client detection filters

This commit is contained in:
cita
2026-03-20 12:25:30 +08:00
parent 2de5eebf29
commit 4e60290838
34 changed files with 3511 additions and 266 deletions
+61
View File
@@ -2,6 +2,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest';
const {
hasProxyLogBillingDetailsColumnMock,
hasProxyLogClientColumnsMock,
hasProxyLogDownstreamApiKeyIdColumnMock,
dbInsertMock,
dbInsertValuesMock,
@@ -9,6 +10,7 @@ const {
proxyLogsSchema,
} = vi.hoisted(() => ({
hasProxyLogBillingDetailsColumnMock: vi.fn(),
hasProxyLogClientColumnsMock: vi.fn(),
hasProxyLogDownstreamApiKeyIdColumnMock: vi.fn(),
dbInsertMock: vi.fn(),
dbInsertValuesMock: vi.fn(),
@@ -28,6 +30,10 @@ const {
totalTokens: 'total_tokens',
estimatedCost: 'estimated_cost',
billingDetails: 'billing_details',
clientFamily: 'client_family',
clientAppId: 'client_app_id',
clientAppName: 'client_app_name',
clientConfidence: 'client_confidence',
errorMessage: 'error_message',
retryCount: 'retry_count',
createdAt: 'created_at',
@@ -42,6 +48,7 @@ vi.mock('../db/index.js', () => ({
proxyLogs: proxyLogsSchema,
},
hasProxyLogBillingDetailsColumn: (...args: unknown[]) => hasProxyLogBillingDetailsColumnMock(...args),
hasProxyLogClientColumns: (...args: unknown[]) => hasProxyLogClientColumnsMock(...args),
hasProxyLogDownstreamApiKeyIdColumn: (...args: unknown[]) => hasProxyLogDownstreamApiKeyIdColumnMock(...args),
}));
@@ -50,11 +57,13 @@ import { insertProxyLog, withProxyLogSelectFields } from './proxyLogStore.js';
describe('proxyLogStore', () => {
beforeEach(() => {
hasProxyLogBillingDetailsColumnMock.mockReset();
hasProxyLogClientColumnsMock.mockReset();
hasProxyLogDownstreamApiKeyIdColumnMock.mockReset();
dbInsertMock.mockReset();
dbInsertValuesMock.mockReset();
dbInsertRunMock.mockReset();
hasProxyLogBillingDetailsColumnMock.mockResolvedValue(false);
hasProxyLogClientColumnsMock.mockResolvedValue(false);
hasProxyLogDownstreamApiKeyIdColumnMock.mockResolvedValue(false);
dbInsertMock.mockReturnValue({
@@ -132,4 +141,56 @@ describe('proxyLogStore', () => {
expect(dbInsertValuesMock.mock.calls[2][0].billingDetails).toBeUndefined();
expect(dbInsertValuesMock.mock.calls[2][0].downstreamApiKeyId).toBeUndefined();
});
it('writes structured client fields when the schema supports them', async () => {
hasProxyLogClientColumnsMock.mockResolvedValue(true);
await insertProxyLog({
modelRequested: 'gpt-5',
clientFamily: 'codex',
clientAppId: 'cherry_studio',
clientAppName: 'Cherry Studio',
clientConfidence: 'exact',
});
expect(dbInsertValuesMock).toHaveBeenCalledTimes(1);
expect(dbInsertValuesMock.mock.calls[0][0]).toMatchObject({
modelRequested: 'gpt-5',
clientFamily: 'codex',
clientAppId: 'cherry_studio',
clientAppName: 'Cherry Studio',
clientConfidence: 'exact',
});
});
it('retries proxy log inserts without structured client fields when those columns are missing', async () => {
hasProxyLogClientColumnsMock.mockResolvedValue(true);
dbInsertRunMock
.mockRejectedValueOnce(new Error('column proxy_logs.client_app_id does not exist'))
.mockResolvedValueOnce(undefined);
await insertProxyLog({
modelRequested: 'gpt-5',
clientFamily: 'codex',
clientAppId: 'cherry_studio',
clientAppName: 'Cherry Studio',
clientConfidence: 'exact',
});
expect(dbInsertValuesMock).toHaveBeenCalledTimes(2);
expect(dbInsertValuesMock.mock.calls[0][0]).toMatchObject({
modelRequested: 'gpt-5',
clientFamily: 'codex',
clientAppId: 'cherry_studio',
clientAppName: 'Cherry Studio',
clientConfidence: 'exact',
});
expect(dbInsertValuesMock.mock.calls[1][0]).toMatchObject({
modelRequested: 'gpt-5',
});
expect(dbInsertValuesMock.mock.calls[1][0].clientFamily).toBeUndefined();
expect(dbInsertValuesMock.mock.calls[1][0].clientAppId).toBeUndefined();
expect(dbInsertValuesMock.mock.calls[1][0].clientAppName).toBeUndefined();
expect(dbInsertValuesMock.mock.calls[1][0].clientConfidence).toBeUndefined();
});
});
+146 -60
View File
@@ -1,4 +1,10 @@
import { db, schema, hasProxyLogBillingDetailsColumn, hasProxyLogDownstreamApiKeyIdColumn } from '../db/index.js';
import {
db,
schema,
hasProxyLogBillingDetailsColumn,
hasProxyLogClientColumns,
hasProxyLogDownstreamApiKeyIdColumn,
} from '../db/index.js';
export type ProxyLogInsertInput = {
routeId?: number | null;
@@ -15,12 +21,16 @@ export type ProxyLogInsertInput = {
totalTokens?: number | null;
estimatedCost?: number | null;
billingDetails?: unknown;
clientFamily?: string | null;
clientAppId?: string | null;
clientAppName?: string | null;
clientConfidence?: string | null;
errorMessage?: string | null;
retryCount?: number | null;
createdAt?: string | null;
};
function buildProxyLogBaseSelectFields() {
function buildProxyLogCoreSelectFields() {
return {
id: schema.proxyLogs.id,
routeId: schema.proxyLogs.routeId,
@@ -42,47 +52,93 @@ function buildProxyLogBaseSelectFields() {
};
}
export function getProxyLogBaseSelectFields() {
return buildProxyLogBaseSelectFields();
function buildProxyLogClientSelectFields() {
return {
clientFamily: schema.proxyLogs.clientFamily,
clientAppId: schema.proxyLogs.clientAppId,
clientAppName: schema.proxyLogs.clientAppName,
clientConfidence: schema.proxyLogs.clientConfidence,
};
}
export type ProxyLogSelectFields = ReturnType<typeof buildProxyLogBaseSelectFields> & {
billingDetails?: typeof schema.proxyLogs.billingDetails;
};
function buildProxyLogSelectFields(options?: {
includeBillingDetails?: boolean;
includeClientFields?: boolean;
}) {
return {
...buildProxyLogCoreSelectFields(),
...(options?.includeClientFields ? buildProxyLogClientSelectFields() : {}),
...(options?.includeBillingDetails ? { billingDetails: schema.proxyLogs.billingDetails } : {}),
};
}
export function getProxyLogBaseSelectFields() {
return buildProxyLogCoreSelectFields();
}
export type ProxyLogSelectFields = ReturnType<typeof buildProxyLogSelectFields>;
export type ResolvedProxyLogSelectFields = {
includeBillingDetails: boolean;
includeClientFields: boolean;
fields: ProxyLogSelectFields;
};
export async function resolveProxyLogSelectFields(options?: { includeBillingDetails?: boolean }) {
export async function resolveProxyLogSelectFields(options?: {
includeBillingDetails?: boolean;
includeClientFields?: boolean;
}) {
const includeBillingDetails = options?.includeBillingDetails === true
&& await hasProxyLogBillingDetailsColumn();
const includeClientFields = options?.includeClientFields !== false
&& await hasProxyLogClientColumns();
return {
includeBillingDetails,
fields: includeBillingDetails
? { ...buildProxyLogBaseSelectFields(), billingDetails: schema.proxyLogs.billingDetails }
: buildProxyLogBaseSelectFields(),
includeClientFields,
fields: buildProxyLogSelectFields({
includeBillingDetails,
includeClientFields,
}),
};
}
export async function withProxyLogSelectFields<T>(
runner: (selection: ResolvedProxyLogSelectFields) => Promise<T>,
options?: { includeBillingDetails?: boolean },
options?: { includeBillingDetails?: boolean; includeClientFields?: boolean },
): Promise<T> {
const selection = await resolveProxyLogSelectFields(options);
let selection = await resolveProxyLogSelectFields(options);
try {
return await runner(selection);
} catch (error) {
if (selection.includeBillingDetails && isMissingBillingDetailsColumnError(error)) {
return await runner({
includeBillingDetails: false,
fields: buildProxyLogBaseSelectFields(),
});
while (true) {
try {
return await runner(selection);
} catch (error) {
if (selection.includeBillingDetails && isMissingBillingDetailsColumnError(error)) {
selection = {
includeBillingDetails: false,
includeClientFields: selection.includeClientFields,
fields: buildProxyLogSelectFields({
includeBillingDetails: false,
includeClientFields: selection.includeClientFields,
}),
};
continue;
}
if (selection.includeClientFields && isMissingProxyLogClientColumnsError(error)) {
selection = {
includeBillingDetails: selection.includeBillingDetails,
includeClientFields: false,
fields: buildProxyLogSelectFields({
includeBillingDetails: selection.includeBillingDetails,
includeClientFields: false,
}),
};
continue;
}
throw error;
}
throw error;
}
}
@@ -96,11 +152,15 @@ export function parseProxyLogBillingDetails(value: unknown): Record<string, unkn
}
}
export function isMissingBillingDetailsColumnError(error: unknown): boolean {
function normalizeProxyLogStoreErrorMessage(error: unknown): string {
const message = typeof error === 'object' && error && 'message' in error
? String((error as { message?: unknown }).message || '')
: String(error || '');
const lowered = message.toLowerCase();
return message.toLowerCase();
}
export function isMissingBillingDetailsColumnError(error: unknown): boolean {
const lowered = normalizeProxyLogStoreErrorMessage(error);
return lowered.includes('billing_details')
&& (
lowered.includes('does not exist')
@@ -111,10 +171,7 @@ export function isMissingBillingDetailsColumnError(error: unknown): boolean {
}
export function isMissingDownstreamApiKeyIdColumnError(error: unknown): boolean {
const message = typeof error === 'object' && error && 'message' in error
? String((error as { message?: unknown }).message || '')
: String(error || '');
const lowered = message.toLowerCase();
const lowered = normalizeProxyLogStoreErrorMessage(error);
return lowered.includes('downstream_api_key_id')
&& (
lowered.includes('does not exist')
@@ -124,6 +181,24 @@ export function isMissingDownstreamApiKeyIdColumnError(error: unknown): boolean
);
}
export function isMissingProxyLogClientColumnsError(error: unknown): boolean {
const lowered = normalizeProxyLogStoreErrorMessage(error);
const hasClientColumnReference = [
'client_family',
'client_app_id',
'client_app_name',
'client_confidence',
].some((columnName) => lowered.includes(columnName));
return hasClientColumnReference
&& (
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,
@@ -149,43 +224,54 @@ export async function insertProxyLog(input: ProxyLogInsertInput): Promise<void>
&& await hasProxyLogBillingDetailsColumn();
const includeDownstreamApiKeyId = input.downstreamApiKeyId != null
&& await hasProxyLogDownstreamApiKeyIdColumn();
const requestedClientFields = [
input.clientFamily,
input.clientAppId,
input.clientAppName,
input.clientConfidence,
].some((value) => value != null && String(value).trim().length > 0);
const includeClientFields = requestedClientFields
&& await hasProxyLogClientColumns();
try {
await db.insert(schema.proxyLogs).values(
includeBillingDetails || includeDownstreamApiKeyId
let allowBillingDetails = includeBillingDetails;
let allowDownstreamApiKeyId = includeDownstreamApiKeyId;
let allowClientFields = includeClientFields;
while (true) {
const values = {
...baseValues,
...(allowBillingDetails ? { billingDetails: serializedBillingDetails } : {}),
...(allowDownstreamApiKeyId ? { downstreamApiKeyId: input.downstreamApiKeyId } : {}),
...(allowClientFields
? {
...baseValues,
...(includeBillingDetails ? { billingDetails: serializedBillingDetails } : {}),
...(includeDownstreamApiKeyId ? { downstreamApiKeyId: input.downstreamApiKeyId } : {}),
clientFamily: input.clientFamily ?? null,
clientAppId: input.clientAppId ?? null,
clientAppName: input.clientAppName ?? null,
clientConfidence: input.clientConfidence ?? null,
}
: baseValues,
).run();
} catch (error) {
if (includeBillingDetails && isMissingBillingDetailsColumnError(error)) {
try {
await db.insert(schema.proxyLogs).values(
includeDownstreamApiKeyId
? { ...baseValues, downstreamApiKeyId: input.downstreamApiKeyId }
: baseValues,
).run();
} catch (fallbackError) {
if (includeDownstreamApiKeyId && isMissingDownstreamApiKeyIdColumnError(fallbackError)) {
await db.insert(schema.proxyLogs).values(baseValues).run();
return;
}
throw fallbackError;
}
return;
}
: {}),
};
if (includeDownstreamApiKeyId && isMissingDownstreamApiKeyIdColumnError(error)) {
await db.insert(schema.proxyLogs).values(
includeBillingDetails
? { ...baseValues, billingDetails: serializedBillingDetails }
: baseValues,
).run();
try {
await db.insert(schema.proxyLogs).values(values).run();
return;
} catch (error) {
if (allowBillingDetails && isMissingBillingDetailsColumnError(error)) {
allowBillingDetails = false;
continue;
}
if (allowDownstreamApiKeyId && isMissingDownstreamApiKeyIdColumnError(error)) {
allowDownstreamApiKeyId = false;
continue;
}
if (allowClientFields && isMissingProxyLogClientColumnsError(error)) {
allowClientFields = false;
continue;
}
throw error;
}
throw error;
}
}