* feat: downstream API key usage + proxy log attribution * chore(schema): regenerate generated artifacts * test: keep migration replay + platform detection tests stable * feat: complete downstream api key management validation * refactor: move downstream keys into console nav * feat: upgrade downstream keys management console * feat: harden downstream key management flows * test: cover downstream key policy guards * feat: add downstream key grouping and tags * feat: polish downstream key search and log metadata * test: fix sqlite migration bootstrap coverage * perf: isolate vchart bundle loading * fix: align downstream key drawer metrics --------- Co-authored-by: DeliciousBuding <DeliciousBuding@users.noreply.github.com>
192 lines
6.5 KiB
TypeScript
192 lines
6.5 KiB
TypeScript
import { db, schema, hasProxyLogBillingDetailsColumn, hasProxyLogDownstreamApiKeyIdColumn } from '../db/index.js';
|
|
|
|
export type ProxyLogInsertInput = {
|
|
routeId?: number | null;
|
|
channelId?: number | null;
|
|
accountId?: number | null;
|
|
downstreamApiKeyId?: 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,
|
|
downstreamApiKeyId: schema.proxyLogs.downstreamApiKeyId,
|
|
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 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();
|
|
return lowered.includes('downstream_api_key_id')
|
|
&& (
|
|
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();
|
|
const includeDownstreamApiKeyId = input.downstreamApiKeyId != null
|
|
&& await hasProxyLogDownstreamApiKeyIdColumn();
|
|
|
|
try {
|
|
await db.insert(schema.proxyLogs).values(
|
|
includeBillingDetails || includeDownstreamApiKeyId
|
|
? {
|
|
...baseValues,
|
|
...(includeBillingDetails ? { billingDetails: serializedBillingDetails } : {}),
|
|
...(includeDownstreamApiKeyId ? { downstreamApiKeyId: input.downstreamApiKeyId } : {}),
|
|
}
|
|
: 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();
|
|
return;
|
|
}
|
|
throw error;
|
|
}
|
|
}
|