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
@@ -19,7 +19,7 @@ vi.mock('../../services/platforms/index.js', () => ({
type DbModule = typeof import('../../db/index.js');
describe('accounts credential mode', () => {
describe('accounts credential mode', { timeout: 15_000 }, () => {
let app: FastifyInstance;
let db: DbModule['db'];
let schema: DbModule['schema'];
@@ -15,7 +15,7 @@ vi.mock('../../services/platforms/index.js', () => ({
type DbModule = typeof import('../../db/index.js');
describe('accounts rebind-session api', () => {
describe('accounts rebind-session api', { timeout: 15_000 }, () => {
let app: FastifyInstance;
let db: DbModule['db'];
let schema: DbModule['schema'];
@@ -0,0 +1,496 @@
import Fastify, { type FastifyInstance } from 'fastify';
import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest';
import { mkdtempSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { eq } from 'drizzle-orm';
type DbModule = typeof import('../../db/index.js');
describe('downstream api keys routes', () => {
let app: FastifyInstance;
let db: DbModule['db'];
let schema: DbModule['schema'];
let dataDir = '';
beforeAll(async () => {
dataDir = mkdtempSync(join(tmpdir(), 'metapi-downstream-routes-'));
process.env.DATA_DIR = dataDir;
await import('../../db/migrate.js');
const dbModule = await import('../../db/index.js');
const routesModule = await import('./downstreamApiKeys.js');
db = dbModule.db;
schema = dbModule.schema;
app = Fastify();
await app.register(routesModule.downstreamApiKeysRoutes);
});
beforeEach(async () => {
await db.delete(schema.proxyLogs).run();
await db.delete(schema.downstreamApiKeys).run();
await db.delete(schema.tokenRoutes).run();
await db.delete(schema.sites).run();
});
afterAll(async () => {
await app.close();
delete process.env.DATA_DIR;
});
it('creates, updates, resets and deletes downstream api keys', async () => {
const site = await db.insert(schema.sites).values({
name: 'portal-site',
url: 'https://portal.example.com',
status: 'active',
platform: 'openai',
}).returning().get();
const route = await db.insert(schema.tokenRoutes).values({
modelPattern: 'gpt-5.2',
displayName: 'portal-route',
enabled: true,
}).returning().get();
const createRes = await app.inject({
method: 'POST',
url: '/api/downstream-keys',
payload: {
name: 'portal-key',
key: 'sk-portal-key-001',
description: 'portal consumer',
groupName: '项目A',
tags: ['移动端', 'VIP'],
enabled: true,
maxCost: 12.5,
maxRequests: 500,
supportedModels: ['gpt-5.2', 'claude-sonnet-4-5'],
allowedRouteIds: [route.id],
siteWeightMultipliers: { [site.id]: 1.2 },
},
});
expect(createRes.statusCode).toBe(200);
const createdBody = createRes.json();
expect(createdBody.success).toBe(true);
expect(createdBody.item).toMatchObject({
name: 'portal-key',
keyMasked: expect.any(String),
groupName: '项目A',
tags: ['移动端', 'VIP'],
maxCost: 12.5,
maxRequests: 500,
supportedModels: ['gpt-5.2', 'claude-sonnet-4-5'],
allowedRouteIds: [route.id],
});
const keyId = createdBody.item.id as number;
const updateRes = await app.inject({
method: 'PUT',
url: `/api/downstream-keys/${keyId}`,
payload: {
name: 'portal-key-updated',
key: 'sk-portal-key-001',
groupName: '项目B',
tags: ['批量候选', 'VIP'],
enabled: false,
maxCost: 20,
maxRequests: 900,
},
});
expect(updateRes.statusCode).toBe(200);
expect(updateRes.json()).toMatchObject({
success: true,
item: {
id: keyId,
name: 'portal-key-updated',
groupName: '项目B',
tags: ['批量候选', 'VIP'],
enabled: false,
maxCost: 20,
maxRequests: 900,
},
});
await db.update(schema.downstreamApiKeys).set({
usedCost: 5.5,
usedRequests: 123,
}).where(eq(schema.downstreamApiKeys.id, keyId)).run();
const resetRes = await app.inject({
method: 'POST',
url: `/api/downstream-keys/${keyId}/reset-usage`,
});
expect(resetRes.statusCode).toBe(200);
expect(resetRes.json()).toMatchObject({ success: true });
const resetRow = await db.select().from(schema.downstreamApiKeys)
.where(eq(schema.downstreamApiKeys.id, keyId))
.get();
expect(resetRow?.usedCost).toBe(0);
expect(resetRow?.usedRequests).toBe(0);
const deleteRes = await app.inject({
method: 'DELETE',
url: `/api/downstream-keys/${keyId}`,
});
expect(deleteRes.statusCode).toBe(200);
expect(deleteRes.json()).toMatchObject({ success: true });
const listRes = await app.inject({
method: 'GET',
url: '/api/downstream-keys',
});
expect(listRes.statusCode).toBe(200);
expect(listRes.json()).toMatchObject({ success: true, items: [] });
});
it('supports batch enable/disable/reset/delete operations', async () => {
const inserted = await db.insert(schema.downstreamApiKeys).values([
{
name: 'batch-a',
key: 'sk-batch-a-001',
enabled: true,
usedCost: 1.2,
usedRequests: 12,
},
{
name: 'batch-b',
key: 'sk-batch-b-001',
enabled: false,
usedCost: 2.4,
usedRequests: 24,
},
]).returning().all();
const disableRes = await app.inject({
method: 'POST',
url: '/api/downstream-keys/batch',
payload: {
ids: [inserted[0].id, inserted[1].id],
action: 'disable',
},
});
expect(disableRes.statusCode).toBe(200);
expect(disableRes.json()).toMatchObject({
success: true,
successIds: [inserted[0].id, inserted[1].id],
failedItems: [],
});
const disabledRows = await db.select().from(schema.downstreamApiKeys).all();
expect(disabledRows.every((row) => row.enabled === false)).toBe(true);
const resetRes = await app.inject({
method: 'POST',
url: '/api/downstream-keys/batch',
payload: {
ids: [inserted[0].id, inserted[1].id],
action: 'resetUsage',
},
});
expect(resetRes.statusCode).toBe(200);
const resetRows = await db.select().from(schema.downstreamApiKeys).all();
expect(resetRows.every((row) => Number(row.usedCost) === 0 && Number(row.usedRequests) === 0)).toBe(true);
const deleteRes = await app.inject({
method: 'POST',
url: '/api/downstream-keys/batch',
payload: {
ids: [inserted[0].id, inserted[1].id],
action: 'delete',
},
});
expect(deleteRes.statusCode).toBe(200);
expect(await db.select().from(schema.downstreamApiKeys).all()).toHaveLength(0);
});
it('supports batch metadata update for group and tags', async () => {
const inserted = await db.insert(schema.downstreamApiKeys).values([
{
name: 'meta-a',
key: 'sk-meta-a-001',
groupName: '旧分组',
tags: JSON.stringify(['旧标签']),
enabled: true,
},
{
name: 'meta-b',
key: 'sk-meta-b-001',
tags: JSON.stringify(['旧标签', '公共']),
enabled: true,
},
]).returning().all();
const res = await app.inject({
method: 'POST',
url: '/api/downstream-keys/batch',
payload: {
ids: inserted.map((item) => item.id),
action: 'updateMetadata',
groupOperation: 'set',
groupName: '新分组',
tagOperation: 'append',
tags: ['项目A', '公共'],
},
});
expect(res.statusCode).toBe(200);
expect(res.json()).toMatchObject({
success: true,
successIds: inserted.map((item) => item.id),
failedItems: [],
});
const rows = await db.select().from(schema.downstreamApiKeys).all();
expect(rows).toHaveLength(2);
for (const row of rows) {
expect(row.groupName).toBe('新分组');
}
const views = rows.map((row) => ({
id: row.id,
groupName: row.groupName,
tags: JSON.parse(String(row.tags || '[]')),
}));
expect(views).toContainEqual(expect.objectContaining({ tags: ['旧标签', '项目A', '公共'] }));
expect(views).toContainEqual(expect.objectContaining({ tags: ['旧标签', '公共', '项目A'] }));
});
it('rejects duplicate key creation and invalid batch action', async () => {
const firstRes = await app.inject({
method: 'POST',
url: '/api/downstream-keys',
payload: {
name: 'dup-a',
key: 'sk-dup-key-001',
},
});
expect(firstRes.statusCode).toBe(200);
const duplicateRes = await app.inject({
method: 'POST',
url: '/api/downstream-keys',
payload: {
name: 'dup-b',
key: 'sk-dup-key-001',
},
});
expect(duplicateRes.statusCode).toBe(409);
expect(duplicateRes.json()).toMatchObject({
success: false,
message: 'API key 已存在',
});
const invalidBatchRes = await app.inject({
method: 'POST',
url: '/api/downstream-keys/batch',
payload: {
ids: [999],
action: 'archive',
},
});
expect(invalidBatchRes.statusCode).toBe(400);
expect(invalidBatchRes.json()).toMatchObject({
success: false,
message: 'Invalid action',
});
});
it('returns summary, overview and trend aggregated from proxy logs', async () => {
const inserted = await db.insert(schema.downstreamApiKeys).values({
name: 'analytics-key',
key: 'sk-analytics-key-001',
enabled: true,
description: 'analytics',
usedCost: 0,
usedRequests: 0,
}).returning().get();
const now = Date.now();
const within24h = new Date(now - 2 * 60 * 60 * 1000).toISOString();
const within7d = new Date(now - 2 * 24 * 60 * 60 * 1000).toISOString();
const outside7d = new Date(now - 10 * 24 * 60 * 60 * 1000).toISOString();
await db.insert(schema.proxyLogs).values([
{
downstreamApiKeyId: inserted.id,
status: 'success',
totalTokens: 1200,
estimatedCost: 0.12,
createdAt: within24h,
},
{
downstreamApiKeyId: inserted.id,
status: 'failed',
totalTokens: 300,
estimatedCost: 0.03,
createdAt: within24h,
},
{
downstreamApiKeyId: inserted.id,
status: 'success',
totalTokens: 600,
estimatedCost: 0.06,
createdAt: within7d,
},
{
downstreamApiKeyId: inserted.id,
status: 'success',
totalTokens: 900,
estimatedCost: 0.09,
createdAt: outside7d,
},
]).run();
const summaryRes = await app.inject({
method: 'GET',
url: '/api/downstream-keys/summary?range=24h&status=enabled&search=analytics',
});
expect(summaryRes.statusCode).toBe(200);
expect(summaryRes.json()).toMatchObject({
success: true,
range: '24h',
status: 'enabled',
search: 'analytics',
group: '',
tags: [],
tagMatch: 'any',
items: [
{
id: inserted.id,
name: 'analytics-key',
rangeUsage: {
totalRequests: 2,
successRequests: 1,
failedRequests: 1,
successRate: 50,
totalTokens: 1500,
totalCost: 0.15,
},
},
],
});
const overviewRes = await app.inject({
method: 'GET',
url: `/api/downstream-keys/${inserted.id}/overview`,
});
expect(overviewRes.statusCode).toBe(200);
expect(overviewRes.json()).toMatchObject({
success: true,
item: { id: inserted.id, name: 'analytics-key' },
usage: {
last24h: {
totalRequests: 2,
successRequests: 1,
failedRequests: 1,
totalTokens: 1500,
totalCost: 0.15,
},
last7d: {
totalRequests: 3,
successRequests: 2,
failedRequests: 1,
totalTokens: 2100,
totalCost: 0.21,
},
all: {
totalRequests: 4,
successRequests: 3,
failedRequests: 1,
totalTokens: 3000,
totalCost: 0.3,
},
},
});
const trendRes = await app.inject({
method: 'GET',
url: `/api/downstream-keys/${inserted.id}/trend?range=all`,
});
expect(trendRes.statusCode).toBe(200);
const trendBody = trendRes.json();
expect(trendBody.success).toBe(true);
expect(trendBody.range).toBe('all');
expect(trendBody.item).toMatchObject({ id: inserted.id, name: 'analytics-key' });
expect(Array.isArray(trendBody.buckets)).toBe(true);
expect(trendBody.buckets.length).toBeGreaterThanOrEqual(3);
expect(trendBody.buckets.some((bucket: any) => bucket.totalTokens === 1500)).toBe(true);
expect(trendBody.buckets.some((bucket: any) => bucket.totalTokens === 600)).toBe(true);
expect(trendBody.buckets.some((bucket: any) => bucket.totalTokens === 900)).toBe(true);
});
it('rejects unknown route ids and site ids in downstream policy payloads', async () => {
const createRes = await app.inject({
method: 'POST',
url: '/api/downstream-keys',
payload: {
name: 'invalid-policy',
key: 'sk-invalid-policy-001',
allowedRouteIds: [999],
},
});
expect(createRes.statusCode).toBe(400);
expect(createRes.json()).toMatchObject({
success: false,
message: 'allowedRouteIds 包含不存在的路由: 999',
});
const site = await db.insert(schema.sites).values({
name: 'site-a',
url: 'https://example.com',
status: 'active',
platform: 'openai',
}).returning().get();
const route = await db.insert(schema.tokenRoutes).values({
modelPattern: 'gpt-5.2',
enabled: true,
}).returning().get();
const created = await app.inject({
method: 'POST',
url: '/api/downstream-keys',
payload: {
name: 'valid-policy',
key: 'sk-valid-policy-001',
allowedRouteIds: [route.id],
siteWeightMultipliers: { [site.id]: 1.2 },
},
});
expect(created.statusCode).toBe(200);
const keyId = created.json().item.id as number;
const updateRes = await app.inject({
method: 'PUT',
url: `/api/downstream-keys/${keyId}`,
payload: {
siteWeightMultipliers: { 999: 1.5 },
},
});
expect(updateRes.statusCode).toBe(400);
expect(updateRes.json()).toMatchObject({
success: false,
message: 'siteWeightMultipliers 包含不存在的站点: 999',
});
const filteredSummaryRes = await app.inject({
method: 'GET',
url: '/api/downstream-keys/summary?range=all&group=__ungrouped__&tags=foo,bar&tagMatch=all',
});
expect(filteredSummaryRes.statusCode).toBe(200);
expect(filteredSummaryRes.json()).toMatchObject({
success: true,
range: 'all',
group: '__ungrouped__',
tags: ['foo', 'bar'],
tagMatch: 'all',
items: [],
});
});
});
+485 -6
View File
@@ -1,6 +1,6 @@
import { FastifyInstance } from 'fastify';
import { eq } from 'drizzle-orm';
import { db, schema } from '../../db/index.js';
import { and, eq, inArray, sql, type SQL } from 'drizzle-orm';
import { db, hasProxyLogDownstreamApiKeyIdColumn, runtimeDbDialect, schema } from '../../db/index.js';
import {
getDownstreamApiKeyById,
listDownstreamApiKeys,
@@ -8,6 +8,7 @@ import {
toDownstreamApiKeyPolicyView,
toPersistenceJson,
} from '../../services/downstreamApiKeyService.js';
import { formatUtcSqlDateTime } from '../../services/localTimeService.js';
function parseRouteId(raw: string): number | null {
const id = Number.parseInt(raw, 10);
@@ -24,7 +25,371 @@ function looksLikeUniqueViolation(error: unknown): boolean {
return message.includes('UNIQUE constraint failed') && message.includes('downstream_api_keys.key');
}
function normalizeBatchIds(raw: unknown): number[] {
const values = Array.isArray(raw) ? raw : [];
const ids: number[] = [];
for (const item of values) {
const parsed = Number(item);
if (!Number.isFinite(parsed)) continue;
const id = Math.trunc(parsed);
if (id <= 0 || ids.includes(id)) continue;
ids.push(id);
if (ids.length >= 500) break;
}
return ids;
}
type DownstreamKeyRange = '24h' | '7d' | 'all';
type DownstreamKeyStatus = 'all' | 'enabled' | 'disabled';
function normalizeDownstreamKeyRange(raw: unknown): DownstreamKeyRange {
const value = typeof raw === 'string' ? raw.trim().toLowerCase() : '';
if (value === '24h') return '24h';
if (value === '7d') return '7d';
if (value === 'all') return 'all';
return '24h';
}
function normalizeDownstreamKeyStatus(raw: unknown): DownstreamKeyStatus {
const value = typeof raw === 'string' ? raw.trim().toLowerCase() : '';
if (value === 'enabled') return 'enabled';
if (value === 'disabled') return 'disabled';
return 'all';
}
function normalizeSearchQuery(raw: unknown): string {
const value = typeof raw === 'string' ? raw.trim() : '';
if (!value) return '';
return value.slice(0, 80);
}
function normalizeGroupQuery(raw: unknown): string {
const value = typeof raw === 'string' ? raw.trim() : '';
return value.slice(0, 64);
}
function normalizeTagQuery(raw: unknown): string[] {
const value = typeof raw === 'string' ? raw : Array.isArray(raw) ? raw.join(',') : '';
return value
.split(/[\r\n,,]+/g)
.map((item) => item.trim())
.filter(Boolean)
.map((item) => item.slice(0, 32))
.filter((item, index, arr) => arr.findIndex((candidate) => candidate.toLowerCase() === item.toLowerCase()) === index)
.slice(0, 20);
}
function normalizeTagMatchMode(raw: unknown): 'any' | 'all' {
const value = typeof raw === 'string' ? raw.trim().toLowerCase() : '';
return value === 'all' ? 'all' : 'any';
}
function resolveRangeSinceUtc(range: DownstreamKeyRange): string | null {
const nowTs = Date.now();
if (range === '24h') return formatUtcSqlDateTime(new Date(nowTs - 24 * 60 * 60 * 1000));
if (range === '7d') return formatUtcSqlDateTime(new Date(nowTs - 7 * 24 * 60 * 60 * 1000));
return null;
}
function resolveBucketSeconds(range: DownstreamKeyRange): number {
return range === 'all' ? 86400 : 3600;
}
function resolveBucketTsExpression(bucketSeconds: number) {
if (runtimeDbDialect === 'mysql') {
return sql<number>`floor(unix_timestamp(${schema.proxyLogs.createdAt}) / ${bucketSeconds}) * ${bucketSeconds}`;
}
if (runtimeDbDialect === 'postgres') {
if (bucketSeconds === 86400) {
return sql<number>`extract(epoch from date_trunc('day', ${schema.proxyLogs.createdAt}))::bigint`;
}
return sql<number>`extract(epoch from date_trunc('hour', ${schema.proxyLogs.createdAt}))::bigint`;
}
// sqlite
return sql<number>`cast(cast(strftime('%s', ${schema.proxyLogs.createdAt}) as integer) / ${bucketSeconds} as integer) * ${bucketSeconds}`;
}
async function validatePolicyReferences(input: {
allowedRouteIds: number[];
siteWeightMultipliers: Record<number, number>;
}): Promise<string | null> {
const routeIds = input.allowedRouteIds || [];
if (routeIds.length > 0) {
const rows = await db.select({ id: schema.tokenRoutes.id })
.from(schema.tokenRoutes)
.where(inArray(schema.tokenRoutes.id, routeIds))
.all();
const existingIds = new Set(rows.map((row) => Number(row.id)));
const missingIds = routeIds.filter((id) => !existingIds.has(id));
if (missingIds.length > 0) {
return `allowedRouteIds 包含不存在的路由: ${missingIds.join(', ')}`;
}
}
const siteIds = Object.keys(input.siteWeightMultipliers || {})
.map((key) => Number(key))
.filter((value) => Number.isFinite(value) && value > 0)
.map((value) => Math.trunc(value));
if (siteIds.length > 0) {
const rows = await db.select({ id: schema.sites.id })
.from(schema.sites)
.where(inArray(schema.sites.id, siteIds))
.all();
const existingIds = new Set(rows.map((row) => Number(row.id)));
const missingIds = siteIds.filter((id) => !existingIds.has(id));
if (missingIds.length > 0) {
return `siteWeightMultipliers 包含不存在的站点: ${missingIds.join(', ')}`;
}
}
return null;
}
export async function downstreamApiKeysRoutes(app: FastifyInstance) {
app.get<{ Querystring: { range?: string; status?: string; search?: string; group?: string; tags?: string | string[]; tagMatch?: string } }>('/api/downstream-keys/summary', async (request) => {
const range = normalizeDownstreamKeyRange(request.query?.range);
const status = normalizeDownstreamKeyStatus(request.query?.status);
const search = normalizeSearchQuery(request.query?.search);
const group = normalizeGroupQuery(request.query?.group);
const tags = normalizeTagQuery(request.query?.tags);
const tagMatch = normalizeTagMatchMode(request.query?.tagMatch);
const whereClauses: SQL[] = [];
if (status === 'enabled') {
whereClauses.push(eq(schema.downstreamApiKeys.enabled, true));
} else if (status === 'disabled') {
whereClauses.push(eq(schema.downstreamApiKeys.enabled, false));
}
if (search) {
const pattern = `%${search.toLowerCase()}%`;
whereClauses.push(sql`(lower(${schema.downstreamApiKeys.name}) like ${pattern} or lower(coalesce(${schema.downstreamApiKeys.description}, '')) like ${pattern})`);
}
let keysQuery = db.select().from(schema.downstreamApiKeys);
if (whereClauses.length > 0) {
keysQuery = keysQuery.where(and(...whereClauses));
}
const keys = (await keysQuery.all())
.map((row) => toDownstreamApiKeyPolicyView(row))
.filter((item) => {
if (group === '__ungrouped__') {
if (item.groupName) return false;
} else if (group && item.groupName !== group) {
return false;
}
if (!search && tags.length === 0) return true;
const haystack = [
item.name,
item.description || '',
item.keyMasked,
item.groupName || '',
...item.tags,
...item.supportedModels,
].join(' ').toLowerCase();
if (search && !haystack.includes(search.toLowerCase())) return false;
if (tags.length === 0) return true;
const itemTags = new Set(item.tags.map((tag) => tag.toLowerCase()));
return tagMatch === 'all'
? tags.every((tag) => itemTags.has(tag.toLowerCase()))
: tags.some((tag) => itemTags.has(tag.toLowerCase()));
})
.sort((a, b) => b.id - a.id);
if (keys.length === 0) {
return { success: true, range, status, search, group, tags, tagMatch, items: [] };
}
const columnReady = await hasProxyLogDownstreamApiKeyIdColumn();
const sinceUtc = resolveRangeSinceUtc(range);
const ids = keys.map((k) => k.id);
const usageRows = columnReady
? await db.select({
keyId: schema.proxyLogs.downstreamApiKeyId,
totalRequests: sql<number>`count(*)`,
successRequests: sql<number>`coalesce(sum(case when ${schema.proxyLogs.status} = 'success' then 1 else 0 end), 0)`,
failedRequests: sql<number>`coalesce(sum(case when ${schema.proxyLogs.status} = 'success' then 0 else 1 end), 0)`,
totalTokens: sql<number>`coalesce(sum(coalesce(${schema.proxyLogs.totalTokens}, 0)), 0)`,
totalCost: sql<number>`coalesce(sum(coalesce(${schema.proxyLogs.estimatedCost}, 0)), 0)`,
})
.from(schema.proxyLogs)
.where(and(
inArray(schema.proxyLogs.downstreamApiKeyId, ids),
...(sinceUtc ? [sql`${schema.proxyLogs.createdAt} >= ${sinceUtc}`] : []),
))
.groupBy(schema.proxyLogs.downstreamApiKeyId)
.all()
: [];
const usageByKey = new Map<number, {
totalRequests: number;
successRequests: number;
failedRequests: number;
totalTokens: number;
totalCost: number;
}>();
for (const row of usageRows) {
const keyId = Number((row as any).keyId ?? 0);
if (!Number.isFinite(keyId) || keyId <= 0) continue;
usageByKey.set(keyId, {
totalRequests: Number((row as any).totalRequests || 0),
successRequests: Number((row as any).successRequests || 0),
failedRequests: Number((row as any).failedRequests || 0),
totalTokens: Number((row as any).totalTokens || 0),
totalCost: Number((row as any).totalCost || 0),
});
}
return {
success: true,
range,
status,
search,
group,
tags,
tagMatch,
items: keys.map((key) => {
const usage = usageByKey.get(key.id) || {
totalRequests: 0,
successRequests: 0,
failedRequests: 0,
totalTokens: 0,
totalCost: 0,
};
const successRate = usage.totalRequests > 0
? Math.round((usage.successRequests / usage.totalRequests) * 1000) / 10
: null;
return {
...key,
rangeUsage: {
totalRequests: usage.totalRequests,
successRequests: usage.successRequests,
failedRequests: usage.failedRequests,
successRate,
totalTokens: usage.totalTokens,
totalCost: Math.round(usage.totalCost * 1_000_000) / 1_000_000,
},
};
}),
};
});
app.get<{ Params: { id: string } }>('/api/downstream-keys/:id/overview', async (request, reply) => {
const id = parseRouteId(request.params.id);
if (!id) {
return reply.code(400).send({ success: false, message: 'id 无效' });
}
const item = await getDownstreamApiKeyById(id);
if (!item) {
return reply.code(404).send({ success: false, message: 'API key 不存在' });
}
const columnReady = await hasProxyLogDownstreamApiKeyIdColumn();
if (!columnReady) {
return { success: true, item, usage: { last24h: null, last7d: null, all: null } };
}
const readAggregate = async (range: DownstreamKeyRange) => {
const sinceUtc = resolveRangeSinceUtc(range);
const row = await db.select({
totalRequests: sql<number>`count(*)`,
successRequests: sql<number>`coalesce(sum(case when ${schema.proxyLogs.status} = 'success' then 1 else 0 end), 0)`,
failedRequests: sql<number>`coalesce(sum(case when ${schema.proxyLogs.status} = 'success' then 0 else 1 end), 0)`,
totalTokens: sql<number>`coalesce(sum(coalesce(${schema.proxyLogs.totalTokens}, 0)), 0)`,
totalCost: sql<number>`coalesce(sum(coalesce(${schema.proxyLogs.estimatedCost}, 0)), 0)`,
})
.from(schema.proxyLogs)
.where(and(
eq(schema.proxyLogs.downstreamApiKeyId, id),
...(sinceUtc ? [sql`${schema.proxyLogs.createdAt} >= ${sinceUtc}`] : []),
))
.get();
const totalRequests = Number((row as any)?.totalRequests || 0);
const successRequests = Number((row as any)?.successRequests || 0);
const totalCost = Number((row as any)?.totalCost || 0);
return {
totalRequests,
successRequests,
failedRequests: Number((row as any)?.failedRequests || 0),
successRate: totalRequests > 0 ? Math.round((successRequests / totalRequests) * 1000) / 10 : null,
totalTokens: Number((row as any)?.totalTokens || 0),
totalCost: Math.round(totalCost * 1_000_000) / 1_000_000,
};
};
const [last24h, last7d, all] = await Promise.all([
readAggregate('24h'),
readAggregate('7d'),
readAggregate('all'),
]);
return { success: true, item, usage: { last24h, last7d, all } };
});
app.get<{ Params: { id: string }; Querystring: { range?: string } }>('/api/downstream-keys/:id/trend', async (request, reply) => {
const id = parseRouteId(request.params.id);
if (!id) {
return reply.code(400).send({ success: false, message: 'id 无效' });
}
const range = normalizeDownstreamKeyRange(request.query?.range);
const item = await getDownstreamApiKeyById(id);
if (!item) {
return reply.code(404).send({ success: false, message: 'API key 不存在' });
}
const columnReady = await hasProxyLogDownstreamApiKeyIdColumn();
if (!columnReady) {
return { success: true, range, item: { id: item.id, name: item.name }, buckets: [] };
}
const bucketSeconds = resolveBucketSeconds(range);
const bucketTs = resolveBucketTsExpression(bucketSeconds);
const sinceUtc = resolveRangeSinceUtc(range);
const rows = await db.select({
bucketTs,
totalRequests: sql<number>`count(*)`,
successRequests: sql<number>`coalesce(sum(case when ${schema.proxyLogs.status} = 'success' then 1 else 0 end), 0)`,
failedRequests: sql<number>`coalesce(sum(case when ${schema.proxyLogs.status} = 'success' then 0 else 1 end), 0)`,
totalTokens: sql<number>`coalesce(sum(coalesce(${schema.proxyLogs.totalTokens}, 0)), 0)`,
totalCost: sql<number>`coalesce(sum(coalesce(${schema.proxyLogs.estimatedCost}, 0)), 0)`,
})
.from(schema.proxyLogs)
.where(and(
eq(schema.proxyLogs.downstreamApiKeyId, id),
...(sinceUtc ? [sql`${schema.proxyLogs.createdAt} >= ${sinceUtc}`] : []),
))
.groupBy(bucketTs)
.orderBy(bucketTs)
.all();
return {
success: true,
range,
item: { id: item.id, name: item.name },
bucketSeconds,
buckets: rows.map((row: any) => {
const tsSeconds = Number(row.bucketTs || 0);
const totalRequests = Number(row.totalRequests || 0);
const successRequests = Number(row.successRequests || 0);
return {
startUtc: tsSeconds > 0 ? new Date(tsSeconds * 1000).toISOString() : null,
totalRequests,
successRequests,
failedRequests: Number(row.failedRequests || 0),
successRate: totalRequests > 0 ? Math.round((successRequests / totalRequests) * 1000) / 10 : null,
totalTokens: Number(row.totalTokens || 0),
totalCost: Math.round(Number(row.totalCost || 0) * 1_000_000) / 1_000_000,
};
}),
};
});
app.get('/api/downstream-keys', async () => {
return {
success: true,
@@ -37,6 +402,8 @@ export async function downstreamApiKeysRoutes(app: FastifyInstance) {
name?: unknown;
key?: unknown;
description?: unknown;
groupName?: unknown;
tags?: unknown;
enabled?: unknown;
expiresAt?: unknown;
maxCost?: unknown;
@@ -62,6 +429,13 @@ export async function downstreamApiKeysRoutes(app: FastifyInstance) {
if (!validateKeyShape(normalized.key)) {
return reply.code(400).send({ success: false, message: 'key 必须以 sk- 开头且长度至少 6' });
}
const policyRefError = await validatePolicyReferences({
allowedRouteIds: normalized.allowedRouteIds,
siteWeightMultipliers: normalized.siteWeightMultipliers,
});
if (policyRefError) {
return reply.code(400).send({ success: false, message: policyRefError });
}
const nowIso = new Date().toISOString();
@@ -70,6 +444,8 @@ export async function downstreamApiKeysRoutes(app: FastifyInstance) {
name: normalized.name,
key: normalized.key,
description: normalized.description,
groupName: normalized.groupName,
tags: toPersistenceJson(normalized.tags),
enabled: normalized.enabled,
expiresAt: normalized.expiresAt,
maxCost: normalized.maxCost,
@@ -111,6 +487,8 @@ export async function downstreamApiKeysRoutes(app: FastifyInstance) {
name?: unknown;
key?: unknown;
description?: unknown;
groupName?: unknown;
tags?: unknown;
enabled?: unknown;
expiresAt?: unknown;
maxCost?: unknown;
@@ -140,6 +518,8 @@ export async function downstreamApiKeysRoutes(app: FastifyInstance) {
name: request.body?.name ?? existing.name,
key: request.body?.key ?? existing.key,
description: request.body?.description ?? existing.description,
groupName: request.body?.groupName ?? existing.groupName,
tags: request.body?.tags ?? existingView.tags,
enabled: request.body?.enabled ?? existing.enabled,
expiresAt: request.body?.expiresAt ?? existing.expiresAt,
maxCost: request.body?.maxCost ?? existing.maxCost,
@@ -161,6 +541,13 @@ export async function downstreamApiKeysRoutes(app: FastifyInstance) {
if (!validateKeyShape(normalized.key)) {
return reply.code(400).send({ success: false, message: 'key 必须以 sk- 开头且长度至少 6' });
}
const policyRefError = await validatePolicyReferences({
allowedRouteIds: normalized.allowedRouteIds,
siteWeightMultipliers: normalized.siteWeightMultipliers,
});
if (policyRefError) {
return reply.code(400).send({ success: false, message: policyRefError });
}
const nowIso = new Date().toISOString();
try {
@@ -168,6 +555,8 @@ export async function downstreamApiKeysRoutes(app: FastifyInstance) {
name: normalized.name,
key: normalized.key,
description: normalized.description,
groupName: normalized.groupName,
tags: toPersistenceJson(normalized.tags),
enabled: normalized.enabled,
expiresAt: normalized.expiresAt,
maxCost: normalized.maxCost,
@@ -178,7 +567,7 @@ export async function downstreamApiKeysRoutes(app: FastifyInstance) {
updatedAt: nowIso,
}).where(eq(schema.downstreamApiKeys.id, id)).run();
const updated = getDownstreamApiKeyById(id);
const updated = await getDownstreamApiKeyById(id);
return {
success: true,
item: updated,
@@ -197,7 +586,7 @@ export async function downstreamApiKeysRoutes(app: FastifyInstance) {
return reply.code(400).send({ success: false, message: 'id 无效' });
}
const existing = getDownstreamApiKeyById(id);
const existing = await getDownstreamApiKeyById(id);
if (!existing) {
return reply.code(404).send({ success: false, message: 'API key 不存在' });
}
@@ -210,7 +599,7 @@ export async function downstreamApiKeysRoutes(app: FastifyInstance) {
return {
success: true,
item: getDownstreamApiKeyById(id),
item: await getDownstreamApiKeyById(id),
};
});
@@ -220,7 +609,7 @@ export async function downstreamApiKeysRoutes(app: FastifyInstance) {
return reply.code(400).send({ success: false, message: 'id 无效' });
}
const existing = getDownstreamApiKeyById(id);
const existing = await getDownstreamApiKeyById(id);
if (!existing) {
return reply.code(404).send({ success: false, message: 'API key 不存在' });
}
@@ -231,4 +620,94 @@ export async function downstreamApiKeysRoutes(app: FastifyInstance) {
return { success: true };
});
app.post<{ Body?: {
ids?: number[];
action?: string;
groupOperation?: string;
groupName?: unknown;
tagOperation?: string;
tags?: unknown;
} }>('/api/downstream-keys/batch', async (request, reply) => {
const ids = normalizeBatchIds(request.body?.ids);
const action = String(request.body?.action || '').trim();
if (ids.length === 0) {
return reply.code(400).send({ success: false, message: 'ids is required' });
}
if (!['enable', 'disable', 'delete', 'resetUsage', 'updateMetadata'].includes(action)) {
return reply.code(400).send({ success: false, message: 'Invalid action' });
}
const groupOperation = String(request.body?.groupOperation || 'keep').trim();
const tagOperation = String(request.body?.tagOperation || 'keep').trim();
const normalizedGroupName = normalizeDownstreamApiKeyPayload({ groupName: request.body?.groupName }).groupName;
const normalizedTags = normalizeDownstreamApiKeyPayload({ tags: request.body?.tags }).tags;
if (action === 'updateMetadata') {
if (!['keep', 'set', 'clear'].includes(groupOperation)) {
return reply.code(400).send({ success: false, message: 'Invalid groupOperation' });
}
if (!['keep', 'append'].includes(tagOperation)) {
return reply.code(400).send({ success: false, message: 'Invalid tagOperation' });
}
if (groupOperation === 'set' && !normalizedGroupName) {
return reply.code(400).send({ success: false, message: 'groupName is required when groupOperation=set' });
}
if (tagOperation === 'append' && normalizedTags.length === 0) {
return reply.code(400).send({ success: false, message: 'tags is required when tagOperation=append' });
}
}
const successIds: number[] = [];
const failedItems: Array<{ id: number; message: string }> = [];
for (const id of ids) {
try {
const existing = await getDownstreamApiKeyById(id);
if (!existing) {
failedItems.push({ id, message: 'API key 不存在' });
continue;
}
if (action === 'delete') {
await db.delete(schema.downstreamApiKeys)
.where(eq(schema.downstreamApiKeys.id, id))
.run();
} else if (action === 'resetUsage') {
await db.update(schema.downstreamApiKeys).set({
usedCost: 0,
usedRequests: 0,
updatedAt: new Date().toISOString(),
}).where(eq(schema.downstreamApiKeys.id, id)).run();
} else if (action === 'updateMetadata') {
const nextGroupName = groupOperation === 'keep'
? existing.groupName
: (groupOperation === 'clear' ? null : normalizedGroupName);
const nextTags = tagOperation === 'append'
? Array.from(new Map([...existing.tags, ...normalizedTags].map((tag) => [tag.toLowerCase(), tag])).values())
: existing.tags;
await db.update(schema.downstreamApiKeys).set({
groupName: nextGroupName,
tags: toPersistenceJson(nextTags),
updatedAt: new Date().toISOString(),
}).where(eq(schema.downstreamApiKeys.id, id)).run();
} else {
await db.update(schema.downstreamApiKeys).set({
enabled: action === 'enable',
updatedAt: new Date().toISOString(),
}).where(eq(schema.downstreamApiKeys.id, id)).run();
}
successIds.push(id);
} catch (error: any) {
failedItems.push({ id, message: error?.message || 'Batch operation failed' });
}
}
return {
success: true,
successIds,
failedItems,
};
});
}
@@ -29,6 +29,7 @@ describe('stats proxy logs routes', () => {
beforeEach(async () => {
await db.delete(schema.proxyLogs).run();
await db.delete(schema.downstreamApiKeys).run();
await db.delete(schema.accounts).run();
await db.delete(schema.sites).run();
});
@@ -52,6 +53,14 @@ describe('stats proxy logs routes', () => {
status: 'active',
}).returning().get();
const downstreamKey = await db.insert(schema.downstreamApiKeys).values({
name: '项目A-Key',
key: 'sk-project-a-001',
groupName: '项目A',
tags: JSON.stringify(['VIP', '灰度']),
enabled: true,
}).returning().get();
const timestamps = [
formatUtcSqlDateTime(new Date('2026-03-09T08:00:00.000Z')),
formatUtcSqlDateTime(new Date('2026-03-09T08:01:00.000Z')),
@@ -62,6 +71,7 @@ describe('stats proxy logs routes', () => {
await db.insert(schema.proxyLogs).values([
{
accountId: account.id,
downstreamApiKeyId: downstreamKey.id,
modelRequested: 'gpt-4o',
modelActual: 'gpt-4o',
status: 'success',
@@ -74,6 +84,7 @@ describe('stats proxy logs routes', () => {
},
{
accountId: account.id,
downstreamApiKeyId: downstreamKey.id,
modelRequested: 'gpt-4o-mini',
modelActual: 'gpt-4o-mini',
status: 'failed',
@@ -136,6 +147,9 @@ describe('stats proxy logs routes', () => {
expect(body.items).toHaveLength(1);
expect(body.items[0]?.modelRequested).toBe('gpt-4o-mini');
expect(body.items[0]?.status).toBe('failed');
expect(body.items[0]?.downstreamKeyName).toBe('项目A-Key');
expect(body.items[0]?.downstreamKeyGroupName).toBe('项目A');
expect(body.items[0]?.downstreamKeyTags).toEqual(['VIP', '灰度']);
expect(body.items[0]).not.toHaveProperty('billingDetails');
expect(body.summary).toEqual({
totalCount: 3,
@@ -160,8 +174,17 @@ describe('stats proxy logs routes', () => {
status: 'active',
}).returning().get();
const downstreamKey = await db.insert(schema.downstreamApiKeys).values({
name: 'detail-key',
key: 'sk-detail-key-001',
groupName: '测试项目',
tags: JSON.stringify(['回归', '日志']),
enabled: true,
}).returning().get();
const inserted = await db.insert(schema.proxyLogs).values({
accountId: account.id,
downstreamApiKeyId: downstreamKey.id,
modelRequested: 'gpt-5',
modelActual: 'gpt-5',
status: 'success',
@@ -188,18 +211,93 @@ describe('stats proxy logs routes', () => {
id: number;
siteName: string | null;
username: string | null;
downstreamKeyName: string | null;
downstreamKeyGroupName: string | null;
downstreamKeyTags: string[];
billingDetails: Record<string, unknown> | null;
};
expect(body.id).toBe(logId);
expect(body.siteName).toBe('detail-site');
expect(body.username).toBe('detail-user');
expect(body.downstreamKeyName).toBe('detail-key');
expect(body.downstreamKeyGroupName).toBe('测试项目');
expect(body.downstreamKeyTags).toEqual(['回归', '日志']);
expect(body.billingDetails).toMatchObject({
breakdown: { totalCost: 0.12 },
usage: { promptTokens: 100, completionTokens: 20 },
});
});
it('supports searching proxy logs by downstream key metadata', async () => {
const site = await db.insert(schema.sites).values({
name: 'meta-site',
url: 'https://meta.example.com',
platform: 'new-api',
}).returning().get();
const account = await db.insert(schema.accounts).values({
siteId: site.id,
username: 'meta-user',
accessToken: 'meta-token',
status: 'active',
}).returning().get();
const alphaKey = await db.insert(schema.downstreamApiKeys).values({
name: '渠道-A',
key: 'sk-channel-a',
groupName: '项目甲',
tags: JSON.stringify(['商务', 'VIP']),
enabled: true,
}).returning().get();
const betaKey = await db.insert(schema.downstreamApiKeys).values({
name: '渠道-B',
key: 'sk-channel-b',
groupName: '项目乙',
tags: JSON.stringify(['灰度']),
enabled: true,
}).returning().get();
await db.insert(schema.proxyLogs).values([
{
accountId: account.id,
downstreamApiKeyId: alphaKey.id,
modelRequested: 'gpt-4o',
modelActual: 'gpt-4o',
status: 'success',
totalTokens: 12,
estimatedCost: 0.12,
createdAt: formatUtcSqlDateTime(new Date('2026-03-09T10:00:00.000Z')),
},
{
accountId: account.id,
downstreamApiKeyId: betaKey.id,
modelRequested: 'gpt-4.1-mini',
modelActual: 'gpt-4.1-mini',
status: 'success',
totalTokens: 22,
estimatedCost: 0.22,
createdAt: formatUtcSqlDateTime(new Date('2026-03-09T10:05:00.000Z')),
},
]).run();
const response = await app.inject({
method: 'GET',
url: '/api/stats/proxy-logs?search=%E9%A1%B9%E7%9B%AE%E7%94%B2',
});
expect(response.statusCode).toBe(200);
const body = response.json() as {
total: number;
items: Array<Record<string, unknown>>;
};
expect(body.total).toBe(1);
expect(body.items[0]?.downstreamKeyName).toBe('渠道-A');
expect(body.items[0]?.downstreamKeyGroupName).toBe('项目甲');
});
it('filters proxy logs by site and time range', async () => {
const alphaSite = await db.insert(schema.sites).values({
name: 'alpha-site',
+55 -3
View File
@@ -121,12 +121,36 @@ function normalizeProxyLogTimeBoundary(raw?: string): string | null {
return formatUtcSqlDateTime(parsed);
}
function parseDownstreamKeyTags(raw: unknown): string[] {
if (typeof raw !== 'string' || !raw.trim()) return [];
try {
const parsed = JSON.parse(raw);
if (!Array.isArray(parsed)) return [];
const seen = new Set<string>();
const result: string[] = [];
for (const value of parsed) {
const text = String(value || '').trim();
if (!text) continue;
const dedupeKey = text.toLowerCase();
if (seen.has(dedupeKey)) continue;
seen.add(dedupeKey);
result.push(text);
}
return result;
} catch {
return [];
}
}
function buildProxyLogSearchCondition(search: string) {
if (!search) return null;
const likeTerm = `%${search}%`;
return sql<boolean>`(
lower(coalesce(${schema.proxyLogs.modelRequested}, '')) like ${likeTerm}
or lower(coalesce(${schema.proxyLogs.modelActual}, '')) like ${likeTerm}
or lower(coalesce(${schema.downstreamApiKeys.name}, '')) like ${likeTerm}
or lower(coalesce(${schema.downstreamApiKeys.groupName}, '')) like ${likeTerm}
or lower(coalesce(${schema.downstreamApiKeys.tags}, '')) like ${likeTerm}
)`;
}
@@ -316,6 +340,12 @@ function mapProxyLogRow(
proxy_logs: Record<string, unknown> & { billingDetails?: string | null };
accounts: { username?: string | null } | null;
sites: { id?: number | null; name?: string | null; url?: string | null } | null;
downstream_api_keys: {
id?: number | null;
name?: string | null;
groupName?: string | null;
tags?: string | null;
} | null;
},
options?: { includeBillingDetails?: boolean },
) {
@@ -328,6 +358,10 @@ function mapProxyLogRow(
siteId: row.sites?.id || null,
siteName: row.sites?.name || null,
siteUrl: row.sites?.url || null,
downstreamKeyId: row.downstream_api_keys?.id || null,
downstreamKeyName: row.downstream_api_keys?.name || null,
downstreamKeyGroupName: row.downstream_api_keys?.groupName || null,
downstreamKeyTags: parseDownstreamKeyTags(row.downstream_api_keys?.tags),
};
}
@@ -530,9 +564,16 @@ export async function statsRoutes(app: FastifyInstance) {
proxy_logs: fields,
accounts: schema.accounts,
sites: schema.sites,
downstream_api_keys: {
id: schema.downstreamApiKeys.id,
name: schema.downstreamApiKeys.name,
groupName: schema.downstreamApiKeys.groupName,
tags: schema.downstreamApiKeys.tags,
},
}).from(schema.proxyLogs)
.leftJoin(schema.accounts, eq(schema.proxyLogs.accountId, schema.accounts.id))
.leftJoin(schema.sites, eq(schema.accounts.siteId, schema.sites.id));
.leftJoin(schema.sites, eq(schema.accounts.siteId, schema.sites.id))
.leftJoin(schema.downstreamApiKeys, eq(schema.proxyLogs.downstreamApiKeyId, schema.downstreamApiKeys.id));
if (listWhere) {
query = query.where(listWhere) as typeof query;
@@ -547,13 +588,15 @@ export async function statsRoutes(app: FastifyInstance) {
proxy_logs: Record<string, unknown> & { billingDetails?: string | null };
accounts: { username?: string | null } | null;
sites: { id?: number | null; name?: string | null; url?: string | null } | null;
downstream_api_keys: { id?: number | null; name?: string | null; groupName?: string | null; tags?: string | null } | null;
}>;
let totalQuery = db.select({
total: sql<number>`count(*)`,
}).from(schema.proxyLogs)
.leftJoin(schema.accounts, eq(schema.proxyLogs.accountId, schema.accounts.id))
.leftJoin(schema.sites, eq(schema.accounts.siteId, schema.sites.id));
.leftJoin(schema.sites, eq(schema.accounts.siteId, schema.sites.id))
.leftJoin(schema.downstreamApiKeys, eq(schema.proxyLogs.downstreamApiKeyId, schema.downstreamApiKeys.id));
if (listWhere) {
totalQuery = totalQuery.where(listWhere) as typeof totalQuery;
}
@@ -567,7 +610,8 @@ export async function statsRoutes(app: FastifyInstance) {
totalTokensAll: sql<number>`coalesce(sum(coalesce(${schema.proxyLogs.totalTokens}, 0)), 0)`,
}).from(schema.proxyLogs)
.leftJoin(schema.accounts, eq(schema.proxyLogs.accountId, schema.accounts.id))
.leftJoin(schema.sites, eq(schema.accounts.siteId, schema.sites.id));
.leftJoin(schema.sites, eq(schema.accounts.siteId, schema.sites.id))
.leftJoin(schema.downstreamApiKeys, eq(schema.proxyLogs.downstreamApiKeyId, schema.downstreamApiKeys.id));
if (summaryWhere) {
summaryQuery = summaryQuery.where(summaryWhere) as typeof summaryQuery;
}
@@ -599,15 +643,23 @@ export async function statsRoutes(app: FastifyInstance) {
proxy_logs: fields,
accounts: schema.accounts,
sites: schema.sites,
downstream_api_keys: {
id: schema.downstreamApiKeys.id,
name: schema.downstreamApiKeys.name,
groupName: schema.downstreamApiKeys.groupName,
tags: schema.downstreamApiKeys.tags,
},
}).from(schema.proxyLogs)
.leftJoin(schema.accounts, eq(schema.proxyLogs.accountId, schema.accounts.id))
.leftJoin(schema.sites, eq(schema.accounts.siteId, schema.sites.id))
.leftJoin(schema.downstreamApiKeys, eq(schema.proxyLogs.downstreamApiKeyId, schema.downstreamApiKeys.id))
.where(eq(schema.proxyLogs.id, id))
.get()
), { includeBillingDetails: true }) as {
proxy_logs: Record<string, unknown> & { billingDetails?: string | null };
accounts: { username?: string | null } | null;
sites: { id?: number | null; name?: string | null; url?: string | null } | null;
downstream_api_keys: { id?: number | null; name?: string | null; groupName?: string | null; tags?: string | null } | null;
} | undefined;
if (!row) {
+25 -11
View File
@@ -1,6 +1,6 @@
import { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify';
import { tokenRouter } from '../../services/tokenRouter.js';
import { db, schema } from '../../db/index.js';
import { db, hasProxyLogDownstreamApiKeyIdColumn, schema } from '../../db/index.js';
import { fetch } from 'undici';
import { refreshModelsAndRebuildRoutes } from '../../services/modelService.js';
import { reportProxyAllFailed, reportTokenExpired } from '../../services/alertService.js';
@@ -26,7 +26,7 @@ import { resolveProxyLogBilling } from './proxyBilling.js';
import { openAiChatTransformer } from '../../transformers/openai/chat/index.js';
import { anthropicMessagesTransformer } from '../../transformers/anthropic/messages/index.js';
import { detectDownstreamClientContext, type DownstreamClientContext } from './downstreamClientContext.js';
import { getProxyResourceOwner } from '../../middleware/auth.js';
import { getProxyAuthContext, getProxyResourceOwner } from '../../middleware/auth.js';
import {
ProxyInputFileResolutionError,
hasNonImageFileInputInOpenAiBody,
@@ -86,6 +86,9 @@ async function handleChatProxyRequest(
}
}
const hasNonImageFileInput = hasNonImageFileInputInOpenAiBody(resolvedOpenAiBody);
const downstreamApiKeyId = getProxyAuthContext(request)?.keyId ?? null;
const logDownstreamApiKeyId = downstreamApiKeyId !== null
&& await hasProxyLogDownstreamApiKeyIdColumn();
const excludeChannelIds: number[] = [];
let retryCount = 0;
@@ -191,15 +194,16 @@ async function handleChatProxyRequest(
ctx.errText,
retryCount,
downstreamPath,
0,
0,
0,
0,
null,
null,
clientContext,
);
},
0,
0,
0,
0,
null,
null,
clientContext,
logDownstreamApiKeyId ? downstreamApiKeyId : null,
);
},
});
if (!endpointResult.ok) {
@@ -222,6 +226,7 @@ async function handleChatProxyRequest(
null,
null,
clientContext,
logDownstreamApiKeyId ? downstreamApiKeyId : null,
);
if (isTokenExpiredError({ status, message: errText })) {
@@ -340,6 +345,8 @@ async function handleChatProxyRequest(
estimatedCost,
billingDetails,
successfulUpstreamPath,
clientContext,
logDownstreamApiKeyId ? downstreamApiKeyId : null,
);
return;
}
@@ -389,6 +396,8 @@ async function handleChatProxyRequest(
estimatedCost,
billingDetails,
successfulUpstreamPath,
clientContext,
logDownstreamApiKeyId ? downstreamApiKeyId : null,
);
return;
}
@@ -447,6 +456,8 @@ async function handleChatProxyRequest(
estimatedCost,
billingDetails,
successfulUpstreamPath,
clientContext,
logDownstreamApiKeyId ? downstreamApiKeyId : null,
);
return reply.send(downstreamResponse);
@@ -468,6 +479,7 @@ async function handleChatProxyRequest(
null,
null,
clientContext,
logDownstreamApiKeyId ? downstreamApiKeyId : null,
);
if (retryCount < MAX_RETRIES) {
@@ -506,6 +518,7 @@ async function logProxy(
billingDetails: unknown = null,
upstreamPath: string | null = null,
clientContext: DownstreamClientContext | null = null,
downstreamApiKeyId: number | null = null,
) {
try {
const createdAt = formatUtcSqlDateTime(new Date());
@@ -523,6 +536,7 @@ async function logProxy(
routeId: selected.channel.routeId,
channelId: selected.channel.id,
accountId: selected.account.id,
...(downstreamApiKeyId !== null ? { downstreamApiKeyId } : {}),
modelRequested,
modelActual: selected.actualModel,
status,
+30 -24
View File
@@ -1,6 +1,6 @@
import { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
import { fetch } from 'undici';
import { db, schema } from '../../db/index.js';
import { db, hasProxyLogDownstreamApiKeyIdColumn, schema } from '../../db/index.js';
import { tokenRouter } from '../../services/tokenRouter.js';
import { refreshModelsAndRebuildRoutes } from '../../services/modelService.js';
import { reportProxyAllFailed, reportTokenExpired } from '../../services/alertService.js';
@@ -13,6 +13,7 @@ import { withSiteRecordProxyRequestInit } from '../../services/siteProxy.js';
import { composeProxyLogMessage } from './logPathMeta.js';
import { formatUtcSqlDateTime } from '../../services/localTimeService.js';
import { resolveProxyLogBilling } from './proxyBilling.js';
import { getProxyAuthContext } from '../../middleware/auth.js';
import { buildUpstreamUrl } from './upstreamUrl.js';
const MAX_RETRIES = 2;
@@ -26,10 +27,13 @@ export async function completionsProxyRoute(app: FastifyInstance) {
}
if (!await ensureModelAllowedForDownstreamKey(request, reply, requestedModel)) return;
const downstreamPolicy = getDownstreamRoutingPolicy(request);
const downstreamApiKeyId = getProxyAuthContext(request)?.keyId ?? null;
const logDownstreamApiKeyId = downstreamApiKeyId !== null
&& await hasProxyLogDownstreamApiKeyIdColumn();
const isStream = body.stream === true;
const excludeChannelIds: number[] = [];
let retryCount = 0;
const excludeChannelIds: number[] = [];
let retryCount = 0;
while (retryCount <= MAX_RETRIES) {
let selected = retryCount === 0
@@ -67,10 +71,10 @@ export async function completionsProxyRoute(app: FastifyInstance) {
body: JSON.stringify(forwardBody),
}));
if (!upstream.ok) {
const errText = await upstream.text().catch(() => 'unknown error');
tokenRouter.recordFailure(selected.channel.id);
logProxy(selected, requestedModel, 'failed', upstream.status, Date.now() - startTime, errText, retryCount);
if (!upstream.ok) {
const errText = await upstream.text().catch(() => 'unknown error');
tokenRouter.recordFailure(selected.channel.id);
logProxy(selected, requestedModel, 'failed', upstream.status, Date.now() - startTime, errText, retryCount, logDownstreamApiKeyId ? downstreamApiKeyId : null);
if (isTokenExpiredError({ status: upstream.status, message: errText })) {
await reportTokenExpired({
@@ -171,7 +175,7 @@ export async function completionsProxyRoute(app: FastifyInstance) {
tokenRouter.recordSuccess(selected.channel.id, latency, estimatedCost);
recordDownstreamCostUsage(request, estimatedCost);
logProxy(
selected, requestedModel, 'success', 200, latency, null, retryCount,
selected, requestedModel, 'success', 200, latency, null, retryCount, logDownstreamApiKeyId ? downstreamApiKeyId : null,
resolvedUsage.promptTokens, resolvedUsage.completionTokens, resolvedUsage.totalTokens, estimatedCost, billingDetails,
);
return;
@@ -206,17 +210,17 @@ export async function completionsProxyRoute(app: FastifyInstance) {
tokenRouter.recordSuccess(selected.channel.id, latency, estimatedCost);
recordDownstreamCostUsage(request, estimatedCost);
logProxy(
selected, requestedModel, 'success', 200, latency, null, retryCount,
selected, requestedModel, 'success', 200, latency, null, retryCount, logDownstreamApiKeyId ? downstreamApiKeyId : null,
resolvedUsage.promptTokens, resolvedUsage.completionTokens, resolvedUsage.totalTokens, estimatedCost, billingDetails,
);
return reply.send(data);
} catch (err: any) {
tokenRouter.recordFailure(selected.channel.id);
logProxy(selected, requestedModel, 'failed', 0, Date.now() - startTime, err.message, retryCount);
if (retryCount < MAX_RETRIES) {
retryCount++;
continue;
}
} catch (err: any) {
tokenRouter.recordFailure(selected.channel.id);
logProxy(selected, requestedModel, 'failed', 0, Date.now() - startTime, err.message, retryCount, logDownstreamApiKeyId ? downstreamApiKeyId : null);
if (retryCount < MAX_RETRIES) {
retryCount++;
continue;
}
await reportProxyAllFailed({
model: requestedModel,
reason: err.message || 'network failure',
@@ -229,14 +233,15 @@ export async function completionsProxyRoute(app: FastifyInstance) {
});
}
async function logProxy(
selected: any,
modelRequested: string,
status: string,
httpStatus: number,
latencyMs: number,
errorMessage: string | null,
retryCount: number,
async function logProxy(
selected: any,
modelRequested: string,
status: string,
httpStatus: number,
latencyMs: number,
errorMessage: string | null,
retryCount: number,
downstreamApiKeyId: number | null = null,
promptTokens = 0,
completionTokens = 0,
totalTokens = 0,
@@ -253,6 +258,7 @@ async function logProxy(
routeId: selected.channel.routeId,
channelId: selected.channel.id,
accountId: selected.account.id,
...(downstreamApiKeyId !== null ? { downstreamApiKeyId } : {}),
modelRequested,
modelActual: selected.actualModel,
status,
+24 -18
View File
@@ -1,6 +1,6 @@
import { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
import { fetch } from 'undici';
import { db, schema } from '../../db/index.js';
import { db, hasProxyLogDownstreamApiKeyIdColumn, schema } from '../../db/index.js';
import { tokenRouter } from '../../services/tokenRouter.js';
import { refreshModelsAndRebuildRoutes } from '../../services/modelService.js';
import { reportProxyAllFailed, reportTokenExpired } from '../../services/alertService.js';
@@ -13,6 +13,7 @@ import { withSiteRecordProxyRequestInit } from '../../services/siteProxy.js';
import { composeProxyLogMessage } from './logPathMeta.js';
import { formatUtcSqlDateTime } from '../../services/localTimeService.js';
import { resolveProxyLogBilling } from './proxyBilling.js';
import { getProxyAuthContext } from '../../middleware/auth.js';
import { buildUpstreamUrl } from './upstreamUrl.js';
const MAX_RETRIES = 2;
@@ -26,9 +27,12 @@ export async function embeddingsProxyRoute(app: FastifyInstance) {
}
if (!await ensureModelAllowedForDownstreamKey(request, reply, requestedModel)) return;
const downstreamPolicy = getDownstreamRoutingPolicy(request);
const downstreamApiKeyId = getProxyAuthContext(request)?.keyId ?? null;
const logDownstreamApiKeyId = downstreamApiKeyId !== null
&& await hasProxyLogDownstreamApiKeyIdColumn();
const excludeChannelIds: number[] = [];
let retryCount = 0;
let retryCount = 0;
while (retryCount <= MAX_RETRIES) {
let selected = retryCount === 0
@@ -64,10 +68,10 @@ export async function embeddingsProxyRoute(app: FastifyInstance) {
body: JSON.stringify(forwardBody),
}));
const text = await upstream.text();
if (!upstream.ok) {
tokenRouter.recordFailure(selected.channel.id);
logProxy(selected, requestedModel, 'failed', upstream.status, Date.now() - startTime, text, retryCount);
const text = await upstream.text();
if (!upstream.ok) {
tokenRouter.recordFailure(selected.channel.id);
logProxy(selected, requestedModel, 'failed', upstream.status, Date.now() - startTime, text, retryCount, logDownstreamApiKeyId ? downstreamApiKeyId : null);
if (isTokenExpiredError({ status: upstream.status, message: text })) {
await reportTokenExpired({
@@ -120,13 +124,13 @@ export async function embeddingsProxyRoute(app: FastifyInstance) {
tokenRouter.recordSuccess(selected.channel.id, latency, estimatedCost);
recordDownstreamCostUsage(request, estimatedCost);
logProxy(
selected, requestedModel, 'success', upstream.status, latency, null, retryCount,
selected, requestedModel, 'success', upstream.status, latency, null, retryCount, logDownstreamApiKeyId ? downstreamApiKeyId : null,
resolvedUsage.promptTokens, resolvedUsage.completionTokens, resolvedUsage.totalTokens, estimatedCost, billingDetails,
);
return reply.code(upstream.status).send(data);
} catch (err: any) {
tokenRouter.recordFailure(selected.channel.id);
logProxy(selected, requestedModel, 'failed', 0, Date.now() - startTime, err.message, retryCount);
} catch (err: any) {
tokenRouter.recordFailure(selected.channel.id);
logProxy(selected, requestedModel, 'failed', 0, Date.now() - startTime, err.message, retryCount, logDownstreamApiKeyId ? downstreamApiKeyId : null);
if (retryCount < MAX_RETRIES) {
retryCount++;
continue;
@@ -141,14 +145,15 @@ export async function embeddingsProxyRoute(app: FastifyInstance) {
});
}
async function logProxy(
selected: any,
modelRequested: string,
status: string,
httpStatus: number,
latencyMs: number,
errorMessage: string | null,
retryCount: number,
async function logProxy(
selected: any,
modelRequested: string,
status: string,
httpStatus: number,
latencyMs: number,
errorMessage: string | null,
retryCount: number,
downstreamApiKeyId: number | null = null,
promptTokens = 0,
completionTokens = 0,
totalTokens = 0,
@@ -165,6 +170,7 @@ async function logProxy(
routeId: selected.channel.routeId,
channelId: selected.channel.id,
accountId: selected.account.id,
...(downstreamApiKeyId !== null ? { downstreamApiKeyId } : {}),
modelRequested,
modelActual: selected.actualModel,
status,
+35 -26
View File
@@ -1,6 +1,6 @@
import { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
import { fetch } from 'undici';
import { db, schema } from '../../db/index.js';
import { db, hasProxyLogDownstreamApiKeyIdColumn, schema } from '../../db/index.js';
import { tokenRouter } from '../../services/tokenRouter.js';
import { refreshModelsAndRebuildRoutes } from '../../services/modelService.js';
import { reportProxyAllFailed, reportTokenExpired } from '../../services/alertService.js';
@@ -12,6 +12,7 @@ import { withSiteRecordProxyRequestInit } from '../../services/siteProxy.js';
import { composeProxyLogMessage } from './logPathMeta.js';
import { formatUtcSqlDateTime } from '../../services/localTimeService.js';
import { cloneFormDataWithOverrides, ensureMultipartBufferParser, parseMultipartFormData } from './multipart.js';
import { getProxyAuthContext } from '../../middleware/auth.js';
import { buildUpstreamUrl } from './upstreamUrl.js';
const MAX_RETRIES = 2;
@@ -24,6 +25,9 @@ export async function imagesProxyRoute(app: FastifyInstance) {
const requestedModel = body?.model || 'gpt-image-1';
if (!await ensureModelAllowedForDownstreamKey(request, reply, requestedModel)) return;
const downstreamPolicy = getDownstreamRoutingPolicy(request);
const downstreamApiKeyId = getProxyAuthContext(request)?.keyId ?? null;
const logDownstreamApiKeyId = downstreamApiKeyId !== null
&& await hasProxyLogDownstreamApiKeyIdColumn();
const excludeChannelIds: number[] = [];
let retryCount = 0;
@@ -63,15 +67,15 @@ export async function imagesProxyRoute(app: FastifyInstance) {
body: JSON.stringify(forwardBody),
}));
const text = await upstream.text();
if (!upstream.ok) {
tokenRouter.recordFailure(selected.channel.id);
logProxy(selected, requestedModel, 'failed', upstream.status, Date.now() - startTime, text, retryCount);
if (isTokenExpiredError({ status: upstream.status, message: text })) {
await reportTokenExpired({
accountId: selected.account.id,
username: selected.account.username,
siteName: selected.site.name,
const text = await upstream.text();
if (!upstream.ok) {
tokenRouter.recordFailure(selected.channel.id);
logProxy(selected, requestedModel, 'failed', upstream.status, Date.now() - startTime, text, retryCount, logDownstreamApiKeyId ? downstreamApiKeyId : null);
if (isTokenExpiredError({ status: upstream.status, message: text })) {
await reportTokenExpired({
accountId: selected.account.id,
username: selected.account.username,
siteName: selected.site.name,
detail: `HTTP ${upstream.status}`,
});
}
@@ -100,15 +104,15 @@ export async function imagesProxyRoute(app: FastifyInstance) {
});
tokenRouter.recordSuccess(selected.channel.id, latency, estimatedCost);
recordDownstreamCostUsage(request, estimatedCost);
logProxy(selected, requestedModel, 'success', upstream.status, latency, null, retryCount, estimatedCost);
logProxy(selected, requestedModel, 'success', upstream.status, latency, null, retryCount, logDownstreamApiKeyId ? downstreamApiKeyId : null, estimatedCost);
return reply.code(upstream.status).send(data);
} catch (err: any) {
tokenRouter.recordFailure(selected.channel.id);
logProxy(selected, requestedModel, 'failed', 0, Date.now() - startTime, err.message, retryCount);
if (retryCount < MAX_RETRIES) {
retryCount++;
continue;
}
} catch (err: any) {
tokenRouter.recordFailure(selected.channel.id);
logProxy(selected, requestedModel, 'failed', 0, Date.now() - startTime, err.message, retryCount, logDownstreamApiKeyId ? downstreamApiKeyId : null);
if (retryCount < MAX_RETRIES) {
retryCount++;
continue;
}
await reportProxyAllFailed({
model: requestedModel,
reason: err.message || 'network failure',
@@ -131,6 +135,9 @@ export async function imagesProxyRoute(app: FastifyInstance) {
if (!await ensureModelAllowedForDownstreamKey(request, reply, requestedModel)) return;
const downstreamPolicy = getDownstreamRoutingPolicy(request);
const downstreamApiKeyId = getProxyAuthContext(request)?.keyId ?? null;
const logDownstreamApiKeyId = downstreamApiKeyId !== null
&& await hasProxyLogDownstreamApiKeyIdColumn();
const excludeChannelIds: number[] = [];
let retryCount = 0;
@@ -185,7 +192,7 @@ export async function imagesProxyRoute(app: FastifyInstance) {
const text = await upstream.text();
if (!upstream.ok) {
tokenRouter.recordFailure(selected.channel.id);
logProxy(selected, requestedModel, 'failed', upstream.status, Date.now() - startTime, text, retryCount, 0, '/v1/images/edits');
logProxy(selected, requestedModel, 'failed', upstream.status, Date.now() - startTime, text, retryCount, logDownstreamApiKeyId ? downstreamApiKeyId : null, 0, '/v1/images/edits');
if (isTokenExpiredError({ status: upstream.status, message: text })) {
await reportTokenExpired({
accountId: selected.account.id,
@@ -219,11 +226,11 @@ export async function imagesProxyRoute(app: FastifyInstance) {
});
tokenRouter.recordSuccess(selected.channel.id, latency, estimatedCost);
recordDownstreamCostUsage(request, estimatedCost);
logProxy(selected, requestedModel, 'success', upstream.status, latency, null, retryCount, estimatedCost, '/v1/images/edits');
logProxy(selected, requestedModel, 'success', upstream.status, latency, null, retryCount, logDownstreamApiKeyId ? downstreamApiKeyId : null, estimatedCost, '/v1/images/edits');
return reply.code(upstream.status).send(data);
} catch (err: any) {
tokenRouter.recordFailure(selected.channel.id);
logProxy(selected, requestedModel, 'failed', 0, Date.now() - startTime, err.message, retryCount, 0, '/v1/images/edits');
logProxy(selected, requestedModel, 'failed', 0, Date.now() - startTime, err.message, retryCount, logDownstreamApiKeyId ? downstreamApiKeyId : null, 0, '/v1/images/edits');
if (retryCount < MAX_RETRIES) {
retryCount++;
continue;
@@ -249,14 +256,15 @@ export async function imagesProxyRoute(app: FastifyInstance) {
});
}
async function logProxy(
selected: any,
modelRequested: string,
status: string,
httpStatus: number,
async function logProxy(
selected: any,
modelRequested: string,
status: string,
httpStatus: number,
latencyMs: number,
errorMessage: string | null,
retryCount: number,
downstreamApiKeyId: number | null = null,
estimatedCost = 0,
downstreamPath = '/v1/images/generations',
) {
@@ -270,6 +278,7 @@ async function logProxy(
routeId: selected.channel.routeId,
channelId: selected.channel.id,
accountId: selected.account.id,
...(downstreamApiKeyId !== null ? { downstreamApiKeyId } : {}),
modelRequested,
modelActual: selected.actualModel,
status,
+14 -2
View File
@@ -1,6 +1,6 @@
import { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
import { fetch } from 'undici';
import { db, schema } from '../../db/index.js';
import { db, hasProxyLogDownstreamApiKeyIdColumn, schema } from '../../db/index.js';
import { tokenRouter } from '../../services/tokenRouter.js';
import { refreshModelsAndRebuildRoutes } from '../../services/modelService.js';
import { reportProxyAllFailed, reportTokenExpired } from '../../services/alertService.js';
@@ -19,7 +19,7 @@ import { composeProxyLogMessage } from './logPathMeta.js';
import { executeEndpointFlow, withUpstreamPath } from './endpointFlow.js';
import { formatUtcSqlDateTime } from '../../services/localTimeService.js';
import { resolveProxyLogBilling } from './proxyBilling.js';
import { getProxyResourceOwner } from '../../middleware/auth.js';
import { getProxyAuthContext, getProxyResourceOwner } from '../../middleware/auth.js';
import { detectDownstreamClientContext, isCodexResponsesSurface, type DownstreamClientContext } from './downstreamClientContext.js';
import { normalizeInputFileBlock } from '../../transformers/shared/inputFile.js';
import {
@@ -147,6 +147,9 @@ export async function responsesProxyRoute(app: FastifyInstance) {
}
if (!await ensureModelAllowedForDownstreamKey(request, reply, requestedModel)) return;
const downstreamPolicy = getDownstreamRoutingPolicy(request);
const downstreamApiKeyId = getProxyAuthContext(request)?.keyId ?? null;
const logDownstreamApiKeyId = downstreamApiKeyId !== null
&& await hasProxyLogDownstreamApiKeyIdColumn();
const excludeChannelIds: number[] = [];
let retryCount = 0;
@@ -290,6 +293,7 @@ export async function responsesProxyRoute(app: FastifyInstance) {
null,
null,
clientContext,
logDownstreamApiKeyId ? downstreamApiKeyId : null,
);
},
});
@@ -314,6 +318,7 @@ export async function responsesProxyRoute(app: FastifyInstance) {
null,
null,
clientContext,
logDownstreamApiKeyId ? downstreamApiKeyId : null,
);
if (isTokenExpiredError({ status, message: errText })) {
@@ -405,6 +410,8 @@ export async function responsesProxyRoute(app: FastifyInstance) {
selected, requestedModel, 'success', 200, latency, null, retryCount, downstreamPath,
resolvedUsage.promptTokens, resolvedUsage.completionTokens, resolvedUsage.totalTokens, estimatedCost, billingDetails,
successfulUpstreamPath,
clientContext,
logDownstreamApiKeyId ? downstreamApiKeyId : null,
);
return;
}
@@ -458,6 +465,8 @@ export async function responsesProxyRoute(app: FastifyInstance) {
selected, requestedModel, 'success', 200, latency, null, retryCount, downstreamPath,
resolvedUsage.promptTokens, resolvedUsage.completionTokens, resolvedUsage.totalTokens, estimatedCost, billingDetails,
successfulUpstreamPath,
clientContext,
logDownstreamApiKeyId ? downstreamApiKeyId : null,
);
return reply.send(downstreamData);
} catch (err: any) {
@@ -478,6 +487,7 @@ export async function responsesProxyRoute(app: FastifyInstance) {
null,
null,
clientContext,
logDownstreamApiKeyId ? downstreamApiKeyId : null,
);
if (retryCount < MAX_RETRIES) {
retryCount += 1;
@@ -516,6 +526,7 @@ async function logProxy(
billingDetails: unknown = null,
upstreamPath: string | null = null,
clientContext: DownstreamClientContext | null = null,
downstreamApiKeyId: number | null = null,
) {
try {
const createdAt = formatUtcSqlDateTime(new Date());
@@ -533,6 +544,7 @@ async function logProxy(
routeId: selected.channel.routeId,
channelId: selected.channel.id,
accountId: selected.account.id,
...(downstreamApiKeyId !== null ? { downstreamApiKeyId } : {}),
modelRequested,
modelActual: selected.actualModel,
status,
+10 -4
View File
@@ -1,6 +1,6 @@
import { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
import { fetch } from 'undici';
import { db, schema } from '../../db/index.js';
import { db, hasProxyLogDownstreamApiKeyIdColumn, schema } from '../../db/index.js';
import { tokenRouter } from '../../services/tokenRouter.js';
import { refreshModelsAndRebuildRoutes } from '../../services/modelService.js';
import { reportProxyAllFailed, reportTokenExpired } from '../../services/alertService.js';
@@ -10,6 +10,7 @@ import { ensureModelAllowedForDownstreamKey, getDownstreamRoutingPolicy, recordD
import { withSiteRecordProxyRequestInit } from '../../services/siteProxy.js';
import { composeProxyLogMessage } from './logPathMeta.js';
import { formatUtcSqlDateTime } from '../../services/localTimeService.js';
import { getProxyAuthContext } from '../../middleware/auth.js';
import { buildUpstreamUrl } from './upstreamUrl.js';
const MAX_RETRIES = 2;
@@ -52,6 +53,9 @@ export async function searchProxyRoute(app: FastifyInstance) {
if (!await ensureModelAllowedForDownstreamKey(request, reply, requestedModel)) return;
const downstreamPolicy = getDownstreamRoutingPolicy(request);
const downstreamApiKeyId = getProxyAuthContext(request)?.keyId ?? null;
const logDownstreamApiKeyId = downstreamApiKeyId !== null
&& await hasProxyLogDownstreamApiKeyIdColumn();
const excludeChannelIds: number[] = [];
let retryCount = 0;
@@ -97,7 +101,7 @@ export async function searchProxyRoute(app: FastifyInstance) {
const text = await upstream.text();
if (!upstream.ok) {
tokenRouter.recordFailure(selected.channel.id);
logProxy(selected, requestedModel, 'failed', upstream.status, Date.now() - startTime, text, retryCount);
logProxy(selected, requestedModel, 'failed', upstream.status, Date.now() - startTime, text, retryCount, logDownstreamApiKeyId ? downstreamApiKeyId : null);
if (isTokenExpiredError({ status: upstream.status, message: text })) {
await reportTokenExpired({
accountId: selected.account.id,
@@ -123,11 +127,11 @@ export async function searchProxyRoute(app: FastifyInstance) {
const latency = Date.now() - startTime;
tokenRouter.recordSuccess(selected.channel.id, latency, 0);
recordDownstreamCostUsage(request, 0);
logProxy(selected, requestedModel, 'success', upstream.status, latency, null, retryCount);
logProxy(selected, requestedModel, 'success', upstream.status, latency, null, retryCount, logDownstreamApiKeyId ? downstreamApiKeyId : null);
return reply.code(upstream.status).send(data);
} catch (error: any) {
tokenRouter.recordFailure(selected.channel.id);
logProxy(selected, requestedModel, 'failed', 0, Date.now() - startTime, error?.message || 'network error', retryCount);
logProxy(selected, requestedModel, 'failed', 0, Date.now() - startTime, error?.message || 'network error', retryCount, logDownstreamApiKeyId ? downstreamApiKeyId : null);
if (retryCount < MAX_RETRIES) {
retryCount += 1;
continue;
@@ -152,6 +156,7 @@ async function logProxy(
latencyMs: number,
errorMessage: string | null,
retryCount: number,
downstreamApiKeyId: number | null = null,
) {
try {
const createdAt = formatUtcSqlDateTime(new Date());
@@ -159,6 +164,7 @@ async function logProxy(
routeId: selected.channel.routeId,
channelId: selected.channel.id,
accountId: selected.account.id,
...(downstreamApiKeyId !== null ? { downstreamApiKeyId } : {}),
modelRequested,
modelActual: selected.actualModel,
status,