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:
Delicious
2026-03-15 22:00:02 +08:00
committed by GitHub
parent 251348f784
commit a64a51aa0c
44 changed files with 4162 additions and 183 deletions
@@ -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,
+26 -11
View File
@@ -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', () => {
+37
View File
@@ -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();
});
});
+47 -4
View File
@@ -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;