Files
metapi/src/server/services/proxyLogStore.ts
T
DeliciousandDeliciousBuding a64a51aa0c feat: 下游 API 密钥管理 (#103)
* 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>
2026-03-15 22:00:02 +08:00

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;
}
}