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