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