Merge pull request #159 from Hureru/feat/account-level-proxy

feat(proxy): account-level proxy URL override
This commit is contained in:
Cita
2026-03-21 13:14:08 +08:00
committed by GitHub
20 changed files with 960 additions and 615 deletions
@@ -7,7 +7,7 @@ import { isTokenExpiredError } from '../../services/alertRules.js';
import { shouldRetryProxyRequest } from '../../services/proxyRetryPolicy.js';
import { resolveProxyUsageWithSelfLogFallback } from '../../services/proxyUsageFallbackService.js';
import { mergeProxyUsage, parseProxyUsage } from '../../services/proxyUsageParser.js';
import { withSiteRecordProxyRequestInit } from '../../services/siteProxy.js';
import { resolveChannelProxyUrl, withSiteRecordProxyRequestInit } from '../../services/siteProxy.js';
import { type DownstreamFormat } from '../../transformers/shared/normalized.js';
import {
buildClaudeCountTokensUpstreamRequest,
@@ -204,6 +204,7 @@ export async function handleChatSurfaceRequest(
runtime: endpointRequest.runtime,
};
};
const channelProxyUrl = resolveChannelProxyUrl(selected.site, selected.account.extraConfig);
const dispatchRequest = (
compatibilityRequest: BuiltEndpointRequest,
targetUrl?: string,
@@ -216,7 +217,7 @@ export async function handleChatSurfaceRequest(
method: 'POST',
headers: requestForFetch.headers,
body: JSON.stringify(requestForFetch.body),
}),
}, channelProxyUrl),
})
);
const endpointStrategy = downstreamTransformer.compatibility.createEndpointStrategy({
@@ -912,7 +913,7 @@ export async function handleClaudeCountTokensSurfaceRequest(
method: 'POST',
headers: requestForFetch.headers,
body: JSON.stringify(requestForFetch.body),
}),
}, resolveChannelProxyUrl(selected.site, selected.account.extraConfig)),
});
if (upstream.status === 401 && oauth) {
@@ -932,7 +933,7 @@ export async function handleClaudeCountTokensSurfaceRequest(
method: 'POST',
headers: requestForFetch.headers,
body: JSON.stringify(requestForFetch.body),
}),
}, resolveChannelProxyUrl(selected.site, selected.account.extraConfig)),
});
} catch {
// Fall through to the regular upstream error handling below.
@@ -9,7 +9,7 @@ import { isModelAllowedByPolicyOrAllowedRoutes } from '../../services/downstream
import { tokenRouter } from '../../services/tokenRouter.js';
import { buildOauthProviderHeaders } from '../../services/oauth/service.js';
import { getOauthInfoFromExtraConfig } from '../../services/oauth/oauthAccount.js';
import { withSiteProxyRequestInit } from '../../services/siteProxy.js';
import { resolveChannelProxyUrl, withSiteRecordProxyRequestInit } from '../../services/siteProxy.js';
import { refreshModelsAndRebuildRoutes } from '../../services/modelService.js';
import { getDownstreamRoutingPolicy } from '../../routes/proxy/downstreamPolicy.js';
import { executeEndpointFlow, type BuiltEndpointRequest } from '../../routes/proxy/endpointFlow.js';
@@ -470,6 +470,7 @@ export async function geminiProxyRoute(app: FastifyInstance) {
selected.tokenValue,
query,
);
const channelProxyUrl = resolveChannelProxyUrl(selected.site, selected.account.extraConfig);
const upstream = isInternalGemini
? await dispatchRuntimeRequest({
siteUrl: selected.site.url,
@@ -489,11 +490,11 @@ export async function geminiProxyRoute(app: FastifyInstance) {
: (isStreamAction ? 'streamGenerateContent' : 'generateContent'),
},
},
buildInit: async (requestUrl, requestForFetch) => withSiteProxyRequestInit(requestUrl, {
buildInit: async (_requestUrl, requestForFetch) => withSiteRecordProxyRequestInit(selected.site, {
method: 'POST',
headers: requestForFetch.headers,
body: JSON.stringify(requestForFetch.body),
}),
}, channelProxyUrl),
})
: await fetch(targetUrl, {
method: 'POST',
@@ -741,16 +742,17 @@ export async function geminiProxyRoute(app: FastifyInstance) {
runtime: endpointRequest.runtime,
};
};
const channelProxyUrl = resolveChannelProxyUrl(selected.site, selected.account.extraConfig);
const dispatchRequest = (compatibilityRequest: BuiltEndpointRequest, targetUrl?: string) => (
dispatchRuntimeRequest({
siteUrl: selected.site.url,
targetUrl,
request: compatibilityRequest,
buildInit: async (requestUrl, requestForFetch) => withSiteProxyRequestInit(requestUrl, {
buildInit: async (_requestUrl, requestForFetch) => withSiteRecordProxyRequestInit(selected.site, {
method: 'POST',
headers: requestForFetch.headers,
body: JSON.stringify(requestForFetch.body),
}),
}, channelProxyUrl),
})
);
const endpointStrategy = createChatEndpointStrategy({
@@ -7,7 +7,7 @@ import { isTokenExpiredError } from '../../services/alertRules.js';
import { shouldRetryProxyRequest } from '../../services/proxyRetryPolicy.js';
import { resolveProxyUsageWithSelfLogFallback } from '../../services/proxyUsageFallbackService.js';
import { mergeProxyUsage, parseProxyUsage } from '../../services/proxyUsageParser.js';
import { withSiteRecordProxyRequestInit } from '../../services/siteProxy.js';
import { resolveChannelProxyUrl, withSiteRecordProxyRequestInit } from '../../services/siteProxy.js';
import { openAiResponsesTransformer } from '../../transformers/openai/responses/index.js';
import {
buildUpstreamEndpointRequest,
@@ -304,6 +304,7 @@ export async function handleOpenAiResponsesSurfaceRequest(
runtime: endpointRequest.runtime,
};
};
const channelProxyUrl = resolveChannelProxyUrl(selected.site, selected.account.extraConfig);
const dispatchRequest = (compatibilityRequest: BuiltEndpointRequest, targetUrl?: string) => (
dispatchRuntimeRequest({
siteUrl: selected.site.url,
@@ -313,7 +314,7 @@ export async function handleOpenAiResponsesSurfaceRequest(
method: 'POST',
headers: requestForFetch.headers,
body: JSON.stringify(requestForFetch.body),
}),
}, channelProxyUrl),
})
);
const endpointStrategy = openAiResponsesTransformer.compatibility.createEndpointStrategy({
+36 -23
View File
@@ -17,8 +17,9 @@ import {
syncTokensFromUpstream,
} from '../../services/accountTokenService.js';
import { getAdapter } from '../../services/platforms/index.js';
import { getCredentialModeFromExtraConfig, resolvePlatformUserId } from '../../services/accountExtraConfig.js';
import { getCredentialModeFromExtraConfig, getProxyUrlFromExtraConfig, resolvePlatformUserId } from '../../services/accountExtraConfig.js';
import { startBackgroundTask } from '../../services/backgroundTaskService.js';
import { withAccountProxyOverride } from '../../services/siteProxy.js';
import {
rebuildTokenRoutesFromAvailability,
refreshModelsForAccount,
@@ -261,15 +262,18 @@ async function executeAccountTokenSync(row: AccountWithSiteRow): Promise<SyncExe
try {
const platformUserId = resolvePlatformUserId(row.accounts.extraConfig, row.accounts.username);
const accountProxyUrl = getProxyUrlFromExtraConfig(row.accounts.extraConfig);
let tokens = await withTimeout(
() => adapter.getApiTokens(row.sites.url, row.accounts.accessToken, platformUserId),
() => withAccountProxyOverride(accountProxyUrl,
() => adapter.getApiTokens(row.sites.url, row.accounts.accessToken, platformUserId)),
TOKEN_SYNC_TIMEOUT_MS,
`token sync timeout (${Math.round(TOKEN_SYNC_TIMEOUT_MS / 1000)}s)`,
);
if (tokens.length === 0) {
const fallback = await withTimeout(
() => adapter.getApiToken(row.sites.url, row.accounts.accessToken, platformUserId),
() => withAccountProxyOverride(accountProxyUrl,
() => adapter.getApiToken(row.sites.url, row.accounts.accessToken, platformUserId)),
TOKEN_SYNC_TIMEOUT_MS,
`token sync timeout (${Math.round(TOKEN_SYNC_TIMEOUT_MS / 1000)}s)`,
);
@@ -579,20 +583,23 @@ export async function accountTokensRoutes(app: FastifyInstance) {
}
const platformUserId = resolvePlatformUserId(account.extraConfig, account.username);
const createdViaUpstream = await adapter.createApiToken(
site.url,
account.accessToken,
platformUserId,
{
name: asTrimmedString(body.name),
group: asTrimmedString(body.group),
unlimitedQuota,
remainQuota,
expiredTime,
allowIps: asTrimmedString(body.allowIps),
modelLimitsEnabled,
modelLimits: asTrimmedString(body.modelLimits),
},
const createdViaUpstream = await withAccountProxyOverride(
getProxyUrlFromExtraConfig(account.extraConfig),
() => adapter.createApiToken(
site.url,
account.accessToken,
platformUserId,
{
name: asTrimmedString(body.name),
group: asTrimmedString(body.group),
unlimitedQuota,
remainQuota,
expiredTime,
allowIps: asTrimmedString(body.allowIps),
modelLimitsEnabled,
modelLimits: asTrimmedString(body.modelLimits),
},
),
);
if (!createdViaUpstream) {
return reply.code(502).send({ success: false, message: '站点创建令牌失败' });
@@ -652,11 +659,14 @@ export async function accountTokensRoutes(app: FastifyInstance) {
if (shouldDeleteUpstream) {
const platformUserId = resolvePlatformUserId(account.extraConfig, account.username);
const upstreamDeleted = await adapter!.deleteApiToken(
site.url,
account.accessToken,
existing.token,
platformUserId,
const upstreamDeleted = await withAccountProxyOverride(
getProxyUrlFromExtraConfig(account.extraConfig),
() => adapter!.deleteApiToken(
site.url,
account.accessToken,
existing.token,
platformUserId,
),
);
if (!upstreamDeleted) {
return { success: false, message: '站点删除令牌失败,本地未删除' };
@@ -904,7 +914,10 @@ export async function accountTokensRoutes(app: FastifyInstance) {
try {
const platformUserId = resolvePlatformUserId(account.extraConfig, account.username);
const groups = await adapter.getUserGroups(site.url, account.accessToken, platformUserId);
const groups = await withAccountProxyOverride(
getProxyUrlFromExtraConfig(account.extraConfig),
() => adapter.getUserGroups(site.url, account.accessToken, platformUserId),
);
const normalized = Array.from(new Set((groups || []).map((item) => String(item || '').trim()).filter(Boolean)));
return { success: true, groups: normalized.length > 0 ? normalized : ['default'] };
} catch (error: any) {
+18 -2
View File
@@ -7,6 +7,7 @@ import { refreshModelsForAccount, rebuildTokenRoutesFromAvailability } from '../
import { ensureDefaultTokenForAccount, syncTokensFromUpstream } from '../../services/accountTokenService.js';
import {
getCredentialModeFromExtraConfig,
getProxyUrlFromExtraConfig,
guessPlatformUserIdFromUsername,
hasOauthProvider,
getSub2ApiAuthFromExtraConfig,
@@ -26,7 +27,7 @@ import {
type RuntimeHealthState,
} from '../../services/accountHealthService.js';
import { appendSessionTokenRebindHint } from '../../services/alertRules.js';
import { withSiteRecordProxyRequestInit } from '../../services/siteProxy.js';
import { parseSiteProxyUrlInput, withAccountProxyOverride, withSiteRecordProxyRequestInit } from '../../services/siteProxy.js';
import { createRateLimitGuard } from '../../middleware/requestRateLimit.js';
type AccountWithSiteRow = {
@@ -981,7 +982,10 @@ export async function accountsRoutes(app: FastifyInstance) {
let verifyResult: any;
try {
verifyResult = await adapter.verifyToken(site.url, nextAccessToken, candidatePlatformUserId);
verifyResult = await withAccountProxyOverride(
getProxyUrlFromExtraConfig(account.extraConfig),
() => adapter.verifyToken(site.url, nextAccessToken, candidatePlatformUserId),
);
} catch (err: any) {
return reply.code(400).send({
success: false,
@@ -1305,6 +1309,18 @@ export async function accountsRoutes(app: FastifyInstance) {
updates.sortOrder = normalizedSortOrder;
}
if (Object.prototype.hasOwnProperty.call(body, 'proxyUrl')) {
const baseExtraConfig = typeof updates.extraConfig === 'string'
? updates.extraConfig : account.extraConfig;
const { present, valid, proxyUrl: normalizedProxy } = parseSiteProxyUrlInput(body.proxyUrl);
if (present && !valid) {
return reply.code(400).send({ message: 'Invalid proxy URL format' });
}
updates.extraConfig = mergeAccountExtraConfig(baseExtraConfig, {
proxyUrl: normalizedProxy ?? undefined,
});
}
updates.updatedAt = new Date().toISOString();
await db.update(schema.accounts).set(updates).where(eq(schema.accounts.id, id)).run();
+2 -1
View File
@@ -9,6 +9,7 @@ import { resolveProxyUsageWithSelfLogFallback } from '../../services/proxyUsageF
import { mergeProxyUsage, parseProxyUsage, pullSseDataEvents } from '../../services/proxyUsageParser.js';
import { ensureModelAllowedForDownstreamKey, getDownstreamRoutingPolicy, recordDownstreamCostUsage } from './downstreamPolicy.js';
import { withSiteRecordProxyRequestInit } from '../../services/siteProxy.js';
import { getProxyUrlFromExtraConfig } from '../../services/accountExtraConfig.js';
import { composeProxyLogMessage } from './logPathMeta.js';
import { formatUtcSqlDateTime } from '../../services/localTimeService.js';
import { detectProxyFailure } from './proxyFailureJudge.js';
@@ -75,7 +76,7 @@ export async function completionsProxyRoute(app: FastifyInstance) {
'Authorization': `Bearer ${selected.tokenValue}`,
},
body: JSON.stringify(forwardBody),
}));
}, getProxyUrlFromExtraConfig(selected.account.extraConfig)));
if (!upstream.ok) {
const errText = await upstream.text().catch(() => 'unknown error');
+195 -194
View File
@@ -1,53 +1,54 @@
import { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
import { fetch } from 'undici';
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';
import { shouldRetryProxyRequest } from '../../services/proxyRetryPolicy.js';
import { resolveProxyUsageWithSelfLogFallback } from '../../services/proxyUsageFallbackService.js';
import { parseProxyUsage } from '../../services/proxyUsageParser.js';
import { ensureModelAllowedForDownstreamKey, getDownstreamRoutingPolicy, recordDownstreamCostUsage } from './downstreamPolicy.js';
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';
import { detectDownstreamClientContext, type DownstreamClientContext } from './downstreamClientContext.js';
import { insertProxyLog } from '../../services/proxyLogStore.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';
import { shouldRetryProxyRequest } from '../../services/proxyRetryPolicy.js';
import { resolveProxyUsageWithSelfLogFallback } from '../../services/proxyUsageFallbackService.js';
import { parseProxyUsage } from '../../services/proxyUsageParser.js';
import { ensureModelAllowedForDownstreamKey, getDownstreamRoutingPolicy, recordDownstreamCostUsage } from './downstreamPolicy.js';
import { withSiteRecordProxyRequestInit } from '../../services/siteProxy.js';
import { getProxyUrlFromExtraConfig } from '../../services/accountExtraConfig.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';
import { detectDownstreamClientContext, type DownstreamClientContext } from './downstreamClientContext.js';
import { insertProxyLog } from '../../services/proxyLogStore.js';
const MAX_RETRIES = 2;
export async function embeddingsProxyRoute(app: FastifyInstance) {
app.post('/v1/embeddings', async (request: FastifyRequest, reply: FastifyReply) => {
const body = request.body as any;
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 downstreamPath = '/v1/embeddings';
const clientContext = detectDownstreamClientContext({
downstreamPath,
headers: request.headers as Record<string, unknown>,
body,
});
const excludeChannelIds: number[] = [];
let retryCount = 0;
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 downstreamPath = '/v1/embeddings';
const clientContext = detectDownstreamClientContext({
downstreamPath,
headers: request.headers as Record<string, unknown>,
body,
});
while (retryCount <= MAX_RETRIES) {
let selected = retryCount === 0
? await tokenRouter.selectChannel(requestedModel, downstreamPolicy)
: await tokenRouter.selectNextChannel(requestedModel, excludeChannelIds, downstreamPolicy);
if (!selected && retryCount === 0) {
await refreshModelsAndRebuildRoutes();
selected = await tokenRouter.selectChannel(requestedModel, downstreamPolicy);
}
const excludeChannelIds: number[] = [];
let retryCount = 0;
while (retryCount <= MAX_RETRIES) {
let selected = retryCount === 0
? await tokenRouter.selectChannel(requestedModel, downstreamPolicy)
: await tokenRouter.selectNextChannel(requestedModel, excludeChannelIds, downstreamPolicy);
if (!selected && retryCount === 0) {
await refreshModelsAndRebuildRoutes();
selected = await tokenRouter.selectChannel(requestedModel, downstreamPolicy);
}
if (!selected) {
await reportProxyAllFailed({
@@ -57,46 +58,46 @@ export async function embeddingsProxyRoute(app: FastifyInstance) {
return reply.code(503).send({ error: { message: 'No available channels', type: 'server_error' } });
}
excludeChannelIds.push(selected.channel.id);
const targetUrl = buildUpstreamUrl(selected.site.url, '/v1/embeddings');
const forwardBody = { ...body, model: selected.actualModel };
const startTime = Date.now();
excludeChannelIds.push(selected.channel.id);
const targetUrl = buildUpstreamUrl(selected.site.url, '/v1/embeddings');
const forwardBody = { ...body, model: selected.actualModel || requestedModel };
const startTime = Date.now();
try {
const upstream = await fetch(targetUrl, withSiteRecordProxyRequestInit(selected.site, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${selected.tokenValue}`,
},
body: JSON.stringify(forwardBody),
}));
const upstream = await fetch(targetUrl, withSiteRecordProxyRequestInit(selected.site, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${selected.tokenValue}`,
},
body: JSON.stringify(forwardBody),
}, getProxyUrlFromExtraConfig(selected.account.extraConfig)));
const text = await upstream.text();
if (!upstream.ok) {
tokenRouter.recordFailure(selected.channel.id, {
status: upstream.status,
errorText: text,
modelName: selected.actualModel,
});
logProxy(
selected,
requestedModel,
'failed',
upstream.status,
Date.now() - startTime,
text,
retryCount,
downstreamApiKeyId,
0,
0,
0,
0,
null,
clientContext,
downstreamPath,
);
const text = await upstream.text();
if (!upstream.ok) {
tokenRouter.recordFailure(selected.channel.id, {
status: upstream.status,
errorText: text,
modelName: selected.actualModel,
});
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({
@@ -107,10 +108,10 @@ export async function embeddingsProxyRoute(app: FastifyInstance) {
});
}
if (shouldRetryProxyRequest(upstream.status, text) && retryCount < MAX_RETRIES) {
retryCount++;
continue;
}
if (shouldRetryProxyRequest(upstream.status, text) && retryCount < MAX_RETRIES) {
retryCount++;
continue;
}
await reportProxyAllFailed({
model: requestedModel,
@@ -119,63 +120,63 @@ export async function embeddingsProxyRoute(app: FastifyInstance) {
return reply.code(upstream.status).send({ error: { message: text, type: 'upstream_error' } });
}
let data: any = {};
try { data = JSON.parse(text); } catch { data = {}; }
const latency = Date.now() - startTime;
const parsedUsage = parseProxyUsage(data);
const resolvedUsage = await resolveProxyUsageWithSelfLogFallback({
site: selected.site,
account: selected.account,
tokenValue: selected.tokenValue,
tokenName: selected.tokenName,
modelName: selected.actualModel || requestedModel,
requestStartedAtMs: startTime,
requestEndedAtMs: startTime + latency,
localLatencyMs: latency,
usage: {
promptTokens: parsedUsage.promptTokens,
completionTokens: parsedUsage.completionTokens,
totalTokens: parsedUsage.totalTokens,
},
});
const { estimatedCost, billingDetails } = await resolveProxyLogBilling({
site: selected.site,
account: selected.account,
modelName: selected.actualModel || requestedModel,
parsedUsage,
resolvedUsage,
});
tokenRouter.recordSuccess(selected.channel.id, latency, estimatedCost, selected.actualModel);
recordDownstreamCostUsage(request, estimatedCost);
logProxy(
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, {
status: 0,
errorText: err.message,
modelName: selected.actualModel,
});
logProxy(
selected,
requestedModel,
'failed',
0,
Date.now() - startTime,
err.message,
retryCount,
downstreamApiKeyId,
0,
0,
0,
0,
null,
clientContext,
downstreamPath,
);
let data: any = {};
try { data = JSON.parse(text); } catch { data = {}; }
const latency = Date.now() - startTime;
const parsedUsage = parseProxyUsage(data);
const resolvedUsage = await resolveProxyUsageWithSelfLogFallback({
site: selected.site,
account: selected.account,
tokenValue: selected.tokenValue,
tokenName: selected.tokenName,
modelName: selected.actualModel || requestedModel,
requestStartedAtMs: startTime,
requestEndedAtMs: startTime + latency,
localLatencyMs: latency,
usage: {
promptTokens: parsedUsage.promptTokens,
completionTokens: parsedUsage.completionTokens,
totalTokens: parsedUsage.totalTokens,
},
});
const { estimatedCost, billingDetails } = await resolveProxyLogBilling({
site: selected.site,
account: selected.account,
modelName: selected.actualModel || requestedModel,
parsedUsage,
resolvedUsage,
});
tokenRouter.recordSuccess(selected.channel.id, latency, estimatedCost, selected.actualModel);
recordDownstreamCostUsage(request, estimatedCost);
logProxy(
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, {
status: 0,
errorText: err.message,
modelName: selected.actualModel,
});
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;
@@ -190,59 +191,59 @@ export async function embeddingsProxyRoute(app: FastifyInstance) {
});
}
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,
clientContext: DownstreamClientContext | null = null,
downstreamPath = '/v1/embeddings',
) {
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,
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/embeddings] failed to write proxy log', error);
}
}
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,
clientContext: DownstreamClientContext | null = null,
downstreamPath = '/v1/embeddings',
) {
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,
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/embeddings] failed to write proxy log', error);
}
}
+334 -333
View File
@@ -1,50 +1,51 @@
import { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
import { fetch } from 'undici';
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';
import { estimateProxyCost } from '../../services/modelPricingService.js';
import { shouldRetryProxyRequest } from '../../services/proxyRetryPolicy.js';
import { ensureModelAllowedForDownstreamKey, getDownstreamRoutingPolicy, recordDownstreamCostUsage } from './downstreamPolicy.js';
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';
import { detectDownstreamClientContext, type DownstreamClientContext } from './downstreamClientContext.js';
import { insertProxyLog } from '../../services/proxyLogStore.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';
import { estimateProxyCost } from '../../services/modelPricingService.js';
import { shouldRetryProxyRequest } from '../../services/proxyRetryPolicy.js';
import { ensureModelAllowedForDownstreamKey, getDownstreamRoutingPolicy, recordDownstreamCostUsage } from './downstreamPolicy.js';
import { withSiteRecordProxyRequestInit } from '../../services/siteProxy.js';
import { getProxyUrlFromExtraConfig } from '../../services/accountExtraConfig.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';
import { detectDownstreamClientContext, type DownstreamClientContext } from './downstreamClientContext.js';
import { insertProxyLog } from '../../services/proxyLogStore.js';
const MAX_RETRIES = 2;
export async function imagesProxyRoute(app: FastifyInstance) {
ensureMultipartBufferParser(app);
app.post('/v1/images/generations', async (request: FastifyRequest, reply: FastifyReply) => {
const body = request.body as any;
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 downstreamPath = '/v1/images/generations';
const clientContext = detectDownstreamClientContext({
downstreamPath,
headers: request.headers as Record<string, unknown>,
body,
});
const excludeChannelIds: number[] = [];
let retryCount = 0;
while (retryCount <= MAX_RETRIES) {
let selected = retryCount === 0
? await tokenRouter.selectChannel(requestedModel, downstreamPolicy)
: await tokenRouter.selectNextChannel(requestedModel, excludeChannelIds, downstreamPolicy);
if (!selected && retryCount === 0) {
await refreshModelsAndRebuildRoutes();
selected = await tokenRouter.selectChannel(requestedModel, downstreamPolicy);
}
export async function imagesProxyRoute(app: FastifyInstance) {
ensureMultipartBufferParser(app);
app.post('/v1/images/generations', async (request: FastifyRequest, reply: FastifyReply) => {
const body = request.body as any;
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 downstreamPath = '/v1/images/generations';
const clientContext = detectDownstreamClientContext({
downstreamPath,
headers: request.headers as Record<string, unknown>,
body,
});
const excludeChannelIds: number[] = [];
let retryCount = 0;
while (retryCount <= MAX_RETRIES) {
let selected = retryCount === 0
? await tokenRouter.selectChannel(requestedModel, downstreamPolicy)
: await tokenRouter.selectNextChannel(requestedModel, excludeChannelIds, downstreamPolicy);
if (!selected && retryCount === 0) {
await refreshModelsAndRebuildRoutes();
selected = await tokenRouter.selectChannel(requestedModel, downstreamPolicy);
}
if (!selected) {
await reportProxyAllFailed({
@@ -58,52 +59,52 @@ export async function imagesProxyRoute(app: FastifyInstance) {
excludeChannelIds.push(selected.channel.id);
const targetUrl = buildUpstreamUrl(selected.site.url, '/v1/images/generations');
const targetUrl = buildUpstreamUrl(selected.site.url, '/v1/images/generations');
const forwardBody = { ...body, model: selected.actualModel };
const startTime = Date.now();
try {
const upstream = await fetch(targetUrl, withSiteRecordProxyRequestInit(selected.site, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${selected.tokenValue}`,
},
body: JSON.stringify(forwardBody),
}));
const upstream = await fetch(targetUrl, withSiteRecordProxyRequestInit(selected.site, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${selected.tokenValue}`,
},
body: JSON.stringify(forwardBody),
}, getProxyUrlFromExtraConfig(selected.account.extraConfig)));
const text = await upstream.text();
if (!upstream.ok) {
tokenRouter.recordFailure(selected.channel.id, {
status: upstream.status,
errorText: text,
modelName: selected.actualModel,
});
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,
username: selected.account.username,
siteName: selected.site.name,
const text = await upstream.text();
if (!upstream.ok) {
tokenRouter.recordFailure(selected.channel.id, {
status: upstream.status,
errorText: text,
modelName: selected.actualModel,
});
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,
username: selected.account.username,
siteName: selected.site.name,
detail: `HTTP ${upstream.status}`,
});
}
if (shouldRetryProxyRequest(upstream.status, text) && retryCount < MAX_RETRIES) {
retryCount++;
continue;
}
if (shouldRetryProxyRequest(upstream.status, text) && retryCount < MAX_RETRIES) {
retryCount++;
continue;
}
await reportProxyAllFailed({
model: requestedModel,
reason: `upstream returned HTTP ${upstream.status}`,
@@ -115,41 +116,41 @@ export async function imagesProxyRoute(app: FastifyInstance) {
try { data = JSON.parse(text); } catch { data = { data: [] }; }
const latency = Date.now() - startTime;
const estimatedCost = await estimateProxyCost({
site: selected.site,
account: selected.account,
modelName: selected.actualModel || requestedModel,
const estimatedCost = await estimateProxyCost({
site: selected.site,
account: selected.account,
modelName: selected.actualModel || requestedModel,
promptTokens: 0,
completionTokens: 0,
totalTokens: 0,
});
tokenRouter.recordSuccess(selected.channel.id, latency, estimatedCost, selected.actualModel);
recordDownstreamCostUsage(request, 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, {
status: 0,
errorText: err.message,
modelName: selected.actualModel,
});
logProxy(
selected,
requestedModel,
'failed',
0,
Date.now() - startTime,
err.message,
retryCount,
downstreamApiKeyId,
0,
downstreamPath,
clientContext,
);
if (retryCount < MAX_RETRIES) {
retryCount++;
continue;
}
});
tokenRouter.recordSuccess(selected.channel.id, latency, estimatedCost, selected.actualModel);
recordDownstreamCostUsage(request, 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, {
status: 0,
errorText: err.message,
modelName: selected.actualModel,
});
logProxy(
selected,
requestedModel,
'failed',
0,
Date.now() - startTime,
err.message,
retryCount,
downstreamApiKeyId,
0,
downstreamPath,
clientContext,
);
if (retryCount < MAX_RETRIES) {
retryCount++;
continue;
}
await reportProxyAllFailed({
model: requestedModel,
reason: err.message || 'network failure',
@@ -157,226 +158,226 @@ export async function imagesProxyRoute(app: FastifyInstance) {
return reply.code(502).send({
error: { message: `Upstream error: ${err.message}`, type: 'upstream_error' },
});
}
}
});
app.post('/v1/images/edits', async (request: FastifyRequest, reply: FastifyReply) => {
const multipartForm = await parseMultipartFormData(request);
const jsonBody = (!multipartForm && request.body && typeof request.body === 'object')
? request.body as Record<string, unknown>
: null;
const requestedModel = typeof multipartForm?.get('model') === 'string'
? String(multipartForm.get('model')).trim()
: (typeof jsonBody?.model === 'string' ? jsonBody.model.trim() : '') || 'gpt-image-1';
if (!await ensureModelAllowedForDownstreamKey(request, reply, requestedModel)) return;
const downstreamPolicy = getDownstreamRoutingPolicy(request);
const downstreamApiKeyId = getProxyAuthContext(request)?.keyId ?? null;
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;
while (retryCount <= MAX_RETRIES) {
let selected = retryCount === 0
? await tokenRouter.selectChannel(requestedModel, downstreamPolicy)
: await tokenRouter.selectNextChannel(requestedModel, excludeChannelIds, downstreamPolicy);
if (!selected && retryCount === 0) {
await refreshModelsAndRebuildRoutes();
selected = await tokenRouter.selectChannel(requestedModel, downstreamPolicy);
}
if (!selected) {
await reportProxyAllFailed({
model: requestedModel,
reason: 'No available channels after retries',
});
return reply.code(503).send({
error: { message: 'No available channels for this model', type: 'server_error' },
});
}
excludeChannelIds.push(selected.channel.id);
const targetUrl = buildUpstreamUrl(selected.site.url, '/v1/images/edits');
const startTime = Date.now();
try {
const requestInit = multipartForm
? withSiteRecordProxyRequestInit(selected.site, {
method: 'POST',
headers: {
Authorization: `Bearer ${selected.tokenValue}`,
},
body: cloneFormDataWithOverrides(multipartForm, {
model: selected.actualModel || requestedModel,
}) as any,
})
: withSiteRecordProxyRequestInit(selected.site, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${selected.tokenValue}`,
},
body: JSON.stringify({
...(jsonBody || {}),
model: selected.actualModel || requestedModel,
}),
});
const upstream = await fetch(targetUrl, requestInit);
const text = await upstream.text();
if (!upstream.ok) {
tokenRouter.recordFailure(selected.channel.id, {
status: upstream.status,
errorText: text,
modelName: selected.actualModel,
});
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,
username: selected.account.username,
siteName: selected.site.name,
detail: `HTTP ${upstream.status}`,
});
}
if (shouldRetryProxyRequest(upstream.status, text) && retryCount < MAX_RETRIES) {
retryCount++;
continue;
}
await reportProxyAllFailed({
model: requestedModel,
reason: `upstream returned HTTP ${upstream.status}`,
});
return reply.code(upstream.status).send({ error: { message: text, type: 'upstream_error' } });
}
let data: any = {};
try { data = JSON.parse(text); } catch { data = { data: [] }; }
const latency = Date.now() - startTime;
const estimatedCost = await estimateProxyCost({
site: selected.site,
account: selected.account,
modelName: selected.actualModel || requestedModel,
promptTokens: 0,
completionTokens: 0,
totalTokens: 0,
});
tokenRouter.recordSuccess(selected.channel.id, latency, estimatedCost, selected.actualModel);
recordDownstreamCostUsage(request, 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, {
status: 0,
errorText: err.message,
modelName: selected.actualModel,
});
logProxy(
selected,
requestedModel,
'failed',
0,
Date.now() - startTime,
err.message,
retryCount,
downstreamApiKeyId,
0,
downstreamPath,
clientContext,
);
if (retryCount < MAX_RETRIES) {
retryCount++;
continue;
}
await reportProxyAllFailed({
model: requestedModel,
reason: err.message || 'network failure',
});
return reply.code(502).send({
error: { message: `Upstream error: ${err.message}`, type: 'upstream_error' },
});
}
}
});
app.post('/v1/images/variations', async (_request: FastifyRequest, reply: FastifyReply) => {
return reply.code(400).send({
error: {
message: 'Image variations are not supported',
type: 'invalid_request_error',
},
});
});
}
}
}
});
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',
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 insertProxyLog({
routeId: selected.channel.routeId,
channelId: selected.channel.id,
accountId: selected.account.id,
downstreamApiKeyId,
modelRequested,
modelActual: selected.actualModel,
status,
httpStatus,
latencyMs,
promptTokens: 0,
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,
});
} catch (error) {
console.warn('[proxy/images] failed to write proxy log', error);
}
}
app.post('/v1/images/edits', async (request: FastifyRequest, reply: FastifyReply) => {
const multipartForm = await parseMultipartFormData(request);
const jsonBody = (!multipartForm && request.body && typeof request.body === 'object')
? request.body as Record<string, unknown>
: null;
const requestedModel = typeof multipartForm?.get('model') === 'string'
? String(multipartForm.get('model')).trim()
: (typeof jsonBody?.model === 'string' ? jsonBody.model.trim() : '') || 'gpt-image-1';
if (!await ensureModelAllowedForDownstreamKey(request, reply, requestedModel)) return;
const downstreamPolicy = getDownstreamRoutingPolicy(request);
const downstreamApiKeyId = getProxyAuthContext(request)?.keyId ?? null;
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;
while (retryCount <= MAX_RETRIES) {
let selected = retryCount === 0
? await tokenRouter.selectChannel(requestedModel, downstreamPolicy)
: await tokenRouter.selectNextChannel(requestedModel, excludeChannelIds, downstreamPolicy);
if (!selected && retryCount === 0) {
await refreshModelsAndRebuildRoutes();
selected = await tokenRouter.selectChannel(requestedModel, downstreamPolicy);
}
if (!selected) {
await reportProxyAllFailed({
model: requestedModel,
reason: 'No available channels after retries',
});
return reply.code(503).send({
error: { message: 'No available channels for this model', type: 'server_error' },
});
}
excludeChannelIds.push(selected.channel.id);
const targetUrl = buildUpstreamUrl(selected.site.url, '/v1/images/edits');
const startTime = Date.now();
try {
const requestInit = multipartForm
? withSiteRecordProxyRequestInit(selected.site, {
method: 'POST',
headers: {
Authorization: `Bearer ${selected.tokenValue}`,
},
body: cloneFormDataWithOverrides(multipartForm, {
model: selected.actualModel || requestedModel,
}) as any,
}, getProxyUrlFromExtraConfig(selected.account.extraConfig))
: withSiteRecordProxyRequestInit(selected.site, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${selected.tokenValue}`,
},
body: JSON.stringify({
...(jsonBody || {}),
model: selected.actualModel || requestedModel,
}),
}, getProxyUrlFromExtraConfig(selected.account.extraConfig));
const upstream = await fetch(targetUrl, requestInit);
const text = await upstream.text();
if (!upstream.ok) {
tokenRouter.recordFailure(selected.channel.id, {
status: upstream.status,
errorText: text,
modelName: selected.actualModel,
});
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,
username: selected.account.username,
siteName: selected.site.name,
detail: `HTTP ${upstream.status}`,
});
}
if (shouldRetryProxyRequest(upstream.status, text) && retryCount < MAX_RETRIES) {
retryCount++;
continue;
}
await reportProxyAllFailed({
model: requestedModel,
reason: `upstream returned HTTP ${upstream.status}`,
});
return reply.code(upstream.status).send({ error: { message: text, type: 'upstream_error' } });
}
let data: any = {};
try { data = JSON.parse(text); } catch { data = { data: [] }; }
const latency = Date.now() - startTime;
const estimatedCost = await estimateProxyCost({
site: selected.site,
account: selected.account,
modelName: selected.actualModel || requestedModel,
promptTokens: 0,
completionTokens: 0,
totalTokens: 0,
});
tokenRouter.recordSuccess(selected.channel.id, latency, estimatedCost, selected.actualModel);
recordDownstreamCostUsage(request, 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, {
status: 0,
errorText: err.message,
modelName: selected.actualModel,
});
logProxy(
selected,
requestedModel,
'failed',
0,
Date.now() - startTime,
err.message,
retryCount,
downstreamApiKeyId,
0,
downstreamPath,
clientContext,
);
if (retryCount < MAX_RETRIES) {
retryCount++;
continue;
}
await reportProxyAllFailed({
model: requestedModel,
reason: err.message || 'network failure',
});
return reply.code(502).send({
error: { message: `Upstream error: ${err.message}`, type: 'upstream_error' },
});
}
}
});
app.post('/v1/images/variations', async (_request: FastifyRequest, reply: FastifyReply) => {
return reply.code(400).send({
error: {
message: 'Image variations are not supported',
type: 'invalid_request_error',
},
});
});
}
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',
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 insertProxyLog({
routeId: selected.channel.routeId,
channelId: selected.channel.id,
accountId: selected.account.id,
downstreamApiKeyId,
modelRequested,
modelActual: selected.actualModel,
status,
httpStatus,
latencyMs,
promptTokens: 0,
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,
});
} catch (error) {
console.warn('[proxy/images] failed to write proxy log', error);
}
}
+2 -1
View File
@@ -7,6 +7,7 @@ import { isTokenExpiredError } from '../../services/alertRules.js';
import { shouldRetryProxyRequest } from '../../services/proxyRetryPolicy.js';
import { ensureModelAllowedForDownstreamKey, getDownstreamRoutingPolicy, recordDownstreamCostUsage } from './downstreamPolicy.js';
import { withSiteRecordProxyRequestInit } from '../../services/siteProxy.js';
import { getProxyUrlFromExtraConfig } from '../../services/accountExtraConfig.js';
import { composeProxyLogMessage } from './logPathMeta.js';
import { formatUtcSqlDateTime } from '../../services/localTimeService.js';
import { getProxyAuthContext } from '../../middleware/auth.js';
@@ -101,7 +102,7 @@ export async function searchProxyRoute(app: FastifyInstance) {
Authorization: `Bearer ${selected.tokenValue}`,
},
body: JSON.stringify(forwardBody),
}));
}, getProxyUrlFromExtraConfig(selected.account.extraConfig)));
const text = await upstream.text();
if (!upstream.ok) {
+4 -2
View File
@@ -8,6 +8,7 @@ import { estimateProxyCost } from '../../services/modelPricingService.js';
import { shouldRetryProxyRequest } from '../../services/proxyRetryPolicy.js';
import { ensureModelAllowedForDownstreamKey, getDownstreamRoutingPolicy, recordDownstreamCostUsage } from './downstreamPolicy.js';
import { withSiteProxyRequestInit, withSiteRecordProxyRequestInit } from '../../services/siteProxy.js';
import { getProxyUrlFromExtraConfig } from '../../services/accountExtraConfig.js';
import { cloneFormDataWithOverrides, ensureMultipartBufferParser, parseMultipartFormData } from './multipart.js';
import { buildUpstreamUrl } from './upstreamUrl.js';
import {
@@ -75,6 +76,7 @@ export async function videosProxyRoute(app: FastifyInstance) {
const startTime = Date.now();
try {
const accountProxy = getProxyUrlFromExtraConfig(selected.account.extraConfig);
const requestInit = multipartForm
? withSiteRecordProxyRequestInit(selected.site, {
method: 'POST',
@@ -84,7 +86,7 @@ export async function videosProxyRoute(app: FastifyInstance) {
body: cloneFormDataWithOverrides(multipartForm, {
model: selected.actualModel || requestedModel,
}) as any,
})
}, accountProxy)
: withSiteRecordProxyRequestInit(selected.site, {
method: 'POST',
headers: {
@@ -95,7 +97,7 @@ export async function videosProxyRoute(app: FastifyInstance) {
...(jsonBody || {}),
model: selected.actualModel || requestedModel,
}),
});
}, accountProxy);
const upstream = await fetch(targetUrl, requestInit);
const text = await upstream.text();
@@ -3,6 +3,7 @@ import {
buildStoredSub2ApiSubscriptionSummary,
getCredentialModeFromExtraConfig,
getPlatformUserIdFromExtraConfig,
getProxyUrlFromExtraConfig,
getSub2ApiAuthFromExtraConfig,
getSub2ApiSubscriptionFromExtraConfig,
guessPlatformUserIdFromUsername,
@@ -72,6 +73,20 @@ describe('accountExtraConfig', () => {
}))).toBeNull();
});
it('reads proxyUrl from extra config', () => {
expect(getProxyUrlFromExtraConfig(JSON.stringify({ proxyUrl: 'http://127.0.0.1:7890' }))).toBe('http://127.0.0.1:7890');
expect(getProxyUrlFromExtraConfig(JSON.stringify({ proxyUrl: ' socks5://proxy.local:1080 ' }))).toBe('socks5://proxy.local:1080');
});
it('returns null for missing or empty proxyUrl', () => {
expect(getProxyUrlFromExtraConfig(JSON.stringify({}))).toBeNull();
expect(getProxyUrlFromExtraConfig(JSON.stringify({ proxyUrl: '' }))).toBeNull();
expect(getProxyUrlFromExtraConfig(JSON.stringify({ proxyUrl: ' ' }))).toBeNull();
expect(getProxyUrlFromExtraConfig(null)).toBeNull();
expect(getProxyUrlFromExtraConfig(undefined)).toBeNull();
expect(getProxyUrlFromExtraConfig('invalid-json')).toBeNull();
});
it('treats auto-mode api token connections as direct-account routable', () => {
expect(supportsDirectAccountRoutingConnection({
accessToken: '',
@@ -114,6 +114,11 @@ export function normalizeCredentialMode(raw: unknown): AccountCredentialMode | u
return normalized as AccountCredentialMode;
}
export function getProxyUrlFromExtraConfig(extraConfig?: string | null): string | null {
const parsed = parseExtraConfig(extraConfig);
return normalizeNonEmptyString(parsed.proxyUrl) ?? null;
}
export function getPlatformUserIdFromExtraConfig(extraConfig?: string | null): number | undefined {
const parsed = parseExtraConfig(extraConfig);
return normalizeUserId(parsed.platformUserId);
+17 -11
View File
@@ -7,6 +7,7 @@ import {
buildStoredSub2ApiSubscriptionSummary,
getAutoReloginConfig,
getCredentialModeFromExtraConfig,
getProxyUrlFromExtraConfig,
getSub2ApiAuthFromExtraConfig,
mergeAccountExtraConfig,
resolvePlatformUserId,
@@ -15,7 +16,7 @@ import { decryptAccountPassword } from './accountCredentialService.js';
import { extractRuntimeHealth, setAccountRuntimeHealth } from './accountHealthService.js';
import { updateTodayIncomeSnapshot } from './todayIncomeRewardService.js';
import type { BalanceInfo } from './platforms/base.js';
import { withSiteProxyRequestInit, withSiteRecordProxyRequestInit } from './siteProxy.js';
import { withAccountProxyOverride, withSiteProxyRequestInit, withSiteRecordProxyRequestInit } from './siteProxy.js';
function isSiteDisabled(status?: string | null): boolean {
return (status || 'active') === 'disabled';
@@ -200,7 +201,7 @@ async function refreshSub2ApiManagedSession(params: {
method: 'POST',
headers,
body: JSON.stringify({ refresh_token: refreshToken }),
}));
}, getProxyUrlFromExtraConfig(params.account.extraConfig)));
payload = await response.json().catch(() => null);
} catch (err: any) {
throw new Error(err?.message || 'sub2api token refresh request failed');
@@ -317,7 +318,10 @@ async function tryAutoRelogin(account: any, site: any): Promise<string | null> {
const password = decryptAccountPassword(relogin.passwordCipher);
if (!password) return null;
const loginResult = await adapter.login(site.url, relogin.username, password);
const loginResult = await withAccountProxyOverride(
getProxyUrlFromExtraConfig(account.extraConfig),
() => adapter.login(site.url, relogin.username, password),
);
if (!loginResult.success || !loginResult.accessToken) return null;
await db.update(schema.accounts)
@@ -378,23 +382,25 @@ export async function refreshBalance(accountId: number) {
let activeExtraConfig = account.extraConfig;
let balanceInfo: BalanceInfo | null = null;
const accountProxyUrl = getProxyUrlFromExtraConfig(account.extraConfig);
if (isSub2ApiPlatform(site.platform)) {
const managedAuth = getSub2ApiAuthFromExtraConfig(activeExtraConfig);
if (managedAuth?.refreshToken && isNearTokenExpiry(managedAuth.tokenExpiresAt)) {
try {
const refreshed = await refreshSub2ApiManagedSession({
const refreshed = await withAccountProxyOverride(accountProxyUrl, () => refreshSub2ApiManagedSession({
account,
site,
currentAccessToken: activeAccessToken,
currentExtraConfig: activeExtraConfig,
});
}));
activeAccessToken = refreshed.accessToken;
activeExtraConfig = refreshed.extraConfig;
} catch {}
}
}
const readBalance = async (token: string) => adapter.getBalance(site.url, token, platformUserId);
const readBalance = async (token: string) => withAccountProxyOverride(accountProxyUrl,
() => adapter.getBalance(site.url, token, platformUserId));
const handleBalanceError = async (err: any) => {
const message = appendSessionTokenRebindHint(err?.message || 'unknown error');
setAccountRuntimeHealth(account.id, {
@@ -424,12 +430,12 @@ export async function refreshBalance(accountId: number) {
if (canTryManagedSub2ApiRefresh) {
try {
const refreshed = await refreshSub2ApiManagedSession({
const refreshed = await withAccountProxyOverride(accountProxyUrl, () => refreshSub2ApiManagedSession({
account,
site,
currentAccessToken: activeAccessToken,
currentExtraConfig: activeExtraConfig,
});
}));
activeAccessToken = refreshed.accessToken;
activeExtraConfig = refreshed.extraConfig;
balanceInfo = await readBalance(activeAccessToken);
@@ -462,12 +468,12 @@ export async function refreshBalance(accountId: number) {
supportsTodayIncomeLogFallback(site.platform)
) {
try {
const fallbackIncome = await fetchTodayIncomeFromLogs({
const fallbackIncome = await withAccountProxyOverride(accountProxyUrl, () => fetchTodayIncomeFromLogs({
baseUrl: site.url,
accessToken: activeAccessToken,
platform: site.platform,
platformUserId,
});
}));
if (typeof fallbackIncome === 'number' && Number.isFinite(fallbackIncome)) {
balanceInfo.todayIncome = fallbackIncome;
}
+11 -3
View File
@@ -8,6 +8,7 @@ import { refreshBalance } from './balanceService.js';
import { parseCheckinRewardAmount } from './checkinRewardParser.js';
import {
getAutoReloginConfig,
getProxyUrlFromExtraConfig,
getPlatformUserIdFromExtraConfig,
guessPlatformUserIdFromUsername,
mergeAccountExtraConfig,
@@ -16,6 +17,7 @@ import {
import { decryptAccountPassword } from './accountCredentialService.js';
import { setAccountRuntimeHealth } from './accountHealthService.js';
import { formatUtcSqlDateTime } from './localTimeService.js';
import { withAccountProxyOverride } from './siteProxy.js';
type CheckinExecutionStatus = 'success' | 'failed' | 'skipped';
@@ -100,7 +102,10 @@ async function tryAutoRelogin(account: any, site: any): Promise<string | null> {
const password = decryptAccountPassword(relogin.passwordCipher);
if (!password) return null;
const result = await adapter.login(site.url, relogin.username, password);
const result = await withAccountProxyOverride(
getProxyUrlFromExtraConfig(account.extraConfig),
() => adapter.login(site.url, relogin.username, password),
);
if (!result.success || !result.accessToken) return null;
await db.update(schema.accounts)
@@ -172,14 +177,17 @@ export async function checkinAccount(accountId: number, options?: { skipEvent?:
: guessPlatformUserIdFromUsername(account.username);
const platformUserId = resolvePlatformUserId(account.extraConfig, account.username);
const accountProxyUrl = getProxyUrlFromExtraConfig(account.extraConfig);
let activeAccessToken = account.accessToken;
let result = await adapter.checkin(site.url, activeAccessToken, platformUserId);
let result = await withAccountProxyOverride(accountProxyUrl,
() => adapter.checkin(site.url, activeAccessToken, platformUserId));
if (!result.success && shouldAttemptAutoRelogin(result.message)) {
const refreshedAccessToken = await tryAutoRelogin(account, site);
if (refreshedAccessToken) {
activeAccessToken = refreshedAccessToken;
result = await adapter.checkin(site.url, activeAccessToken, platformUserId);
result = await withAccountProxyOverride(accountProxyUrl,
() => adapter.checkin(site.url, activeAccessToken, platformUserId));
}
}
@@ -7,6 +7,18 @@ import { eq } from 'drizzle-orm';
const getApiTokenMock = vi.fn();
const getModelsMock = vi.fn();
const undiciFetchMock = vi.fn();
const proxyAgentCtorMock = vi.fn();
class MockProxyAgent {
readonly proxyUrl: string;
constructor(proxyUrl: string) {
this.proxyUrl = proxyUrl;
proxyAgentCtorMock(proxyUrl);
}
}
class MockAgent {}
vi.mock('./platforms/index.js', () => ({
getAdapter: () => ({
@@ -17,6 +29,8 @@ vi.mock('./platforms/index.js', () => ({
vi.mock('undici', () => ({
fetch: (...args: unknown[]) => undiciFetchMock(...args),
ProxyAgent: MockProxyAgent,
Agent: MockAgent,
}));
type DbModule = typeof import('../db/index.js');
@@ -47,6 +61,7 @@ describe('refreshModelsForAccount credential discovery', () => {
getApiTokenMock.mockReset();
getModelsMock.mockReset();
undiciFetchMock.mockReset();
proxyAgentCtorMock.mockReset();
await db.delete(schema.routeChannels).run();
await db.delete(schema.tokenRoutes).run();
@@ -595,6 +610,115 @@ describe('refreshModelsForAccount credential discovery', () => {
expect(parsed.runtimeHealth?.state).toBe('unhealthy');
});
it('applies account proxy override to codex oauth cloud discovery requests', async () => {
getApiTokenMock.mockResolvedValue(null);
getModelsMock.mockRejectedValue(new Error('codex oauth discovery should not call adapter.getModels'));
undiciFetchMock.mockResolvedValue({
ok: true,
status: 200,
json: async () => ({
models: [
{ id: 'gpt-5.4' },
],
}),
text: async () => JSON.stringify({ ok: true }),
});
const site = await db.insert(schema.sites).values({
name: 'codex-account-proxy-site',
url: 'https://chatgpt.com/backend-api/codex',
platform: 'codex',
status: 'active',
}).returning().get();
const account = await db.insert(schema.accounts).values({
siteId: site.id,
username: 'codex-proxy-user@example.com',
accessToken: 'oauth-access-token',
apiToken: null,
status: 'active',
extraConfig: JSON.stringify({
credentialMode: 'session',
proxyUrl: 'http://127.0.0.1:7890',
oauth: {
provider: 'codex',
accountId: 'chatgpt-account-proxy',
email: 'codex-proxy-user@example.com',
planType: 'plus',
},
}),
}).returning().get();
const result = await refreshModelsForAccount(account.id);
expect(result).toMatchObject({
accountId: account.id,
refreshed: true,
status: 'success',
modelCount: 1,
modelsPreview: ['gpt-5.4'],
});
expect(undiciFetchMock).toHaveBeenCalledTimes(1);
expect(proxyAgentCtorMock).toHaveBeenCalledWith('http://127.0.0.1:7890');
expect(undiciFetchMock.mock.calls[0]?.[1]).toMatchObject({
method: 'GET',
dispatcher: expect.any(MockProxyAgent),
});
});
it('applies account proxy override to gemini oauth validation requests', async () => {
getApiTokenMock.mockResolvedValue(null);
getModelsMock.mockRejectedValue(new Error('gemini oauth validation should not call adapter.getModels'));
undiciFetchMock.mockResolvedValue({
ok: true,
status: 200,
json: async () => ({
state: 'ENABLED',
}),
text: async () => JSON.stringify({ state: 'ENABLED' }),
});
const site = await db.insert(schema.sites).values({
name: 'gemini-account-proxy-site',
url: 'https://gemini.example.com',
platform: 'gemini',
status: 'active',
}).returning().get();
const account = await db.insert(schema.accounts).values({
siteId: site.id,
username: 'gemini-proxy-user@example.com',
accessToken: 'gemini-access-token',
apiToken: null,
status: 'active',
extraConfig: JSON.stringify({
credentialMode: 'session',
proxyUrl: 'http://127.0.0.1:1080',
oauth: {
provider: 'gemini-cli',
projectId: 'project-proxy-demo',
email: 'gemini-proxy-user@example.com',
},
}),
}).returning().get();
const result = await refreshModelsForAccount(account.id);
expect(result).toMatchObject({
accountId: account.id,
refreshed: true,
status: 'success',
modelCount: expect.any(Number),
});
expect(undiciFetchMock).toHaveBeenCalledTimes(1);
expect(proxyAgentCtorMock).toHaveBeenCalledWith('http://127.0.0.1:1080');
expect(String(undiciFetchMock.mock.calls[0]?.[0] || '')).toContain('/projects/project-proxy-demo/services/');
expect(undiciFetchMock.mock.calls[0]?.[1]).toMatchObject({
method: 'GET',
dispatcher: expect.any(MockProxyAgent),
});
});
it('discovers antigravity oauth models via fetchAvailableModels fallback using the oauth project id', async () => {
getApiTokenMock.mockResolvedValue(null);
getModelsMock.mockRejectedValue(new Error('antigravity oauth discovery should not call adapter.getModels'));
+20 -10
View File
@@ -10,6 +10,8 @@ import {
isUsableAccountToken,
} from './accountTokenService.js';
import {
getCredentialModeFromExtraConfig,
getProxyUrlFromExtraConfig,
mergeAccountExtraConfig,
requiresManagedAccountTokens,
resolvePlatformUserId,
@@ -18,7 +20,7 @@ import {
import { invalidateTokenRouterCache } from './tokenRouter.js';
import { setAccountRuntimeHealth } from './accountHealthService.js';
import { clearAllRouteDecisionSnapshots } from './routeDecisionSnapshotStore.js';
import { withSiteRecordProxyRequestInit } from './siteProxy.js';
import { withAccountProxyOverride, withExplicitProxyRequestInit, withSiteRecordProxyRequestInit } from './siteProxy.js';
import { getCodexOauthInfoFromExtraConfig, isCodexPlatform } from './oauth/codexAccount.js';
import { buildOauthInfo, getOauthInfoFromExtraConfig } from './oauth/oauthAccount.js';
import { CLAUDE_DEFAULT_ANTHROPIC_VERSION } from './oauth/claudeProvider.js';
@@ -321,7 +323,7 @@ async function validateGeminiCliOauthConnection(input: {
}
const response = await fetch(
`https://serviceusage.googleapis.com/v1/projects/${encodeURIComponent(projectId)}/services/${encodeURIComponent(GEMINI_CLI_REQUIRED_SERVICE)}`,
{
withExplicitProxyRequestInit(getProxyUrlFromExtraConfig(input.account.extraConfig), {
method: 'GET',
headers: {
Authorization: `Bearer ${accessToken}`,
@@ -329,7 +331,7 @@ async function validateGeminiCliOauthConnection(input: {
'User-Agent': GEMINI_CLI_USER_AGENT,
'X-Goog-Api-Client': GEMINI_CLI_GOOGLE_API_CLIENT,
},
},
}, true),
);
if (!response.ok) {
const text = await response.text().catch(() => '');
@@ -493,6 +495,7 @@ export async function refreshModelsForAccount(accountId: number): Promise<ModelR
const site = row.sites;
const oauth = getOauthInfoFromExtraConfig(account.extraConfig);
const adapter = getAdapter(site.platform);
const accountProxyUrl = getProxyUrlFromExtraConfig(account.extraConfig);
const accountTokens = await db.select()
.from(schema.accountTokens)
@@ -522,7 +525,8 @@ export async function refreshModelsForAccount(accountId: number): Promise<ModelR
const startedAt = Date.now();
try {
const codexModels = await withTimeout(
() => discoverCodexModelsFromCloud({ site, account }),
() => withAccountProxyOverride(accountProxyUrl,
() => discoverCodexModelsFromCloud({ site, account })),
MODEL_DISCOVERY_TIMEOUT_MS,
`codex model discovery timeout (${Math.round(MODEL_DISCOVERY_TIMEOUT_MS / 1000)}s)`,
);
@@ -592,7 +596,8 @@ export async function refreshModelsForAccount(accountId: number): Promise<ModelR
const startedAt = Date.now();
try {
await withTimeout(
() => validateClaudeOauthConnection({ site, account }),
() => withAccountProxyOverride(accountProxyUrl,
() => validateClaudeOauthConnection({ site, account })),
MODEL_DISCOVERY_TIMEOUT_MS,
`claude oauth validation timeout (${Math.round(MODEL_DISCOVERY_TIMEOUT_MS / 1000)}s)`,
);
@@ -658,7 +663,8 @@ export async function refreshModelsForAccount(accountId: number): Promise<ModelR
const startedAt = Date.now();
try {
await withTimeout(
() => validateGeminiCliOauthConnection({ account }),
() => withAccountProxyOverride(accountProxyUrl,
() => validateGeminiCliOauthConnection({ account })),
MODEL_DISCOVERY_TIMEOUT_MS,
`gemini cli oauth validation timeout (${Math.round(MODEL_DISCOVERY_TIMEOUT_MS / 1000)}s)`,
);
@@ -724,7 +730,8 @@ export async function refreshModelsForAccount(accountId: number): Promise<ModelR
const startedAt = Date.now();
try {
const antigravityModels = await withTimeout(
() => discoverAntigravityModelsFromCloud({ site, account }),
() => withAccountProxyOverride(accountProxyUrl,
() => discoverAntigravityModelsFromCloud({ site, account })),
MODEL_DISCOVERY_TIMEOUT_MS,
`antigravity model discovery timeout (${Math.round(MODEL_DISCOVERY_TIMEOUT_MS / 1000)}s)`,
);
@@ -799,7 +806,8 @@ export async function refreshModelsForAccount(accountId: number): Promise<ModelR
if (!account.apiToken && account.accessToken) {
try {
discoveredApiToken = await withTimeout(
() => adapter.getApiToken(site.url, account.accessToken, platformUserId),
() => withAccountProxyOverride(accountProxyUrl,
() => adapter.getApiToken(site.url, account.accessToken, platformUserId)),
API_TOKEN_DISCOVERY_TIMEOUT_MS,
`api token discovery timeout (${Math.round(API_TOKEN_DISCOVERY_TIMEOUT_MS / 1000)}s)`,
);
@@ -881,7 +889,8 @@ export async function refreshModelsForAccount(accountId: number): Promise<ModelR
try {
models = normalizeModels(
await withTimeout(
() => adapter.getModels(site.url, credential, platformUserId),
() => withAccountProxyOverride(accountProxyUrl,
() => adapter.getModels(site.url, credential, platformUserId)),
MODEL_DISCOVERY_TIMEOUT_MS,
`model discovery timeout (${Math.round(MODEL_DISCOVERY_TIMEOUT_MS / 1000)}s)`,
),
@@ -908,7 +917,8 @@ export async function refreshModelsForAccount(accountId: number): Promise<ModelR
try {
models = normalizeModels(
await withTimeout(
() => adapter.getModels(site.url, token.token, platformUserId),
() => withAccountProxyOverride(accountProxyUrl,
() => adapter.getModels(site.url, token.token, platformUserId)),
MODEL_DISCOVERY_TIMEOUT_MS,
`model discovery timeout (${Math.round(MODEL_DISCOVERY_TIMEOUT_MS / 1000)}s)`,
),
+73
View File
@@ -217,4 +217,77 @@ describe('siteProxy', () => {
expect(headers.get('x-request-id')).toBe('req-1');
expect('dispatcher' in requestInit).toBe(true);
});
it('resolveChannelProxyUrl prefers account proxy over site proxy', async () => {
const { resolveChannelProxyUrl } = await import('./siteProxy.js');
const accountConfig = JSON.stringify({ proxyUrl: 'http://account-proxy:8080' });
const siteWithProxy = { useSystemProxy: true };
expect(resolveChannelProxyUrl(siteWithProxy, accountConfig)).toBe('http://account-proxy:8080');
expect(resolveChannelProxyUrl(siteWithProxy, null)).toBeNull();
expect(resolveChannelProxyUrl(siteWithProxy, JSON.stringify({}))).toBeNull();
});
it('withSiteRecordProxyRequestInit uses account proxy when provided', async () => {
const { withSiteRecordProxyRequestInit } = await import('./siteProxy.js');
const result = withSiteRecordProxyRequestInit(
{ useSystemProxy: false },
{ method: 'POST' },
'http://account-proxy:8080',
);
expect('dispatcher' in result).toBe(true);
});
it('withSiteRecordProxyRequestInit ignores invalid account proxy', async () => {
const { withSiteRecordProxyRequestInit } = await import('./siteProxy.js');
const result = withSiteRecordProxyRequestInit(
{ useSystemProxy: false },
{ method: 'POST' },
'not-a-url',
);
expect('dispatcher' in result).toBe(false);
});
it('withAccountProxyOverride sets ALS context for nested proxy calls', async () => {
const { withAccountProxyOverride, withSiteProxyRequestInit } = await import('./siteProxy.js');
await db.insert(schema.sites).values({
name: 'als-site',
url: 'https://als-site.example.com',
platform: 'new-api',
}).run();
const result = await withAccountProxyOverride(
'http://account-als-proxy:9090',
async () => {
return withSiteProxyRequestInit('https://als-site.example.com/v1/models', {
method: 'GET',
});
},
);
expect('dispatcher' in result).toBe(true);
});
it('withAccountProxyOverride skips ALS when proxy is null', async () => {
const { withAccountProxyOverride, withSiteProxyRequestInit } = await import('./siteProxy.js');
await db.insert(schema.sites).values({
name: 'als-null-site',
url: 'https://als-null-site.example.com',
platform: 'new-api',
}).run();
const result = await withAccountProxyOverride(
null,
async () => {
return withSiteProxyRequestInit('https://als-null-site.example.com/v1/models', {
method: 'GET',
});
},
);
expect('dispatcher' in result).toBe(false);
});
});
+45 -8
View File
@@ -1,3 +1,4 @@
import { AsyncLocalStorage } from 'node:async_hooks';
import { db, schema } from '../db/index.js';
import { eq } from 'drizzle-orm';
import { config } from '../config.js';
@@ -8,6 +9,7 @@ import { SocksClient } from 'socks';
import type { Dispatcher, RequestInit as UndiciRequestInit } from 'undici';
import { Agent as UndiciAgent, ProxyAgent } from 'undici';
import { mergeHeadersWithSiteCustomHeaders } from './siteCustomHeaders.js';
import { getProxyUrlFromExtraConfig } from './accountExtraConfig.js';
const SITE_PROXY_CACHE_TTL_MS = 3_000;
const SUPPORTED_PROXY_PROTOCOLS = new Set([
@@ -60,6 +62,17 @@ let siteProxyCache: {
const dispatcherCache = new Map<string, Dispatcher>();
const accountProxyOverride = new AsyncLocalStorage<string | null>();
export function withAccountProxyOverride<T>(
proxyUrl: string | null | undefined,
fn: () => Promise<T>,
): Promise<T> {
const normalized = normalizeSiteProxyUrl(proxyUrl);
if (!normalized) return fn();
return accountProxyOverride.run(normalized, fn);
}
type ParsedSocksProxyConfig = {
shouldLookup: boolean;
proxy: {
@@ -145,19 +158,23 @@ async function getCachedSiteProxyRows(nowMs = Date.now()): Promise<SiteProxyRow[
return siteProxyCache.rows;
}
function getDispatcherByProxyUrl(proxyUrl: string): Dispatcher | undefined {
function getDispatcherByProxyUrl(proxyUrl: string, skipCache = false): Dispatcher | undefined {
const normalized = normalizeSiteProxyUrl(proxyUrl);
if (!normalized) return undefined;
const cached = dispatcherCache.get(normalized);
if (cached) return cached;
if (!skipCache) {
const cached = dispatcherCache.get(normalized);
if (cached) return cached;
}
try {
const parsedProxyUrl = new URL(normalized);
const dispatcher = SOCKS_PROXY_PROTOCOLS.has(parsedProxyUrl.protocol.toLowerCase())
? createSocksDispatcher(parsedProxyUrl)
: new ProxyAgent(normalized);
dispatcherCache.set(normalized, dispatcher);
if (!skipCache) {
dispatcherCache.set(normalized, dispatcher);
}
return dispatcher;
} catch {
return undefined;
@@ -398,11 +415,14 @@ export async function withSiteProxyRequestInit(
nextOptions.headers = mergedHeaders;
}
if (!resolved.proxyUrl) {
const alsOverride = accountProxyOverride.getStore();
const proxyUrl = alsOverride ?? resolved.proxyUrl;
if (!proxyUrl) {
return nextOptions;
}
const dispatcher = getDispatcherByProxyUrl(resolved.proxyUrl);
const dispatcher = getDispatcherByProxyUrl(proxyUrl, alsOverride != null);
if (!dispatcher) {
return nextOptions;
}
@@ -416,11 +436,12 @@ export async function withSiteProxyRequestInit(
export function withExplicitProxyRequestInit(
proxyUrl: string | null | undefined,
options?: UndiciRequestInit,
skipCache = false,
): UndiciRequestInit {
const normalized = normalizeSiteProxyUrl(proxyUrl);
if (!normalized) return options ?? {};
const dispatcher = getDispatcherByProxyUrl(normalized);
const dispatcher = getDispatcherByProxyUrl(normalized, skipCache);
if (!dispatcher) return options ?? {};
return {
@@ -439,6 +460,7 @@ export function resolveProxyUrlForSite(site: SiteProxyConfigLike | null | undefi
export function withSiteRecordProxyRequestInit(
site: SiteProxyConfigLike | null | undefined,
options?: UndiciRequestInit,
accountProxyUrl?: string | null,
): UndiciRequestInit {
const nextOptions: UndiciRequestInit = {
...(options || {}),
@@ -447,5 +469,20 @@ export function withSiteRecordProxyRequestInit(
if (mergedHeaders) {
nextOptions.headers = mergedHeaders;
}
return withExplicitProxyRequestInit(resolveProxyUrlForSite(site), nextOptions);
const accountNormalized = normalizeSiteProxyUrl(accountProxyUrl) ?? accountProxyOverride.getStore();
const siteProxyUrl = resolveProxyUrlForSite(site);
const proxyUrl = accountNormalized || siteProxyUrl;
const isAccountOverride = !!accountNormalized && accountNormalized !== siteProxyUrl;
return withExplicitProxyRequestInit(proxyUrl, nextOptions, isAccountOverride);
}
export function resolveChannelProxyUrl(
site: SiteProxyConfigLike | null | undefined,
accountExtraConfig?: string | null,
): string | null {
if (accountExtraConfig) {
const normalized = normalizeSiteProxyUrl(getProxyUrlFromExtraConfig(accountExtraConfig));
if (normalized) return normalized;
}
return resolveProxyUrlForSite(site);
}
+10
View File
@@ -2016,6 +2016,11 @@ body {
color: var(--color-text-muted);
}
.badge-purple {
background: #ede9fe;
color: #6d28d9;
}
.badge-link {
display: inline-flex;
text-decoration: none;
@@ -2377,6 +2382,11 @@ body[data-tooltip-portal="true"] [data-tooltip]::before {
color: var(--color-text-muted);
}
[data-theme="dark"] .badge-purple {
background: #2e1065;
color: #c4b5fd;
}
[data-theme="dark"] .badge-link:hover .badge-muted,
[data-theme="dark"] .badge-link:focus-visible .badge-muted {
background: rgba(96, 165, 250, 0.2);
+34 -16
View File
@@ -111,6 +111,7 @@ export default function Accounts() {
isPinned: false,
refreshToken: '',
tokenExpiresAt: '',
proxyUrl: '',
});
const [savingEdit, setSavingEdit] = useState(false);
const [rebindTarget, setRebindTarget] = useState<any | null>(null);
@@ -652,21 +653,23 @@ export default function Accounts() {
}
};
const parseAccountExtraConfig = (account: any): Record<string, any> => {
try { return JSON.parse(account?.extraConfig || '{}') || {}; }
catch { return {}; }
};
const extractManagedSub2ApiAuth = (account: any) => {
try {
const parsed = JSON.parse(account?.extraConfig || '{}');
const auth = parsed?.sub2apiAuth || {};
return {
refreshToken: typeof auth.refreshToken === 'string' ? auth.refreshToken : '',
tokenExpiresAt: auth.tokenExpiresAt ? String(auth.tokenExpiresAt) : '',
};
} catch {
return { refreshToken: '', tokenExpiresAt: '' };
}
const parsed = parseAccountExtraConfig(account);
const auth = parsed?.sub2apiAuth || {};
return {
refreshToken: typeof auth.refreshToken === 'string' ? auth.refreshToken : '',
tokenExpiresAt: auth.tokenExpiresAt ? String(auth.tokenExpiresAt) : '',
};
};
const openEditPanel = (account: any) => {
const managedAuth = extractManagedSub2ApiAuth(account);
const proxyUrl = parseAccountExtraConfig(account)?.proxyUrl || '';
closeAddPanel();
setRebindTarget(null);
setEditingAccount(account);
@@ -680,6 +683,7 @@ export default function Accounts() {
isPinned: !!account?.isPinned,
refreshToken: managedAuth.refreshToken,
tokenExpiresAt: managedAuth.tokenExpiresAt,
proxyUrl,
});
};
@@ -702,6 +706,7 @@ export default function Accounts() {
isPinned: editForm.isPinned,
refreshToken: editForm.refreshToken.trim() || null,
tokenExpiresAt: editForm.tokenExpiresAt.trim() ? Number.parseInt(editForm.tokenExpiresAt.trim(), 10) : null,
proxyUrl: editForm.proxyUrl.trim() || null,
});
toast.success('账号已更新');
closeEditPanel();
@@ -786,12 +791,10 @@ export default function Accounts() {
};
const extractPlatformUserId = (account: any): string => {
try {
const parsed = JSON.parse(account?.extraConfig || '{}');
const raw = parsed?.platformUserId;
const value = Number.parseInt(String(raw ?? ''), 10);
if (Number.isFinite(value) && value > 0) return String(value);
} catch { }
const parsed = parseAccountExtraConfig(account);
const raw = parsed?.platformUserId;
const value = Number.parseInt(String(raw ?? ''), 10);
if (Number.isFinite(value) && value > 0) return String(value);
const guessed = Number.parseInt(String(account?.username || '').match(/(\d{3,8})$/)?.[1] || '', 10);
return Number.isFinite(guessed) && guessed > 0 ? String(guessed) : '';
};
@@ -1542,6 +1545,15 @@ export default function Accounts() {
onChange={(e) => setEditForm((prev) => ({ ...prev, apiToken: e.target.value }))}
style={{ ...inputStyle, fontFamily: 'var(--font-mono)' }}
/>
<input
placeholder="代理地址(可选,如 http://127.0.0.1:7890)"
value={editForm.proxyUrl}
onChange={(e) => setEditForm((prev) => ({ ...prev, proxyUrl: e.target.value }))}
style={inputStyle}
/>
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginTop: -4 }}>
覆盖站点和系统代理,留空则使用站点设置。支持 http/https/socks5 协议。
</div>
{((editingAccount?.site?.platform || '').toLowerCase() === 'sub2api') && (
<>
<input
@@ -1589,6 +1601,9 @@ export default function Accounts() {
<span className={`badge ${connectionMode === 'apikey' ? 'badge-warning' : 'badge-info'}`} style={{ fontSize: 10 }}>
{connectionMode === 'apikey' ? 'API Key' : 'Session'}
</span>
{parseAccountExtraConfig(a)?.proxyUrl && (
<span className="badge badge-purple" style={{ fontSize: 10 }}>代理</span>
)}
</div>
)}
>
@@ -1799,6 +1814,9 @@ export default function Accounts() {
<span className={`badge ${connectionMode === 'apikey' ? 'badge-warning' : 'badge-info'}`} style={{ fontSize: 10 }}>
{connectionMode === 'apikey' ? 'API Key' : 'Session'}
</span>
{parseAccountExtraConfig(a)?.proxyUrl && (
<span className="badge badge-purple" style={{ fontSize: 10 }}>代理</span>
)}
</div>
</td>
<td>