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:
@@ -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: [],
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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',
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user