forked from boos/BoosAPI
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>
This commit is contained in:
@@ -448,12 +448,13 @@ function buildStatements(snapshot: BackupSnapshot): InsertStatement[] {
|
||||
for (const row of snapshot.accounts.proxyLogs) {
|
||||
statements.push({
|
||||
table: 'proxy_logs',
|
||||
columns: ['id', 'route_id', 'channel_id', 'account_id', 'model_requested', 'model_actual', 'status', 'http_status', 'latency_ms', 'prompt_tokens', 'completion_tokens', 'total_tokens', 'estimated_cost', 'billing_details', 'error_message', 'retry_count', 'created_at'],
|
||||
columns: ['id', 'route_id', 'channel_id', 'account_id', 'downstream_api_key_id', 'model_requested', 'model_actual', 'status', 'http_status', 'latency_ms', 'prompt_tokens', 'completion_tokens', 'total_tokens', 'estimated_cost', 'billing_details', 'error_message', 'retry_count', 'created_at'],
|
||||
values: [
|
||||
asNumber(row.id, 0),
|
||||
asNumber(row.routeId, null),
|
||||
asNumber(row.channelId, null),
|
||||
asNumber(row.accountId, null),
|
||||
asNumber((row as any).downstreamApiKeyId ?? (row as any).downstream_api_key_id, null),
|
||||
asNullableString(row.modelRequested),
|
||||
asNullableString(row.modelActual),
|
||||
asNullableString(row.status),
|
||||
|
||||
@@ -12,6 +12,8 @@ export type DownstreamApiKeyPolicyView = {
|
||||
key: string;
|
||||
keyMasked: string;
|
||||
description: string | null;
|
||||
groupName: string | null;
|
||||
tags: string[];
|
||||
enabled: boolean;
|
||||
expiresAt: string | null;
|
||||
maxCost: number | null;
|
||||
@@ -97,6 +99,34 @@ function parseJson(value: string | null | undefined): unknown {
|
||||
}
|
||||
}
|
||||
|
||||
export function normalizeGroupNameInput(input: unknown): string | null {
|
||||
if (typeof input !== 'string') return null;
|
||||
const value = input.trim();
|
||||
if (!value) return null;
|
||||
return value.slice(0, 64);
|
||||
}
|
||||
|
||||
export function normalizeTagsInput(input: unknown): string[] {
|
||||
const rawValues = Array.isArray(input)
|
||||
? input
|
||||
: (typeof input === 'string' ? input.split(/[\r\n,,]+/g) : []);
|
||||
|
||||
const tags: string[] = [];
|
||||
const seen = new Set<string>();
|
||||
for (const raw of rawValues) {
|
||||
const value = String(raw || '').trim();
|
||||
if (!value) continue;
|
||||
const normalized = value.slice(0, 32);
|
||||
const dedupeKey = normalized.toLowerCase();
|
||||
if (seen.has(dedupeKey)) continue;
|
||||
seen.add(dedupeKey);
|
||||
tags.push(normalized);
|
||||
if (tags.length >= 20) break;
|
||||
}
|
||||
|
||||
return tags;
|
||||
}
|
||||
|
||||
export function normalizeSupportedModelsInput(input: unknown): string[] {
|
||||
if (Array.isArray(input)) {
|
||||
return input
|
||||
@@ -226,6 +256,8 @@ export function toDownstreamApiKeyPolicyView(row: DownstreamApiKeyRow): Downstre
|
||||
key: row.key,
|
||||
keyMasked: maskSecret(row.key),
|
||||
description: row.description || null,
|
||||
groupName: normalizeGroupNameInput(row.groupName),
|
||||
tags: normalizeTagsInput(parseJson(row.tags)),
|
||||
enabled: !!row.enabled,
|
||||
expiresAt: row.expiresAt || null,
|
||||
maxCost: row.maxCost ?? null,
|
||||
@@ -385,6 +417,8 @@ export function normalizeDownstreamApiKeyPayload(input: {
|
||||
name?: unknown;
|
||||
key?: unknown;
|
||||
description?: unknown;
|
||||
groupName?: unknown;
|
||||
tags?: unknown;
|
||||
enabled?: unknown;
|
||||
expiresAt?: unknown;
|
||||
maxCost?: unknown;
|
||||
@@ -398,6 +432,8 @@ export function normalizeDownstreamApiKeyPayload(input: {
|
||||
const description = typeof input.description === 'string'
|
||||
? input.description.trim()
|
||||
: '';
|
||||
const groupName = normalizeGroupNameInput(input.groupName);
|
||||
const tags = normalizeTagsInput(input.tags);
|
||||
const enabled = input.enabled === undefined ? true : !!input.enabled;
|
||||
|
||||
const expiresAtRaw = typeof input.expiresAt === 'string'
|
||||
@@ -422,6 +458,8 @@ export function normalizeDownstreamApiKeyPayload(input: {
|
||||
name,
|
||||
key,
|
||||
description: description || null,
|
||||
groupName,
|
||||
tags,
|
||||
enabled,
|
||||
expiresAt,
|
||||
maxCost,
|
||||
|
||||
@@ -7,19 +7,34 @@ async function withHttpServer(
|
||||
handler: (req: IncomingMessage, res: ServerResponse) => void,
|
||||
run: (baseUrl: string) => Promise<void>,
|
||||
) {
|
||||
const server = createServer(handler);
|
||||
await new Promise<void>((resolve) => {
|
||||
server.listen(0, '127.0.0.1', () => resolve());
|
||||
});
|
||||
const { port } = server.address() as AddressInfo;
|
||||
const baseUrl = `http://127.0.0.1:${port}`;
|
||||
try {
|
||||
await run(baseUrl);
|
||||
} finally {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
server.close((err?: Error) => (err ? reject(err) : resolve()));
|
||||
// Avoid flakiness: CPA uses port 8317 by convention, and our platform detection
|
||||
// includes a fast-path for localhost:8317. Random ephemeral ports can collide.
|
||||
for (let attempt = 0; attempt < 5; attempt += 1) {
|
||||
const server = createServer(handler);
|
||||
await new Promise<void>((resolve) => {
|
||||
server.listen(0, '127.0.0.1', () => resolve());
|
||||
});
|
||||
|
||||
const { port } = server.address() as AddressInfo;
|
||||
if (port === 8317) {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
server.close((err?: Error) => (err ? reject(err) : resolve()));
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
const baseUrl = `http://127.0.0.1:${port}`;
|
||||
try {
|
||||
await run(baseUrl);
|
||||
return;
|
||||
} finally {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
server.close((err?: Error) => (err ? reject(err) : resolve()));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error('withHttpServer: unable to allocate a test port that avoids 8317');
|
||||
}
|
||||
|
||||
describe('getAdapter platform aliases', () => {
|
||||
|
||||
@@ -2,12 +2,14 @@ import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const {
|
||||
hasProxyLogBillingDetailsColumnMock,
|
||||
hasProxyLogDownstreamApiKeyIdColumnMock,
|
||||
dbInsertMock,
|
||||
dbInsertValuesMock,
|
||||
dbInsertRunMock,
|
||||
proxyLogsSchema,
|
||||
} = vi.hoisted(() => ({
|
||||
hasProxyLogBillingDetailsColumnMock: vi.fn(),
|
||||
hasProxyLogDownstreamApiKeyIdColumnMock: vi.fn(),
|
||||
dbInsertMock: vi.fn(),
|
||||
dbInsertValuesMock: vi.fn(),
|
||||
dbInsertRunMock: vi.fn(),
|
||||
@@ -40,6 +42,7 @@ vi.mock('../db/index.js', () => ({
|
||||
proxyLogs: proxyLogsSchema,
|
||||
},
|
||||
hasProxyLogBillingDetailsColumn: (...args: unknown[]) => hasProxyLogBillingDetailsColumnMock(...args),
|
||||
hasProxyLogDownstreamApiKeyIdColumn: (...args: unknown[]) => hasProxyLogDownstreamApiKeyIdColumnMock(...args),
|
||||
}));
|
||||
|
||||
import { insertProxyLog, withProxyLogSelectFields } from './proxyLogStore.js';
|
||||
@@ -47,9 +50,12 @@ import { insertProxyLog, withProxyLogSelectFields } from './proxyLogStore.js';
|
||||
describe('proxyLogStore', () => {
|
||||
beforeEach(() => {
|
||||
hasProxyLogBillingDetailsColumnMock.mockReset();
|
||||
hasProxyLogDownstreamApiKeyIdColumnMock.mockReset();
|
||||
dbInsertMock.mockReset();
|
||||
dbInsertValuesMock.mockReset();
|
||||
dbInsertRunMock.mockReset();
|
||||
hasProxyLogBillingDetailsColumnMock.mockResolvedValue(false);
|
||||
hasProxyLogDownstreamApiKeyIdColumnMock.mockResolvedValue(false);
|
||||
|
||||
dbInsertMock.mockReturnValue({
|
||||
values: (...args: unknown[]) => dbInsertValuesMock(...args),
|
||||
@@ -95,4 +101,35 @@ describe('proxyLogStore', () => {
|
||||
});
|
||||
expect(dbInsertValuesMock.mock.calls[1][0].billingDetails).toBeUndefined();
|
||||
});
|
||||
|
||||
it('falls back to base values when both billing details and downstream key columns are missing', async () => {
|
||||
hasProxyLogBillingDetailsColumnMock.mockResolvedValue(true);
|
||||
hasProxyLogDownstreamApiKeyIdColumnMock.mockResolvedValue(true);
|
||||
dbInsertRunMock
|
||||
.mockRejectedValueOnce(new Error('column proxy_logs.billing_details does not exist'))
|
||||
.mockRejectedValueOnce(new Error('column proxy_logs.downstream_api_key_id does not exist'))
|
||||
.mockResolvedValueOnce(undefined);
|
||||
|
||||
await insertProxyLog({
|
||||
modelRequested: 'gpt-5',
|
||||
billingDetails: { total: 1 },
|
||||
downstreamApiKeyId: 12,
|
||||
});
|
||||
|
||||
expect(dbInsertValuesMock).toHaveBeenCalledTimes(3);
|
||||
expect(dbInsertValuesMock.mock.calls[0][0]).toMatchObject({
|
||||
modelRequested: 'gpt-5',
|
||||
billingDetails: JSON.stringify({ total: 1 }),
|
||||
downstreamApiKeyId: 12,
|
||||
});
|
||||
expect(dbInsertValuesMock.mock.calls[1][0]).toMatchObject({
|
||||
modelRequested: 'gpt-5',
|
||||
downstreamApiKeyId: 12,
|
||||
});
|
||||
expect(dbInsertValuesMock.mock.calls[2][0]).toMatchObject({
|
||||
modelRequested: 'gpt-5',
|
||||
});
|
||||
expect(dbInsertValuesMock.mock.calls[2][0].billingDetails).toBeUndefined();
|
||||
expect(dbInsertValuesMock.mock.calls[2][0].downstreamApiKeyId).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { db, schema, hasProxyLogBillingDetailsColumn } from '../db/index.js';
|
||||
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;
|
||||
@@ -25,6 +26,7 @@ function buildProxyLogBaseSelectFields() {
|
||||
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,
|
||||
@@ -108,6 +110,20 @@ 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();
|
||||
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,
|
||||
@@ -131,16 +147,43 @@ export async function insertProxyLog(input: ProxyLogInsertInput): Promise<void>
|
||||
: 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
|
||||
? { ...baseValues, billingDetails: serializedBillingDetails }
|
||||
includeBillingDetails || includeDownstreamApiKeyId
|
||||
? {
|
||||
...baseValues,
|
||||
...(includeBillingDetails ? { billingDetails: serializedBillingDetails } : {}),
|
||||
...(includeDownstreamApiKeyId ? { downstreamApiKeyId: input.downstreamApiKeyId } : {}),
|
||||
}
|
||||
: baseValues,
|
||||
).run();
|
||||
} catch (error) {
|
||||
if (includeBillingDetails && isMissingBillingDetailsColumnError(error)) {
|
||||
await db.insert(schema.proxyLogs).values(baseValues).run();
|
||||
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;
|
||||
|
||||
Reference in New Issue
Block a user