feat: add proxy log client detection filters
This commit is contained in:
@@ -75,6 +75,10 @@ describe('stats proxy logs routes', () => {
|
||||
modelRequested: 'gpt-4o',
|
||||
modelActual: 'gpt-4o',
|
||||
status: 'success',
|
||||
clientFamily: 'generic',
|
||||
clientAppId: 'cherry_studio',
|
||||
clientAppName: 'Cherry Studio',
|
||||
clientConfidence: 'exact',
|
||||
promptTokens: 10,
|
||||
completionTokens: 5,
|
||||
totalTokens: 15,
|
||||
@@ -88,6 +92,7 @@ describe('stats proxy logs routes', () => {
|
||||
modelRequested: 'gpt-4o-mini',
|
||||
modelActual: 'gpt-4o-mini',
|
||||
status: 'failed',
|
||||
clientFamily: 'codex',
|
||||
promptTokens: 8,
|
||||
completionTokens: 2,
|
||||
totalTokens: 10,
|
||||
@@ -139,6 +144,10 @@ describe('stats proxy logs routes', () => {
|
||||
totalCost: number;
|
||||
totalTokensAll: number;
|
||||
};
|
||||
clientOptions: Array<{
|
||||
value: string;
|
||||
label: string;
|
||||
}>;
|
||||
};
|
||||
|
||||
expect(body.page).toBe(2);
|
||||
@@ -150,7 +159,14 @@ describe('stats proxy logs routes', () => {
|
||||
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]?.clientFamily).toBe('codex');
|
||||
expect(body.items[0]?.clientAppId).toBe(null);
|
||||
expect(body.items[0]?.clientAppName).toBe(null);
|
||||
expect(body.items[0]?.clientConfidence).toBe(null);
|
||||
expect(body.items[0]).not.toHaveProperty('billingDetails');
|
||||
expect(body.clientOptions).toEqual([
|
||||
{ value: 'family:codex', label: '协议 · Codex' },
|
||||
]);
|
||||
expect(body.summary).toEqual({
|
||||
totalCount: 3,
|
||||
successCount: 1,
|
||||
@@ -188,6 +204,10 @@ describe('stats proxy logs routes', () => {
|
||||
modelRequested: 'gpt-5',
|
||||
modelActual: 'gpt-5',
|
||||
status: 'success',
|
||||
clientFamily: 'codex',
|
||||
clientAppId: 'cherry_studio',
|
||||
clientAppName: 'Cherry Studio',
|
||||
clientConfidence: 'exact',
|
||||
promptTokens: 100,
|
||||
completionTokens: 20,
|
||||
totalTokens: 120,
|
||||
@@ -214,6 +234,10 @@ describe('stats proxy logs routes', () => {
|
||||
downstreamKeyName: string | null;
|
||||
downstreamKeyGroupName: string | null;
|
||||
downstreamKeyTags: string[];
|
||||
clientFamily: string | null;
|
||||
clientAppId: string | null;
|
||||
clientAppName: string | null;
|
||||
clientConfidence: string | null;
|
||||
billingDetails: Record<string, unknown> | null;
|
||||
};
|
||||
|
||||
@@ -223,6 +247,10 @@ describe('stats proxy logs routes', () => {
|
||||
expect(body.downstreamKeyName).toBe('detail-key');
|
||||
expect(body.downstreamKeyGroupName).toBe('测试项目');
|
||||
expect(body.downstreamKeyTags).toEqual(['回归', '日志']);
|
||||
expect(body.clientFamily).toBe('codex');
|
||||
expect(body.clientAppId).toBe('cherry_studio');
|
||||
expect(body.clientAppName).toBe('Cherry Studio');
|
||||
expect(body.clientConfidence).toBe('exact');
|
||||
expect(body.billingDetails).toMatchObject({
|
||||
breakdown: { totalCost: 0.12 },
|
||||
usage: { promptTokens: 100, completionTokens: 20 },
|
||||
@@ -392,4 +420,118 @@ describe('stats proxy logs routes', () => {
|
||||
totalTokensAll: 30,
|
||||
});
|
||||
});
|
||||
|
||||
it('filters proxy logs by app id while keeping client options scoped only by the other filters', async () => {
|
||||
const site = await db.insert(schema.sites).values({
|
||||
name: 'client-filter-site',
|
||||
url: 'https://client-filter.example.com',
|
||||
platform: 'new-api',
|
||||
}).returning().get();
|
||||
|
||||
const account = await db.insert(schema.accounts).values({
|
||||
siteId: site.id,
|
||||
username: 'client-filter-user',
|
||||
accessToken: 'client-filter-token',
|
||||
status: 'active',
|
||||
}).returning().get();
|
||||
|
||||
await db.insert(schema.proxyLogs).values([
|
||||
{
|
||||
accountId: account.id,
|
||||
modelRequested: 'gpt-4o',
|
||||
modelActual: 'gpt-4o',
|
||||
status: 'success',
|
||||
clientFamily: 'generic',
|
||||
clientAppId: 'cherry_studio',
|
||||
clientAppName: 'Cherry Studio',
|
||||
clientConfidence: 'exact',
|
||||
totalTokens: 12,
|
||||
estimatedCost: 0.12,
|
||||
createdAt: formatUtcSqlDateTime(new Date('2026-03-09T11:00:00.000Z')),
|
||||
},
|
||||
{
|
||||
accountId: account.id,
|
||||
modelRequested: 'gpt-4.1',
|
||||
modelActual: 'gpt-4.1',
|
||||
status: 'failed',
|
||||
clientFamily: 'codex',
|
||||
totalTokens: 22,
|
||||
estimatedCost: 0.22,
|
||||
createdAt: formatUtcSqlDateTime(new Date('2026-03-09T11:05:00.000Z')),
|
||||
},
|
||||
]).run();
|
||||
|
||||
const response = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/stats/proxy-logs?client=app%3Acherry_studio',
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
const body = response.json() as {
|
||||
total: number;
|
||||
items: Array<Record<string, unknown>>;
|
||||
clientOptions: Array<{
|
||||
value: string;
|
||||
label: string;
|
||||
}>;
|
||||
};
|
||||
|
||||
expect(body.total).toBe(1);
|
||||
expect(body.items[0]?.clientAppId).toBe('cherry_studio');
|
||||
expect(body.clientOptions).toEqual([
|
||||
{ value: 'app:cherry_studio', label: '应用 · Cherry Studio' },
|
||||
{ value: 'family:codex', label: '协议 · Codex' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('falls back to legacy client prefixes for old logs without inferring an app fingerprint', async () => {
|
||||
const site = await db.insert(schema.sites).values({
|
||||
name: 'legacy-site',
|
||||
url: 'https://legacy.example.com',
|
||||
platform: 'new-api',
|
||||
}).returning().get();
|
||||
|
||||
const account = await db.insert(schema.accounts).values({
|
||||
siteId: site.id,
|
||||
username: 'legacy-user',
|
||||
accessToken: 'legacy-token',
|
||||
status: 'active',
|
||||
}).returning().get();
|
||||
|
||||
const inserted = await db.insert(schema.proxyLogs).values({
|
||||
accountId: account.id,
|
||||
modelRequested: 'gpt-4o',
|
||||
modelActual: 'gpt-4o',
|
||||
status: 'failed',
|
||||
errorMessage: '[client:codex] [session:turn-123] [downstream:/v1/responses] upstream error',
|
||||
totalTokens: 9,
|
||||
estimatedCost: 0.09,
|
||||
createdAt: formatUtcSqlDateTime(new Date('2026-03-09T12:00:00.000Z')),
|
||||
}).run();
|
||||
|
||||
const logId = Number(inserted.lastInsertRowid || 0);
|
||||
const listResponse = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/stats/proxy-logs',
|
||||
});
|
||||
const detailResponse = await app.inject({
|
||||
method: 'GET',
|
||||
url: `/api/stats/proxy-logs/${logId}`,
|
||||
});
|
||||
|
||||
expect(listResponse.statusCode).toBe(200);
|
||||
expect(detailResponse.statusCode).toBe(200);
|
||||
|
||||
const listBody = listResponse.json() as {
|
||||
items: Array<Record<string, unknown>>;
|
||||
};
|
||||
const detailBody = detailResponse.json() as Record<string, unknown>;
|
||||
|
||||
expect(listBody.items[0]?.clientFamily).toBe('codex');
|
||||
expect(listBody.items[0]?.clientAppId).toBe(null);
|
||||
expect(listBody.items[0]?.clientAppName).toBe(null);
|
||||
expect(detailBody.clientFamily).toBe('codex');
|
||||
expect(detailBody.clientAppId).toBe(null);
|
||||
expect(detailBody.clientAppName).toBe(null);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
parseProxyLogBillingDetails,
|
||||
withProxyLogSelectFields,
|
||||
} from '../../services/proxyLogStore.js';
|
||||
import { parseProxyLogMessageMeta } from '../proxy/logPathMeta.js';
|
||||
import { requiresManagedAccountTokens } from '../../services/accountExtraConfig.js';
|
||||
import { ACCOUNT_TOKEN_VALUE_STATUS_READY } from '../../services/accountTokenService.js';
|
||||
import {
|
||||
@@ -85,6 +86,22 @@ function proxyCostSqlExpression() {
|
||||
}
|
||||
|
||||
type ProxyLogStatusFilter = 'all' | 'success' | 'failed';
|
||||
type ProxyLogClientFilter = {
|
||||
kind: 'app' | 'family';
|
||||
value: string;
|
||||
} | null;
|
||||
|
||||
type ProxyLogClientOption = {
|
||||
value: string;
|
||||
label: string;
|
||||
};
|
||||
|
||||
const PROXY_LOG_CLIENT_FAMILY_LABELS: Record<string, string> = {
|
||||
codex: 'Codex',
|
||||
claude_code: 'Claude Code',
|
||||
gemini_cli: 'Gemini CLI',
|
||||
generic: '通用',
|
||||
};
|
||||
|
||||
function normalizeProxyLogPageSize(raw?: string): number {
|
||||
const parsed = Number.parseInt(raw || '50', 10);
|
||||
@@ -115,6 +132,20 @@ function normalizeProxyLogSiteId(raw?: string): number | null {
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function normalizeProxyLogClientFilter(raw?: string): ProxyLogClientFilter {
|
||||
const text = (raw || '').trim();
|
||||
if (!text) return null;
|
||||
const separatorIndex = text.indexOf(':');
|
||||
if (separatorIndex <= 0) return null;
|
||||
const kind = text.slice(0, separatorIndex).trim().toLowerCase();
|
||||
const value = text.slice(separatorIndex + 1).trim().toLowerCase();
|
||||
if (!value) return null;
|
||||
if (kind === 'app' || kind === 'family') {
|
||||
return { kind, value };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function normalizeProxyLogTimeBoundary(raw?: string): string | null {
|
||||
const text = (raw || '').trim();
|
||||
if (!text) return null;
|
||||
@@ -166,9 +197,18 @@ function buildProxyLogStatusCondition(status: ProxyLogStatusFilter) {
|
||||
return null;
|
||||
}
|
||||
|
||||
function buildProxyLogClientCondition(client: ProxyLogClientFilter) {
|
||||
if (!client) return null;
|
||||
if (client.kind === 'app') {
|
||||
return eq(schema.proxyLogs.clientAppId, client.value);
|
||||
}
|
||||
return eq(schema.proxyLogs.clientFamily, client.value);
|
||||
}
|
||||
|
||||
function buildProxyLogWhereClause(params: {
|
||||
status?: ProxyLogStatusFilter;
|
||||
search?: string;
|
||||
client?: ProxyLogClientFilter;
|
||||
siteId?: number | null;
|
||||
fromUtc?: string | null;
|
||||
toUtc?: string | null;
|
||||
@@ -176,6 +216,7 @@ function buildProxyLogWhereClause(params: {
|
||||
const conditions = [
|
||||
params.status ? buildProxyLogStatusCondition(params.status) : null,
|
||||
params.search ? buildProxyLogSearchCondition(params.search) : null,
|
||||
params.client ? buildProxyLogClientCondition(params.client) : null,
|
||||
params.siteId ? eq(schema.sites.id, params.siteId) : null,
|
||||
params.fromUtc ? gte(schema.proxyLogs.createdAt, params.fromUtc) : null,
|
||||
params.toUtc ? lt(schema.proxyLogs.createdAt, params.toUtc) : null,
|
||||
@@ -189,6 +230,83 @@ function toRoundedMicroNumber(value: number | null | undefined): number {
|
||||
return Math.round(Number(value || 0) * 1_000_000) / 1_000_000;
|
||||
}
|
||||
|
||||
function normalizeNullableText(value: unknown): string | null {
|
||||
if (typeof value !== 'string') return null;
|
||||
const trimmed = value.trim();
|
||||
return trimmed || null;
|
||||
}
|
||||
|
||||
function normalizeClientConfidence(value: unknown): string | null {
|
||||
const normalized = normalizeNullableText(value)?.toLowerCase() || null;
|
||||
if (normalized === 'exact' || normalized === 'heuristic' || normalized === 'unknown') {
|
||||
return normalized;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function displayProxyLogClientFamily(value: string | null): string | null {
|
||||
if (!value) return null;
|
||||
return PROXY_LOG_CLIENT_FAMILY_LABELS[value] || value;
|
||||
}
|
||||
|
||||
function resolveProxyLogClientMeta(proxyLog: Record<string, unknown>) {
|
||||
const clientFamily = normalizeNullableText(proxyLog.clientFamily)?.toLowerCase() || null;
|
||||
const clientAppId = normalizeNullableText(proxyLog.clientAppId)?.toLowerCase() || null;
|
||||
const clientAppName = normalizeNullableText(proxyLog.clientAppName) || null;
|
||||
const clientConfidence = normalizeClientConfidence(proxyLog.clientConfidence);
|
||||
|
||||
if (clientFamily || clientAppId || clientAppName || clientConfidence) {
|
||||
return {
|
||||
clientFamily,
|
||||
clientAppId,
|
||||
clientAppName,
|
||||
clientConfidence,
|
||||
};
|
||||
}
|
||||
|
||||
const legacyMeta = parseProxyLogMessageMeta(typeof proxyLog.errorMessage === 'string' ? proxyLog.errorMessage : '');
|
||||
return {
|
||||
clientFamily: normalizeNullableText(legacyMeta.clientKind)?.toLowerCase() || null,
|
||||
clientAppId: null,
|
||||
clientAppName: null,
|
||||
clientConfidence: null,
|
||||
};
|
||||
}
|
||||
|
||||
function buildProxyLogClientOptions(rows: Array<{
|
||||
clientFamily?: string | null;
|
||||
clientAppId?: string | null;
|
||||
clientAppName?: string | null;
|
||||
}>): ProxyLogClientOption[] {
|
||||
const appOptions = new Map<string, ProxyLogClientOption>();
|
||||
const familyOptions = new Map<string, ProxyLogClientOption>();
|
||||
|
||||
for (const row of rows) {
|
||||
const clientAppId = normalizeNullableText(row.clientAppId)?.toLowerCase() || null;
|
||||
const clientAppName = normalizeNullableText(row.clientAppName) || null;
|
||||
const clientFamily = normalizeNullableText(row.clientFamily)?.toLowerCase() || null;
|
||||
|
||||
if (clientAppId && clientAppName && !appOptions.has(clientAppId)) {
|
||||
appOptions.set(clientAppId, {
|
||||
value: `app:${clientAppId}`,
|
||||
label: `应用 · ${clientAppName}`,
|
||||
});
|
||||
}
|
||||
|
||||
if (clientFamily && clientFamily !== 'generic' && !familyOptions.has(clientFamily)) {
|
||||
familyOptions.set(clientFamily, {
|
||||
value: `family:${clientFamily}`,
|
||||
label: `协议 · ${displayProxyLogClientFamily(clientFamily) || clientFamily}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
...Array.from(appOptions.values()).sort((left, right) => left.label.localeCompare(right.label, 'zh-CN')),
|
||||
...Array.from(familyOptions.values()).sort((left, right) => left.label.localeCompare(right.label, 'zh-CN')),
|
||||
];
|
||||
}
|
||||
|
||||
const SITE_AVAILABILITY_BUCKET_COUNT = 24;
|
||||
const SITE_AVAILABILITY_BUCKET_MS = 60 * 60 * 1000;
|
||||
|
||||
@@ -351,11 +469,16 @@ function mapProxyLogRow(
|
||||
},
|
||||
options?: { includeBillingDetails?: boolean },
|
||||
) {
|
||||
const clientMeta = resolveProxyLogClientMeta(row.proxy_logs);
|
||||
return {
|
||||
...row.proxy_logs,
|
||||
...(options?.includeBillingDetails
|
||||
? { billingDetails: parseProxyLogBillingDetails(row.proxy_logs.billingDetails) }
|
||||
: {}),
|
||||
clientFamily: clientMeta.clientFamily,
|
||||
clientAppId: clientMeta.clientAppId,
|
||||
clientAppName: clientMeta.clientAppName,
|
||||
clientConfidence: clientMeta.clientConfidence,
|
||||
username: row.accounts?.username || null,
|
||||
siteId: row.sites?.id || null,
|
||||
siteName: row.sites?.name || null,
|
||||
@@ -547,6 +670,7 @@ export async function statsRoutes(app: FastifyInstance) {
|
||||
offset?: string;
|
||||
status?: string;
|
||||
search?: string;
|
||||
client?: string;
|
||||
siteId?: string;
|
||||
from?: string;
|
||||
to?: string;
|
||||
@@ -555,11 +679,13 @@ export async function statsRoutes(app: FastifyInstance) {
|
||||
const offset = normalizeProxyLogOffset(request.query.offset);
|
||||
const status = normalizeProxyLogStatusFilter(request.query.status);
|
||||
const search = normalizeProxyLogSearch(request.query.search);
|
||||
const client = normalizeProxyLogClientFilter(request.query.client);
|
||||
const siteId = normalizeProxyLogSiteId(request.query.siteId);
|
||||
const fromUtc = normalizeProxyLogTimeBoundary(request.query.from);
|
||||
const toUtc = normalizeProxyLogTimeBoundary(request.query.to);
|
||||
const listWhere = buildProxyLogWhereClause({ status, search, siteId, fromUtc, toUtc });
|
||||
const summaryWhere = buildProxyLogWhereClause({ search, siteId, fromUtc, toUtc });
|
||||
const listWhere = buildProxyLogWhereClause({ status, search, client, siteId, fromUtc, toUtc });
|
||||
const summaryWhere = buildProxyLogWhereClause({ search, client, siteId, fromUtc, toUtc });
|
||||
const clientOptionsWhere = buildProxyLogWhereClause({ status, search, siteId, fromUtc, toUtc });
|
||||
|
||||
const listRows = await withProxyLogSelectFields(({ fields }) => {
|
||||
let query = db.select({
|
||||
@@ -604,6 +730,31 @@ export async function statsRoutes(app: FastifyInstance) {
|
||||
}
|
||||
const totalRow = await totalQuery.get();
|
||||
|
||||
const clientOptionRows = await withProxyLogSelectFields(({ fields, includeClientFields }) => {
|
||||
if (!includeClientFields) {
|
||||
return Promise.resolve([]);
|
||||
}
|
||||
|
||||
let query = db.select({
|
||||
clientFamily: fields.clientFamily!,
|
||||
clientAppId: fields.clientAppId!,
|
||||
clientAppName: fields.clientAppName!,
|
||||
}).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));
|
||||
|
||||
if (clientOptionsWhere) {
|
||||
query = query.where(clientOptionsWhere) as typeof query;
|
||||
}
|
||||
|
||||
return query.all();
|
||||
}, { includeBillingDetails: false, includeClientFields: true }) as Array<{
|
||||
clientFamily?: string | null;
|
||||
clientAppId?: string | null;
|
||||
clientAppName?: string | null;
|
||||
}>;
|
||||
|
||||
let summaryQuery = db.select({
|
||||
totalCount: sql<number>`count(*)`,
|
||||
successCount: sql<number>`coalesce(sum(case when ${schema.proxyLogs.status} = 'success' then 1 else 0 end), 0)`,
|
||||
@@ -624,6 +775,7 @@ export async function statsRoutes(app: FastifyInstance) {
|
||||
total: Number(totalRow?.total || 0),
|
||||
page: Math.floor(offset / limit) + 1,
|
||||
pageSize: limit,
|
||||
clientOptions: buildProxyLogClientOptions(clientOptionRows),
|
||||
summary: {
|
||||
totalCount: Number(summaryRow?.totalCount || 0),
|
||||
successCount: Number(summaryRow?.successCount || 0),
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
|
||||
import { fetch } from 'undici';
|
||||
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';
|
||||
@@ -14,8 +13,10 @@ import { composeProxyLogMessage } from './logPathMeta.js';
|
||||
import { formatUtcSqlDateTime } from '../../services/localTimeService.js';
|
||||
import { detectProxyFailure } from './proxyFailureJudge.js';
|
||||
import { resolveProxyLogBilling } from './proxyBilling.js';
|
||||
import { getProxyAuthContext } from '../../middleware/auth.js';
|
||||
import { buildUpstreamUrl } from './upstreamUrl.js';
|
||||
import { getProxyAuthContext } from '../../middleware/auth.js';
|
||||
import { buildUpstreamUrl } from './upstreamUrl.js';
|
||||
import { detectDownstreamClientContext, type DownstreamClientContext } from './downstreamClientContext.js';
|
||||
import { insertProxyLog } from '../../services/proxyLogStore.js';
|
||||
|
||||
const MAX_RETRIES = 2;
|
||||
|
||||
@@ -25,12 +26,16 @@ export async function completionsProxyRoute(app: FastifyInstance) {
|
||||
const requestedModel = body?.model;
|
||||
if (!requestedModel) {
|
||||
return reply.code(400).send({ error: { message: 'model is required', type: 'invalid_request_error' } });
|
||||
}
|
||||
if (!await ensureModelAllowedForDownstreamKey(request, reply, requestedModel)) return;
|
||||
const downstreamPolicy = getDownstreamRoutingPolicy(request);
|
||||
const downstreamApiKeyId = getProxyAuthContext(request)?.keyId ?? null;
|
||||
const logDownstreamApiKeyId = downstreamApiKeyId !== null
|
||||
&& await hasProxyLogDownstreamApiKeyIdColumn();
|
||||
}
|
||||
if (!await ensureModelAllowedForDownstreamKey(request, reply, requestedModel)) return;
|
||||
const downstreamPolicy = getDownstreamRoutingPolicy(request);
|
||||
const downstreamApiKeyId = getProxyAuthContext(request)?.keyId ?? null;
|
||||
const downstreamPath = '/v1/completions';
|
||||
const clientContext = detectDownstreamClientContext({
|
||||
downstreamPath,
|
||||
headers: request.headers as Record<string, unknown>,
|
||||
body,
|
||||
});
|
||||
|
||||
const isStream = body.stream === true;
|
||||
const excludeChannelIds: number[] = [];
|
||||
@@ -75,7 +80,7 @@ export async function completionsProxyRoute(app: FastifyInstance) {
|
||||
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);
|
||||
logProxy(selected, requestedModel, 'failed', upstream.status, Date.now() - startTime, errText, retryCount, downstreamApiKeyId, 0, 0, 0, 0, null, clientContext, downstreamPath);
|
||||
|
||||
if (isTokenExpiredError({ status: upstream.status, message: errText })) {
|
||||
await reportTokenExpired({
|
||||
@@ -173,12 +178,12 @@ export async function completionsProxyRoute(app: FastifyInstance) {
|
||||
parsedUsage,
|
||||
resolvedUsage,
|
||||
});
|
||||
tokenRouter.recordSuccess(selected.channel.id, latency, estimatedCost);
|
||||
recordDownstreamCostUsage(request, estimatedCost);
|
||||
logProxy(
|
||||
selected, requestedModel, 'success', 200, latency, null, retryCount, logDownstreamApiKeyId ? downstreamApiKeyId : null,
|
||||
resolvedUsage.promptTokens, resolvedUsage.completionTokens, resolvedUsage.totalTokens, estimatedCost, billingDetails,
|
||||
);
|
||||
tokenRouter.recordSuccess(selected.channel.id, latency, estimatedCost);
|
||||
recordDownstreamCostUsage(request, estimatedCost);
|
||||
logProxy(
|
||||
selected, requestedModel, 'success', 200, latency, null, retryCount, downstreamApiKeyId,
|
||||
resolvedUsage.promptTokens, resolvedUsage.completionTokens, resolvedUsage.totalTokens, estimatedCost, billingDetails, clientContext, downstreamPath,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -199,12 +204,19 @@ export async function completionsProxyRoute(app: FastifyInstance) {
|
||||
selected,
|
||||
requestedModel,
|
||||
'failed',
|
||||
failure.status,
|
||||
latency,
|
||||
errText,
|
||||
retryCount,
|
||||
logDownstreamApiKeyId ? downstreamApiKeyId : null,
|
||||
);
|
||||
failure.status,
|
||||
latency,
|
||||
errText,
|
||||
retryCount,
|
||||
downstreamApiKeyId,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
null,
|
||||
clientContext,
|
||||
downstreamPath,
|
||||
);
|
||||
|
||||
if (shouldRetryProxyRequest(failure.status, errText) && retryCount < MAX_RETRIES) {
|
||||
retryCount += 1;
|
||||
@@ -244,16 +256,16 @@ export async function completionsProxyRoute(app: FastifyInstance) {
|
||||
resolvedUsage,
|
||||
});
|
||||
|
||||
tokenRouter.recordSuccess(selected.channel.id, latency, estimatedCost);
|
||||
recordDownstreamCostUsage(request, estimatedCost);
|
||||
logProxy(
|
||||
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, logDownstreamApiKeyId ? downstreamApiKeyId : null);
|
||||
tokenRouter.recordSuccess(selected.channel.id, latency, estimatedCost);
|
||||
recordDownstreamCostUsage(request, estimatedCost);
|
||||
logProxy(
|
||||
selected, requestedModel, 'success', 200, latency, null, retryCount, downstreamApiKeyId,
|
||||
resolvedUsage.promptTokens, resolvedUsage.completionTokens, resolvedUsage.totalTokens, estimatedCost, billingDetails, clientContext, downstreamPath,
|
||||
);
|
||||
return reply.send(data);
|
||||
} catch (err: any) {
|
||||
tokenRouter.recordFailure(selected.channel.id);
|
||||
logProxy(selected, requestedModel, 'failed', 0, Date.now() - startTime, err.message, retryCount, downstreamApiKeyId, 0, 0, 0, 0, null, clientContext, downstreamPath);
|
||||
if (retryCount < MAX_RETRIES) {
|
||||
retryCount++;
|
||||
continue;
|
||||
@@ -270,47 +282,58 @@ export async function completionsProxyRoute(app: FastifyInstance) {
|
||||
});
|
||||
}
|
||||
|
||||
async function logProxy(
|
||||
selected: any,
|
||||
modelRequested: string,
|
||||
status: string,
|
||||
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,
|
||||
estimatedCost = 0,
|
||||
billingDetails: unknown = null,
|
||||
) {
|
||||
try {
|
||||
const createdAt = formatUtcSqlDateTime(new Date());
|
||||
const normalizedErrorMessage = composeProxyLogMessage({
|
||||
downstreamPath: '/v1/completions',
|
||||
errorMessage,
|
||||
});
|
||||
await db.insert(schema.proxyLogs).values({
|
||||
routeId: selected.channel.routeId,
|
||||
channelId: selected.channel.id,
|
||||
accountId: selected.account.id,
|
||||
...(downstreamApiKeyId !== null ? { downstreamApiKeyId } : {}),
|
||||
modelRequested,
|
||||
modelActual: selected.actualModel,
|
||||
status,
|
||||
completionTokens = 0,
|
||||
totalTokens = 0,
|
||||
estimatedCost = 0,
|
||||
billingDetails: unknown = null,
|
||||
clientContext: DownstreamClientContext | null = null,
|
||||
downstreamPath = '/v1/completions',
|
||||
) {
|
||||
try {
|
||||
const createdAt = formatUtcSqlDateTime(new Date());
|
||||
const normalizedErrorMessage = composeProxyLogMessage({
|
||||
clientKind: clientContext?.clientKind && clientContext.clientKind !== 'generic'
|
||||
? clientContext.clientKind
|
||||
: null,
|
||||
sessionId: clientContext?.sessionId || null,
|
||||
traceHint: clientContext?.traceHint || null,
|
||||
downstreamPath,
|
||||
errorMessage,
|
||||
});
|
||||
await insertProxyLog({
|
||||
routeId: selected.channel.routeId,
|
||||
channelId: selected.channel.id,
|
||||
accountId: selected.account.id,
|
||||
downstreamApiKeyId,
|
||||
modelRequested,
|
||||
modelActual: selected.actualModel,
|
||||
status,
|
||||
httpStatus,
|
||||
latencyMs,
|
||||
promptTokens,
|
||||
completionTokens,
|
||||
totalTokens,
|
||||
estimatedCost,
|
||||
billingDetails: billingDetails ? JSON.stringify(billingDetails) : null,
|
||||
errorMessage: normalizedErrorMessage,
|
||||
retryCount,
|
||||
createdAt,
|
||||
}).run();
|
||||
} catch (error) {
|
||||
completionTokens,
|
||||
totalTokens,
|
||||
estimatedCost,
|
||||
billingDetails,
|
||||
clientFamily: clientContext?.clientKind || null,
|
||||
clientAppId: clientContext?.clientAppId || null,
|
||||
clientAppName: clientContext?.clientAppName || null,
|
||||
clientConfidence: clientContext?.clientConfidence || null,
|
||||
errorMessage: normalizedErrorMessage,
|
||||
retryCount,
|
||||
createdAt,
|
||||
});
|
||||
} catch (error) {
|
||||
console.warn('[proxy/completions] failed to write proxy log', error);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -71,6 +71,9 @@ vi.mock('../../db/index.js', () => ({
|
||||
schema: {
|
||||
proxyLogs: {},
|
||||
},
|
||||
hasProxyLogBillingDetailsColumn: async () => false,
|
||||
hasProxyLogClientColumns: async () => false,
|
||||
hasProxyLogDownstreamApiKeyIdColumn: async () => false,
|
||||
}));
|
||||
|
||||
describe('downstream client context route logging', () => {
|
||||
|
||||
@@ -130,4 +130,49 @@ describe('detectDownstreamClientContext', () => {
|
||||
clientKind: 'gemini_cli',
|
||||
});
|
||||
});
|
||||
|
||||
it('recognizes app fingerprints alongside a generic protocol family', () => {
|
||||
expect(detectDownstreamClientContext({
|
||||
downstreamPath: '/v1/chat/completions',
|
||||
headers: {
|
||||
'x-title': 'Cherry Studio',
|
||||
'http-referer': 'https://cherry-ai.com',
|
||||
},
|
||||
})).toEqual({
|
||||
clientKind: 'generic',
|
||||
clientAppId: 'cherry_studio',
|
||||
clientAppName: 'Cherry Studio',
|
||||
clientConfidence: 'exact',
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps protocol family detection when an app fingerprint also matches', () => {
|
||||
expect(detectDownstreamClientContext({
|
||||
downstreamPath: '/v1/responses',
|
||||
headers: {
|
||||
originator: 'codex_cli_rs',
|
||||
'x-title': 'Cherry Studio',
|
||||
'http-referer': 'https://cherry-ai.com',
|
||||
},
|
||||
})).toEqual({
|
||||
clientKind: 'codex',
|
||||
clientAppId: 'cherry_studio',
|
||||
clientAppName: 'Cherry Studio',
|
||||
clientConfidence: 'exact',
|
||||
});
|
||||
});
|
||||
|
||||
it('marks weak app-only matches as heuristic instead of upgrading protocol behavior', () => {
|
||||
expect(detectDownstreamClientContext({
|
||||
downstreamPath: '/v1/chat/completions',
|
||||
headers: {
|
||||
'user-agent': 'CherryStudio/1.2.3',
|
||||
},
|
||||
})).toEqual({
|
||||
clientKind: 'generic',
|
||||
clientAppId: 'cherry_studio',
|
||||
clientAppName: 'Cherry Studio',
|
||||
clientConfidence: 'heuristic',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -6,13 +6,158 @@ import { isCodexResponsesSurface as isCodexResponsesSurfaceViaProfile } from '..
|
||||
import type { CliProfileId } from '../../proxy-core/cliProfiles/types.js';
|
||||
|
||||
export type DownstreamClientKind = CliProfileId;
|
||||
export type DownstreamClientConfidence = 'exact' | 'heuristic';
|
||||
|
||||
export type DownstreamClientContext = {
|
||||
clientKind: DownstreamClientKind;
|
||||
sessionId?: string;
|
||||
traceHint?: string;
|
||||
clientAppId?: string;
|
||||
clientAppName?: string;
|
||||
clientConfidence?: DownstreamClientConfidence;
|
||||
};
|
||||
|
||||
type NormalizedClientHeaders = Record<string, string[]>;
|
||||
|
||||
type DownstreamClientBodySummary = {
|
||||
topLevelKeys: string[];
|
||||
metadataUserId: string | null;
|
||||
};
|
||||
|
||||
type DownstreamClientFingerprintInput = {
|
||||
downstreamPath: string;
|
||||
headers: NormalizedClientHeaders;
|
||||
bodySummary: DownstreamClientBodySummary;
|
||||
};
|
||||
|
||||
type DownstreamClientFingerprintRule = {
|
||||
id: string;
|
||||
name: string;
|
||||
priority: number;
|
||||
match(input: DownstreamClientFingerprintInput): DownstreamClientConfidence | null;
|
||||
};
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return !!value && typeof value === 'object' && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function normalizeHeaderValues(value: unknown): string[] {
|
||||
if (typeof value === 'string') {
|
||||
const trimmed = value.trim();
|
||||
return trimmed ? [trimmed] : [];
|
||||
}
|
||||
|
||||
if (!Array.isArray(value)) return [];
|
||||
return value
|
||||
.filter((item): item is string => typeof item === 'string')
|
||||
.map((item) => item.trim())
|
||||
.filter((item) => item.length > 0);
|
||||
}
|
||||
|
||||
function normalizeHeaders(headers?: Record<string, unknown>): NormalizedClientHeaders {
|
||||
if (!headers) return {};
|
||||
|
||||
const normalized: NormalizedClientHeaders = {};
|
||||
for (const [rawKey, rawValue] of Object.entries(headers)) {
|
||||
const key = rawKey.trim().toLowerCase();
|
||||
if (!key) continue;
|
||||
const values = normalizeHeaderValues(rawValue);
|
||||
if (values.length === 0) continue;
|
||||
normalized[key] = normalized[key]
|
||||
? [...normalized[key], ...values]
|
||||
: values;
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function headerEquals(headers: NormalizedClientHeaders, key: string, expected: string): boolean {
|
||||
const normalizedExpected = expected.trim().toLowerCase();
|
||||
return (headers[key.trim().toLowerCase()] || []).some((value) => value.trim().toLowerCase() === normalizedExpected);
|
||||
}
|
||||
|
||||
function headerIncludes(headers: NormalizedClientHeaders, key: string, expectedFragment: string): boolean {
|
||||
const normalizedExpected = expectedFragment.trim().toLowerCase();
|
||||
return (headers[key.trim().toLowerCase()] || []).some((value) => value.trim().toLowerCase().includes(normalizedExpected));
|
||||
}
|
||||
|
||||
function buildBodySummary(body: unknown): DownstreamClientBodySummary {
|
||||
if (!isRecord(body)) {
|
||||
return {
|
||||
topLevelKeys: [],
|
||||
metadataUserId: null,
|
||||
};
|
||||
}
|
||||
|
||||
const metadataUserId = isRecord(body.metadata) && typeof body.metadata.user_id === 'string'
|
||||
? body.metadata.user_id.trim() || null
|
||||
: null;
|
||||
|
||||
return {
|
||||
topLevelKeys: Object.keys(body).sort((left, right) => left.localeCompare(right)),
|
||||
metadataUserId,
|
||||
};
|
||||
}
|
||||
|
||||
const appFingerprintRegistry: DownstreamClientFingerprintRule[] = [
|
||||
{
|
||||
id: 'cherry_studio',
|
||||
name: 'Cherry Studio',
|
||||
priority: 100,
|
||||
match(input) {
|
||||
const hasTitle = headerEquals(input.headers, 'x-title', 'Cherry Studio');
|
||||
const hasReferer = headerEquals(input.headers, 'http-referer', 'https://cherry-ai.com')
|
||||
|| headerEquals(input.headers, 'referer', 'https://cherry-ai.com');
|
||||
|
||||
if (hasTitle && hasReferer) {
|
||||
return 'exact';
|
||||
}
|
||||
|
||||
const weakSignals = [
|
||||
headerIncludes(input.headers, 'user-agent', 'cherrystudio'),
|
||||
headerIncludes(input.headers, 'x-title', 'cherry studio'),
|
||||
headerIncludes(input.headers, 'http-referer', 'cherry-ai.com'),
|
||||
headerIncludes(input.headers, 'referer', 'cherry-ai.com'),
|
||||
];
|
||||
|
||||
return weakSignals.some(Boolean) ? 'heuristic' : null;
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
function detectDownstreamClientFingerprint(input: {
|
||||
downstreamPath: string;
|
||||
headers?: Record<string, unknown>;
|
||||
body?: unknown;
|
||||
}) {
|
||||
const fingerprintInput: DownstreamClientFingerprintInput = {
|
||||
downstreamPath: input.downstreamPath,
|
||||
headers: normalizeHeaders(input.headers),
|
||||
bodySummary: buildBodySummary(input.body),
|
||||
};
|
||||
|
||||
let matchedRule: DownstreamClientFingerprintRule | null = null;
|
||||
let matchedConfidence: DownstreamClientConfidence | null = null;
|
||||
|
||||
for (const rule of appFingerprintRegistry) {
|
||||
const confidence = rule.match(fingerprintInput);
|
||||
if (!confidence) continue;
|
||||
if (!matchedRule || rule.priority > matchedRule.priority) {
|
||||
matchedRule = rule;
|
||||
matchedConfidence = confidence;
|
||||
}
|
||||
}
|
||||
|
||||
if (!matchedRule || !matchedConfidence) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
clientAppId: matchedRule.id,
|
||||
clientAppName: matchedRule.name,
|
||||
clientConfidence: matchedConfidence,
|
||||
};
|
||||
}
|
||||
|
||||
export function isCodexResponsesSurface(headers?: Record<string, unknown>): boolean {
|
||||
return isCodexResponsesSurfaceViaProfile(headers);
|
||||
}
|
||||
@@ -27,9 +172,11 @@ export function detectDownstreamClientContext(input: {
|
||||
body?: unknown;
|
||||
}): DownstreamClientContext {
|
||||
const detected = detectCliProfile(input);
|
||||
const fingerprint = detectDownstreamClientFingerprint(input);
|
||||
return {
|
||||
clientKind: detected.id,
|
||||
...(detected.sessionId ? { sessionId: detected.sessionId } : {}),
|
||||
...(detected.traceHint ? { traceHint: detected.traceHint } : {}),
|
||||
...(fingerprint || {}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
|
||||
import { fetch } from 'undici';
|
||||
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';
|
||||
@@ -15,6 +14,8 @@ import { formatUtcSqlDateTime } from '../../services/localTimeService.js';
|
||||
import { resolveProxyLogBilling } from './proxyBilling.js';
|
||||
import { getProxyAuthContext } from '../../middleware/auth.js';
|
||||
import { buildUpstreamUrl } from './upstreamUrl.js';
|
||||
import { detectDownstreamClientContext, type DownstreamClientContext } from './downstreamClientContext.js';
|
||||
import { insertProxyLog } from '../../services/proxyLogStore.js';
|
||||
|
||||
const MAX_RETRIES = 2;
|
||||
|
||||
@@ -28,8 +29,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 downstreamPath = '/v1/embeddings';
|
||||
const clientContext = detectDownstreamClientContext({
|
||||
downstreamPath,
|
||||
headers: request.headers as Record<string, unknown>,
|
||||
body,
|
||||
});
|
||||
|
||||
const excludeChannelIds: number[] = [];
|
||||
let retryCount = 0;
|
||||
@@ -71,7 +76,7 @@ export async function embeddingsProxyRoute(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, logDownstreamApiKeyId ? downstreamApiKeyId : null);
|
||||
logProxy(selected, requestedModel, 'failed', upstream.status, Date.now() - startTime, text, retryCount, downstreamApiKeyId, 0, 0, 0, 0, null, clientContext, downstreamPath);
|
||||
|
||||
if (isTokenExpiredError({ status: upstream.status, message: text })) {
|
||||
await reportTokenExpired({
|
||||
@@ -124,13 +129,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, logDownstreamApiKeyId ? downstreamApiKeyId : null,
|
||||
resolvedUsage.promptTokens, resolvedUsage.completionTokens, resolvedUsage.totalTokens, estimatedCost, billingDetails,
|
||||
selected, requestedModel, 'success', upstream.status, latency, null, retryCount, downstreamApiKeyId,
|
||||
resolvedUsage.promptTokens, resolvedUsage.completionTokens, resolvedUsage.totalTokens, estimatedCost, billingDetails, clientContext, downstreamPath,
|
||||
);
|
||||
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, logDownstreamApiKeyId ? downstreamApiKeyId : null);
|
||||
logProxy(selected, requestedModel, 'failed', 0, Date.now() - startTime, err.message, retryCount, downstreamApiKeyId, 0, 0, 0, 0, null, clientContext, downstreamPath);
|
||||
if (retryCount < MAX_RETRIES) {
|
||||
retryCount++;
|
||||
continue;
|
||||
@@ -159,18 +164,25 @@ async function logProxy(
|
||||
totalTokens = 0,
|
||||
estimatedCost = 0,
|
||||
billingDetails: unknown = null,
|
||||
clientContext: DownstreamClientContext | null = null,
|
||||
downstreamPath = '/v1/embeddings',
|
||||
) {
|
||||
try {
|
||||
const createdAt = formatUtcSqlDateTime(new Date());
|
||||
const normalizedErrorMessage = composeProxyLogMessage({
|
||||
downstreamPath: '/v1/embeddings',
|
||||
clientKind: clientContext?.clientKind && clientContext.clientKind !== 'generic'
|
||||
? clientContext.clientKind
|
||||
: null,
|
||||
sessionId: clientContext?.sessionId || null,
|
||||
traceHint: clientContext?.traceHint || null,
|
||||
downstreamPath,
|
||||
errorMessage,
|
||||
});
|
||||
await db.insert(schema.proxyLogs).values({
|
||||
await insertProxyLog({
|
||||
routeId: selected.channel.routeId,
|
||||
channelId: selected.channel.id,
|
||||
accountId: selected.account.id,
|
||||
...(downstreamApiKeyId !== null ? { downstreamApiKeyId } : {}),
|
||||
downstreamApiKeyId,
|
||||
modelRequested,
|
||||
modelActual: selected.actualModel,
|
||||
status,
|
||||
@@ -180,11 +192,15 @@ async function logProxy(
|
||||
completionTokens,
|
||||
totalTokens,
|
||||
estimatedCost,
|
||||
billingDetails: billingDetails ? JSON.stringify(billingDetails) : null,
|
||||
billingDetails,
|
||||
clientFamily: clientContext?.clientKind || null,
|
||||
clientAppId: clientContext?.clientAppId || null,
|
||||
clientAppName: clientContext?.clientAppName || null,
|
||||
clientConfidence: clientContext?.clientConfidence || null,
|
||||
errorMessage: normalizedErrorMessage,
|
||||
retryCount,
|
||||
createdAt,
|
||||
}).run();
|
||||
});
|
||||
} catch (error) {
|
||||
console.warn('[proxy/embeddings] failed to write proxy log', error);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
|
||||
import { fetch } from 'undici';
|
||||
import { db, hasProxyLogDownstreamApiKeyIdColumn, schema } from '../../db/index.js';
|
||||
import { tokenRouter } from '../../services/tokenRouter.js';
|
||||
import { tokenRouter } from '../../services/tokenRouter.js';
|
||||
import { refreshModelsAndRebuildRoutes } from '../../services/modelService.js';
|
||||
import { reportProxyAllFailed, reportTokenExpired } from '../../services/alertService.js';
|
||||
import { isTokenExpiredError } from '../../services/alertRules.js';
|
||||
@@ -14,6 +13,8 @@ import { formatUtcSqlDateTime } from '../../services/localTimeService.js';
|
||||
import { cloneFormDataWithOverrides, ensureMultipartBufferParser, parseMultipartFormData } from './multipart.js';
|
||||
import { getProxyAuthContext } from '../../middleware/auth.js';
|
||||
import { buildUpstreamUrl } from './upstreamUrl.js';
|
||||
import { detectDownstreamClientContext, type DownstreamClientContext } from './downstreamClientContext.js';
|
||||
import { insertProxyLog } from '../../services/proxyLogStore.js';
|
||||
|
||||
const MAX_RETRIES = 2;
|
||||
|
||||
@@ -26,8 +27,12 @@ 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 downstreamPath = '/v1/images/generations';
|
||||
const clientContext = detectDownstreamClientContext({
|
||||
downstreamPath,
|
||||
headers: request.headers as Record<string, unknown>,
|
||||
body,
|
||||
});
|
||||
const excludeChannelIds: number[] = [];
|
||||
let retryCount = 0;
|
||||
|
||||
@@ -70,7 +75,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, logDownstreamApiKeyId ? downstreamApiKeyId : null);
|
||||
logProxy(selected, requestedModel, 'failed', upstream.status, Date.now() - startTime, text, retryCount, downstreamApiKeyId, 0, downstreamPath, clientContext);
|
||||
if (isTokenExpiredError({ status: upstream.status, message: text })) {
|
||||
await reportTokenExpired({
|
||||
accountId: selected.account.id,
|
||||
@@ -104,11 +109,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, logDownstreamApiKeyId ? downstreamApiKeyId : null, estimatedCost);
|
||||
logProxy(selected, requestedModel, 'success', upstream.status, latency, null, retryCount, downstreamApiKeyId, estimatedCost, downstreamPath, clientContext);
|
||||
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, logDownstreamApiKeyId ? downstreamApiKeyId : null);
|
||||
logProxy(selected, requestedModel, 'failed', 0, Date.now() - startTime, err.message, retryCount, downstreamApiKeyId, 0, downstreamPath, clientContext);
|
||||
if (retryCount < MAX_RETRIES) {
|
||||
retryCount++;
|
||||
continue;
|
||||
@@ -136,8 +141,12 @@ 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 downstreamPath = '/v1/images/edits';
|
||||
const clientContext = detectDownstreamClientContext({
|
||||
downstreamPath,
|
||||
headers: request.headers as Record<string, unknown>,
|
||||
body: jsonBody || Object.fromEntries(multipartForm?.entries?.() || []),
|
||||
});
|
||||
const excludeChannelIds: number[] = [];
|
||||
let retryCount = 0;
|
||||
|
||||
@@ -192,7 +201,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, logDownstreamApiKeyId ? downstreamApiKeyId : null, 0, '/v1/images/edits');
|
||||
logProxy(selected, requestedModel, 'failed', upstream.status, Date.now() - startTime, text, retryCount, downstreamApiKeyId, 0, downstreamPath, clientContext);
|
||||
if (isTokenExpiredError({ status: upstream.status, message: text })) {
|
||||
await reportTokenExpired({
|
||||
accountId: selected.account.id,
|
||||
@@ -226,11 +235,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, logDownstreamApiKeyId ? downstreamApiKeyId : null, estimatedCost, '/v1/images/edits');
|
||||
logProxy(selected, requestedModel, 'success', upstream.status, latency, null, retryCount, downstreamApiKeyId, estimatedCost, downstreamPath, clientContext);
|
||||
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, logDownstreamApiKeyId ? downstreamApiKeyId : null, 0, '/v1/images/edits');
|
||||
logProxy(selected, requestedModel, 'failed', 0, Date.now() - startTime, err.message, retryCount, downstreamApiKeyId, 0, downstreamPath, clientContext);
|
||||
if (retryCount < MAX_RETRIES) {
|
||||
retryCount++;
|
||||
continue;
|
||||
@@ -267,18 +276,24 @@ async function logProxy(
|
||||
downstreamApiKeyId: number | null = null,
|
||||
estimatedCost = 0,
|
||||
downstreamPath = '/v1/images/generations',
|
||||
clientContext: DownstreamClientContext | null = null,
|
||||
) {
|
||||
try {
|
||||
const createdAt = formatUtcSqlDateTime(new Date());
|
||||
const normalizedErrorMessage = composeProxyLogMessage({
|
||||
clientKind: clientContext?.clientKind && clientContext.clientKind !== 'generic'
|
||||
? clientContext.clientKind
|
||||
: null,
|
||||
sessionId: clientContext?.sessionId || null,
|
||||
traceHint: clientContext?.traceHint || null,
|
||||
downstreamPath,
|
||||
errorMessage,
|
||||
});
|
||||
await db.insert(schema.proxyLogs).values({
|
||||
await insertProxyLog({
|
||||
routeId: selected.channel.routeId,
|
||||
channelId: selected.channel.id,
|
||||
accountId: selected.account.id,
|
||||
...(downstreamApiKeyId !== null ? { downstreamApiKeyId } : {}),
|
||||
downstreamApiKeyId,
|
||||
modelRequested,
|
||||
modelActual: selected.actualModel,
|
||||
status,
|
||||
@@ -288,10 +303,14 @@ async function logProxy(
|
||||
completionTokens: 0,
|
||||
totalTokens: 0,
|
||||
estimatedCost,
|
||||
clientFamily: clientContext?.clientKind || null,
|
||||
clientAppId: clientContext?.clientAppId || null,
|
||||
clientAppName: clientContext?.clientAppName || null,
|
||||
clientConfidence: clientContext?.clientConfidence || null,
|
||||
errorMessage: normalizedErrorMessage,
|
||||
retryCount,
|
||||
createdAt,
|
||||
}).run();
|
||||
});
|
||||
} catch (error) {
|
||||
console.warn('[proxy/images] failed to write proxy log', error);
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ type ComposeProxyLogMessageArgs = {
|
||||
errorMessage?: string | null;
|
||||
};
|
||||
|
||||
type ParsedPathMeta = {
|
||||
export type ParsedProxyLogMessageMeta = {
|
||||
clientKind: string | null;
|
||||
sessionId: string | null;
|
||||
downstreamPath: string | null;
|
||||
@@ -15,7 +15,7 @@ type ParsedPathMeta = {
|
||||
messageText: string;
|
||||
};
|
||||
|
||||
function parseExistingPathMeta(rawMessage: string): ParsedPathMeta {
|
||||
export function parseProxyLogMessageMeta(rawMessage: string): ParsedProxyLogMessageMeta {
|
||||
const clientMatch = rawMessage.match(/\[client:([^\]]+)\]/i);
|
||||
const sessionMatch = rawMessage.match(/\[session:([^\]]+)\]/i);
|
||||
const downstreamMatch = rawMessage.match(/\[downstream:([^\]]+)\]/i);
|
||||
@@ -42,7 +42,7 @@ export function composeProxyLogMessage({
|
||||
errorMessage,
|
||||
}: ComposeProxyLogMessageArgs): string | null {
|
||||
const rawMessage = typeof errorMessage === 'string' ? errorMessage.trim() : '';
|
||||
const parsed = parseExistingPathMeta(rawMessage);
|
||||
const parsed = parseProxyLogMessageMeta(rawMessage);
|
||||
const finalClientKind = (clientKind || parsed.clientKind || '').trim();
|
||||
const finalSessionId = (sessionId || traceHint || parsed.sessionId || '').trim();
|
||||
const finalDownstreamPath = (downstreamPath || parsed.downstreamPath || '').trim();
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
|
||||
import { fetch } from 'undici';
|
||||
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 +11,8 @@ import { composeProxyLogMessage } from './logPathMeta.js';
|
||||
import { formatUtcSqlDateTime } from '../../services/localTimeService.js';
|
||||
import { getProxyAuthContext } from '../../middleware/auth.js';
|
||||
import { buildUpstreamUrl } from './upstreamUrl.js';
|
||||
import { detectDownstreamClientContext, type DownstreamClientContext } from './downstreamClientContext.js';
|
||||
import { insertProxyLog } from '../../services/proxyLogStore.js';
|
||||
|
||||
const MAX_RETRIES = 2;
|
||||
const DEFAULT_SEARCH_MODEL = '__search';
|
||||
@@ -54,8 +55,12 @@ 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 downstreamPath = '/v1/search';
|
||||
const clientContext = detectDownstreamClientContext({
|
||||
downstreamPath,
|
||||
headers: request.headers as Record<string, unknown>,
|
||||
body,
|
||||
});
|
||||
const excludeChannelIds: number[] = [];
|
||||
let retryCount = 0;
|
||||
|
||||
@@ -101,7 +106,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, logDownstreamApiKeyId ? downstreamApiKeyId : null);
|
||||
logProxy(selected, requestedModel, 'failed', upstream.status, Date.now() - startTime, text, retryCount, downstreamApiKeyId, clientContext, downstreamPath);
|
||||
if (isTokenExpiredError({ status: upstream.status, message: text })) {
|
||||
await reportTokenExpired({
|
||||
accountId: selected.account.id,
|
||||
@@ -127,11 +132,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, logDownstreamApiKeyId ? downstreamApiKeyId : null);
|
||||
logProxy(selected, requestedModel, 'success', upstream.status, latency, null, retryCount, downstreamApiKeyId, clientContext, downstreamPath);
|
||||
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, logDownstreamApiKeyId ? downstreamApiKeyId : null);
|
||||
logProxy(selected, requestedModel, 'failed', 0, Date.now() - startTime, error?.message || 'network error', retryCount, downstreamApiKeyId, clientContext, downstreamPath);
|
||||
if (retryCount < MAX_RETRIES) {
|
||||
retryCount += 1;
|
||||
continue;
|
||||
@@ -157,14 +162,16 @@ async function logProxy(
|
||||
errorMessage: string | null,
|
||||
retryCount: number,
|
||||
downstreamApiKeyId: number | null = null,
|
||||
clientContext: DownstreamClientContext | null = null,
|
||||
downstreamPath = '/v1/search',
|
||||
) {
|
||||
try {
|
||||
const createdAt = formatUtcSqlDateTime(new Date());
|
||||
await db.insert(schema.proxyLogs).values({
|
||||
await insertProxyLog({
|
||||
routeId: selected.channel.routeId,
|
||||
channelId: selected.channel.id,
|
||||
accountId: selected.account.id,
|
||||
...(downstreamApiKeyId !== null ? { downstreamApiKeyId } : {}),
|
||||
downstreamApiKeyId,
|
||||
modelRequested,
|
||||
modelActual: selected.actualModel,
|
||||
status,
|
||||
@@ -175,12 +182,21 @@ async function logProxy(
|
||||
totalTokens: 0,
|
||||
estimatedCost: 0,
|
||||
errorMessage: composeProxyLogMessage({
|
||||
downstreamPath: '/v1/search',
|
||||
clientKind: clientContext?.clientKind && clientContext.clientKind !== 'generic'
|
||||
? clientContext.clientKind
|
||||
: null,
|
||||
sessionId: clientContext?.sessionId || null,
|
||||
traceHint: clientContext?.traceHint || null,
|
||||
downstreamPath,
|
||||
errorMessage,
|
||||
}),
|
||||
clientFamily: clientContext?.clientKind || null,
|
||||
clientAppId: clientContext?.clientAppId || null,
|
||||
clientAppName: clientContext?.clientAppName || null,
|
||||
clientConfidence: clientContext?.clientConfidence || null,
|
||||
retryCount,
|
||||
createdAt,
|
||||
}).run();
|
||||
});
|
||||
} catch (error) {
|
||||
console.warn('[proxy/search] failed to write proxy log', error);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user