forked from boos/BoosAPI
Merge pull request #187 from cita-777/codex/proxy-log-client-detection-main
feat: add proxy log client detection filters
This commit is contained in:
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user