From 5687e87308e927b2af4dfa4b46899ebbc4da27f8 Mon Sep 17 00:00:00 2001 From: Hureru <3507039083@qq.com> Date: Mon, 16 Mar 2026 01:03:32 +0800 Subject: [PATCH 01/17] feat(proxy): add account-level proxy resolution core Add getProxyUrlFromExtraConfig to extract proxy URL from account extraConfig JSON. Extend siteProxy with AsyncLocalStorage-based withAccountProxyOverride, resolveChannelProxyUrl, and optional accountProxyUrl parameter on withSiteRecordProxyRequestInit. Priority chain: account proxyUrl > site useSystemProxy > direct. --- src/server/services/accountExtraConfig.ts | 5 ++++ src/server/services/siteProxy.ts | 35 +++++++++++++++++++++-- 2 files changed, 37 insertions(+), 3 deletions(-) diff --git a/src/server/services/accountExtraConfig.ts b/src/server/services/accountExtraConfig.ts index 280ee69..1950761 100644 --- a/src/server/services/accountExtraConfig.ts +++ b/src/server/services/accountExtraConfig.ts @@ -67,6 +67,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); diff --git a/src/server/services/siteProxy.ts b/src/server/services/siteProxy.ts index 77b46c7..878ad03 100644 --- a/src/server/services/siteProxy.ts +++ b/src/server/services/siteProxy.ts @@ -1,9 +1,11 @@ +import { AsyncLocalStorage } from 'node:async_hooks'; import { db, schema } from '../db/index.js'; import { eq } from 'drizzle-orm'; import { config } from '../config.js'; import type { Dispatcher, RequestInit as UndiciRequestInit } from 'undici'; import { 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([ @@ -45,6 +47,17 @@ let siteProxyCache: { const dispatcherCache = new Map(); +const accountProxyOverride = new AsyncLocalStorage(); + +export function withAccountProxyOverride( + proxyUrl: string | null | undefined, + fn: () => Promise, +): Promise { + const normalized = normalizeSiteProxyUrl(proxyUrl); + if (!normalized) return fn(); + return accountProxyOverride.run(normalized, fn); +} + function normalizeSiteUrl(value: string): string { const trimmed = (value || '').trim(); if (!trimmed) return ''; @@ -231,11 +244,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); if (!dispatcher) { return nextOptions; } @@ -270,6 +286,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 || {}), @@ -278,5 +295,17 @@ export function withSiteRecordProxyRequestInit( if (mergedHeaders) { nextOptions.headers = mergedHeaders; } - return withExplicitProxyRequestInit(resolveProxyUrlForSite(site), nextOptions); + const proxyUrl = normalizeSiteProxyUrl(accountProxyUrl) || resolveProxyUrlForSite(site); + return withExplicitProxyRequestInit(proxyUrl, nextOptions); +} + +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); } From b43a4fd2ea062c877818381c8b3ce1fec73f2d54 Mon Sep 17 00:00:00 2001 From: Hureru <3507039083@qq.com> Date: Mon, 16 Mar 2026 01:05:43 +0800 Subject: [PATCH 02/17] fix(proxy): use proxyUrl parameter in endpointFlow fetch The ExecuteEndpointFlowInput.proxyUrl field was defined but ignored by the fetch call. Apply it via withExplicitProxyRequestInit when present, falling back to withSiteProxyRequestInit otherwise. --- src/server/routes/proxy/endpointFlow.ts | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/src/server/routes/proxy/endpointFlow.ts b/src/server/routes/proxy/endpointFlow.ts index c1e9336..33ebc72 100644 --- a/src/server/routes/proxy/endpointFlow.ts +++ b/src/server/routes/proxy/endpointFlow.ts @@ -1,5 +1,5 @@ import { fetch } from 'undici'; -import { withSiteProxyRequestInit } from '../../services/siteProxy.js'; +import { withExplicitProxyRequestInit, withSiteProxyRequestInit } from '../../services/siteProxy.js'; import { summarizeUpstreamError } from './upstreamError.js'; import type { UpstreamEndpoint } from './upstreamEndpoint.js'; import { buildUpstreamUrl } from './upstreamUrl.js'; @@ -69,11 +69,17 @@ export async function executeEndpointFlow(input: ExecuteEndpointFlowInput): Prom const request = input.buildRequest(endpoint, endpointIndex); const targetUrl = buildUpstreamUrl(input.siteUrl, request.path); - let response = await fetch(targetUrl, await withSiteProxyRequestInit(targetUrl, { - method: 'POST', - headers: request.headers, - body: JSON.stringify(request.body), - })); + let response = await fetch(targetUrl, input.proxyUrl + ? withExplicitProxyRequestInit(input.proxyUrl, { + method: 'POST', + headers: request.headers, + body: JSON.stringify(request.body), + }) + : await withSiteProxyRequestInit(targetUrl, { + method: 'POST', + headers: request.headers, + body: JSON.stringify(request.body), + })); if (response.ok) { return { From 6d2f1c4b819b2b00a5784a24f2ae1546db4085a9 Mon Sep 17 00:00:00 2001 From: Hureru <3507039083@qq.com> Date: Mon, 16 Mar 2026 01:06:16 +0800 Subject: [PATCH 03/17] feat(proxy): pass account proxy through all proxy route handlers Thread account-level proxyUrl from extraConfig into resolveChannelProxyUrl for endpointFlow and the third parameter of withSiteRecordProxyRequestInit for direct fetch calls across chat, responses, completions, embeddings, images, search, and videos proxy routes. --- src/server/routes/proxy/chat.ts | 7 +- src/server/routes/proxy/completions.ts | 369 ++++++++++----------- src/server/routes/proxy/embeddings.ts | 231 ++++++------- src/server/routes/proxy/images.ts | 438 +++++++++++++------------ src/server/routes/proxy/responses.ts | 7 +- src/server/routes/proxy/search.ts | 3 +- src/server/routes/proxy/videos.ts | 6 +- 7 files changed, 535 insertions(+), 526 deletions(-) diff --git a/src/server/routes/proxy/chat.ts b/src/server/routes/proxy/chat.ts index 556e44d..de186e4 100644 --- a/src/server/routes/proxy/chat.ts +++ b/src/server/routes/proxy/chat.ts @@ -8,7 +8,8 @@ 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 { resolveProxyUrlForSite, withSiteRecordProxyRequestInit } from '../../services/siteProxy.js'; +import { resolveChannelProxyUrl, withSiteRecordProxyRequestInit } from '../../services/siteProxy.js'; +import { getProxyUrlFromExtraConfig } from '../../services/accountExtraConfig.js'; import { type DownstreamFormat } from '../../transformers/shared/normalized.js'; import { buildUpstreamEndpointRequest, @@ -168,7 +169,7 @@ async function handleChatProxyRequest( method: 'POST', headers: compatibilityRequest.headers, body: JSON.stringify(compatibilityRequest.body), - }), + }, getProxyUrlFromExtraConfig(selected.account.extraConfig)), ), }); let startTime = Date.now(); @@ -176,7 +177,7 @@ async function handleChatProxyRequest( try { const endpointResult = await executeEndpointFlow({ siteUrl: selected.site.url, - proxyUrl: resolveProxyUrlForSite(selected.site), + proxyUrl: resolveChannelProxyUrl(selected.site, selected.account.extraConfig), endpointCandidates, buildRequest: (endpoint) => buildEndpointRequest(endpoint), tryRecover: endpointStrategy.tryRecover, diff --git a/src/server/routes/proxy/completions.ts b/src/server/routes/proxy/completions.ts index 17686e3..65eb286 100644 --- a/src/server/routes/proxy/completions.ts +++ b/src/server/routes/proxy/completions.ts @@ -1,45 +1,46 @@ import { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify'; import { fetch } from 'undici'; import { db, schema } from '../../db/index.js'; -import { tokenRouter } from '../../services/tokenRouter.js'; -import { refreshModelsAndRebuildRoutes } from '../../services/modelService.js'; -import { reportProxyAllFailed, reportTokenExpired } from '../../services/alertService.js'; -import { isTokenExpiredError } from '../../services/alertRules.js'; -import { shouldRetryProxyRequest } from '../../services/proxyRetryPolicy.js'; -import { resolveProxyUsageWithSelfLogFallback } from '../../services/proxyUsageFallbackService.js'; -import { mergeProxyUsage, parseProxyUsage, pullSseDataEvents } from '../../services/proxyUsageParser.js'; -import { ensureModelAllowedForDownstreamKey, getDownstreamRoutingPolicy, recordDownstreamCostUsage } from './downstreamPolicy.js'; -import { withSiteRecordProxyRequestInit } from '../../services/siteProxy.js'; -import { composeProxyLogMessage } from './logPathMeta.js'; -import { formatUtcSqlDateTime } from '../../services/localTimeService.js'; -import { resolveProxyLogBilling } from './proxyBilling.js'; -import { buildUpstreamUrl } from './upstreamUrl.js'; +import { tokenRouter } from '../../services/tokenRouter.js'; +import { refreshModelsAndRebuildRoutes } from '../../services/modelService.js'; +import { reportProxyAllFailed, reportTokenExpired } from '../../services/alertService.js'; +import { isTokenExpiredError } from '../../services/alertRules.js'; +import { shouldRetryProxyRequest } from '../../services/proxyRetryPolicy.js'; +import { resolveProxyUsageWithSelfLogFallback } from '../../services/proxyUsageFallbackService.js'; +import { 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 { resolveProxyLogBilling } from './proxyBilling.js'; +import { buildUpstreamUrl } from './upstreamUrl.js'; const MAX_RETRIES = 2; export async function completionsProxyRoute(app: FastifyInstance) { app.post('/v1/completions', async (request: FastifyRequest, reply: FastifyReply) => { const body = request.body as any; - const requestedModel = body?.model; - if (!requestedModel) { - return reply.code(400).send({ error: { message: 'model is required', type: 'invalid_request_error' } }); - } - if (!await ensureModelAllowedForDownstreamKey(request, reply, requestedModel)) return; - const downstreamPolicy = getDownstreamRoutingPolicy(request); - - const isStream = body.stream === true; + const requestedModel = body?.model; + if (!requestedModel) { + return reply.code(400).send({ error: { message: 'model is required', type: 'invalid_request_error' } }); + } + if (!await ensureModelAllowedForDownstreamKey(request, reply, requestedModel)) return; + const downstreamPolicy = getDownstreamRoutingPolicy(request); + + const isStream = body.stream === true; 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); - } + 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({ @@ -51,21 +52,21 @@ export async function completionsProxyRoute(app: FastifyInstance) { }); } - excludeChannelIds.push(selected.channel.id); - - const targetUrl = buildUpstreamUrl(selected.site.url, '/v1/completions'); - const forwardBody = { ...body, model: selected.actualModel }; - const startTime = Date.now(); + excludeChannelIds.push(selected.channel.id); + + const targetUrl = buildUpstreamUrl(selected.site.url, '/v1/completions'); + const forwardBody = { ...body, model: selected.actualModel }; + const startTime = Date.now(); try { - const upstream = await fetch(targetUrl, withSiteRecordProxyRequestInit(selected.site, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'Authorization': `Bearer ${selected.tokenValue}`, - }, - body: JSON.stringify(forwardBody), - })); + const upstream = await fetch(targetUrl, withSiteRecordProxyRequestInit(selected.site, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${selected.tokenValue}`, + }, + body: JSON.stringify(forwardBody), + }, getProxyUrlFromExtraConfig(selected.account.extraConfig))); if (!upstream.ok) { const errText = await upstream.text().catch(() => 'unknown error'); @@ -81,10 +82,10 @@ export async function completionsProxyRoute(app: FastifyInstance) { }); } - if (shouldRetryProxyRequest(upstream.status, errText) && retryCount < MAX_RETRIES) { - retryCount++; - continue; - } + if (shouldRetryProxyRequest(upstream.status, errText) && retryCount < MAX_RETRIES) { + retryCount++; + continue; + } await reportProxyAllFailed({ model: requestedModel, @@ -106,110 +107,110 @@ export async function completionsProxyRoute(app: FastifyInstance) { return; } - const decoder = new TextDecoder(); - let parsedUsage: ReturnType = { - promptTokens: 0, - completionTokens: 0, - totalTokens: 0, - cacheReadTokens: 0, - cacheCreationTokens: 0, - promptTokensIncludeCache: null, - }; - let sseBuffer = ''; - try { - while (true) { - const { done, value } = await reader.read(); - if (done) break; - const chunk = decoder.decode(value, { stream: true }); - reply.raw.write(chunk); - - sseBuffer += chunk; - const pulled = pullSseDataEvents(sseBuffer); - sseBuffer = pulled.rest; - for (const eventPayload of pulled.events) { - try { - parsedUsage = mergeProxyUsage(parsedUsage, parseProxyUsage(JSON.parse(eventPayload))); - } catch {} - } - } - if (sseBuffer.trim().length > 0) { - const pulled = pullSseDataEvents(`${sseBuffer}\n\n`); - for (const eventPayload of pulled.events) { - try { - parsedUsage = mergeProxyUsage(parsedUsage, parseProxyUsage(JSON.parse(eventPayload))); - } catch {} - } - } - } finally { - reader.releaseLock(); - reply.raw.end(); - } + const decoder = new TextDecoder(); + let parsedUsage: ReturnType = { + promptTokens: 0, + completionTokens: 0, + totalTokens: 0, + cacheReadTokens: 0, + cacheCreationTokens: 0, + promptTokensIncludeCache: null, + }; + let sseBuffer = ''; + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + const chunk = decoder.decode(value, { stream: true }); + reply.raw.write(chunk); - const latency = Date.now() - startTime; - const resolvedUsage = await resolveProxyUsageWithSelfLogFallback({ - site: selected.site, - account: selected.account, - tokenValue: selected.tokenValue, - tokenName: selected.tokenName, - modelName: selected.actualModel || requestedModel, - requestStartedAtMs: startTime, - requestEndedAtMs: startTime + latency, - localLatencyMs: latency, - usage: { - promptTokens: parsedUsage.promptTokens, - completionTokens: parsedUsage.completionTokens, - totalTokens: parsedUsage.totalTokens, - }, - }); - const { estimatedCost, billingDetails } = await resolveProxyLogBilling({ - site: selected.site, - account: selected.account, - modelName: selected.actualModel || requestedModel, - parsedUsage, - resolvedUsage, - }); - tokenRouter.recordSuccess(selected.channel.id, latency, estimatedCost); - recordDownstreamCostUsage(request, estimatedCost); - logProxy( - selected, requestedModel, 'success', 200, latency, null, retryCount, - resolvedUsage.promptTokens, resolvedUsage.completionTokens, resolvedUsage.totalTokens, estimatedCost, billingDetails, - ); - return; - } - - const data = await upstream.json() as any; - const latency = Date.now() - startTime; - const parsedUsage = parseProxyUsage(data); - const resolvedUsage = await resolveProxyUsageWithSelfLogFallback({ - site: selected.site, - account: selected.account, - tokenValue: selected.tokenValue, - tokenName: selected.tokenName, - modelName: selected.actualModel || requestedModel, - requestStartedAtMs: startTime, - requestEndedAtMs: startTime + latency, - localLatencyMs: latency, - usage: { - promptTokens: parsedUsage.promptTokens, - completionTokens: parsedUsage.completionTokens, - totalTokens: parsedUsage.totalTokens, - }, - }); - const { estimatedCost, billingDetails } = await resolveProxyLogBilling({ - site: selected.site, - account: selected.account, - modelName: selected.actualModel || requestedModel, - parsedUsage, - resolvedUsage, - }); - - tokenRouter.recordSuccess(selected.channel.id, latency, estimatedCost); - recordDownstreamCostUsage(request, estimatedCost); - logProxy( - selected, requestedModel, 'success', 200, latency, null, retryCount, - resolvedUsage.promptTokens, resolvedUsage.completionTokens, resolvedUsage.totalTokens, estimatedCost, billingDetails, - ); - return reply.send(data); + sseBuffer += chunk; + const pulled = pullSseDataEvents(sseBuffer); + sseBuffer = pulled.rest; + for (const eventPayload of pulled.events) { + try { + parsedUsage = mergeProxyUsage(parsedUsage, parseProxyUsage(JSON.parse(eventPayload))); + } catch {} + } + } + if (sseBuffer.trim().length > 0) { + const pulled = pullSseDataEvents(`${sseBuffer}\n\n`); + for (const eventPayload of pulled.events) { + try { + parsedUsage = mergeProxyUsage(parsedUsage, parseProxyUsage(JSON.parse(eventPayload))); + } catch {} + } + } + } finally { + reader.releaseLock(); + reply.raw.end(); + } + + const latency = Date.now() - startTime; + const resolvedUsage = await resolveProxyUsageWithSelfLogFallback({ + site: selected.site, + account: selected.account, + tokenValue: selected.tokenValue, + tokenName: selected.tokenName, + modelName: selected.actualModel || requestedModel, + requestStartedAtMs: startTime, + requestEndedAtMs: startTime + latency, + localLatencyMs: latency, + usage: { + promptTokens: parsedUsage.promptTokens, + completionTokens: parsedUsage.completionTokens, + totalTokens: parsedUsage.totalTokens, + }, + }); + const { estimatedCost, billingDetails } = await resolveProxyLogBilling({ + site: selected.site, + account: selected.account, + modelName: selected.actualModel || requestedModel, + parsedUsage, + resolvedUsage, + }); + tokenRouter.recordSuccess(selected.channel.id, latency, estimatedCost); + recordDownstreamCostUsage(request, estimatedCost); + logProxy( + selected, requestedModel, 'success', 200, latency, null, retryCount, + resolvedUsage.promptTokens, resolvedUsage.completionTokens, resolvedUsage.totalTokens, estimatedCost, billingDetails, + ); + return; + } + + const data = await upstream.json() as any; + const latency = Date.now() - startTime; + const parsedUsage = parseProxyUsage(data); + const resolvedUsage = await resolveProxyUsageWithSelfLogFallback({ + site: selected.site, + account: selected.account, + tokenValue: selected.tokenValue, + tokenName: selected.tokenName, + modelName: selected.actualModel || requestedModel, + requestStartedAtMs: startTime, + requestEndedAtMs: startTime + latency, + localLatencyMs: latency, + usage: { + promptTokens: parsedUsage.promptTokens, + completionTokens: parsedUsage.completionTokens, + totalTokens: parsedUsage.totalTokens, + }, + }); + const { estimatedCost, billingDetails } = await resolveProxyLogBilling({ + site: selected.site, + account: selected.account, + modelName: selected.actualModel || requestedModel, + parsedUsage, + resolvedUsage, + }); + + tokenRouter.recordSuccess(selected.channel.id, latency, estimatedCost); + recordDownstreamCostUsage(request, estimatedCost); + logProxy( + selected, requestedModel, 'success', 200, latency, null, retryCount, + resolvedUsage.promptTokens, resolvedUsage.completionTokens, resolvedUsage.totalTokens, estimatedCost, billingDetails, + ); + return reply.send(data); } catch (err: any) { tokenRouter.recordFailure(selected.channel.id); logProxy(selected, requestedModel, 'failed', 0, Date.now() - startTime, err.message, retryCount); @@ -237,38 +238,38 @@ async function logProxy( latencyMs: number, errorMessage: string | null, retryCount: number, - promptTokens = 0, - completionTokens = 0, - totalTokens = 0, - estimatedCost = 0, - billingDetails: unknown = null, -) { - try { - const createdAt = formatUtcSqlDateTime(new Date()); - const normalizedErrorMessage = composeProxyLogMessage({ - downstreamPath: '/v1/completions', - errorMessage, - }); - await db.insert(schema.proxyLogs).values({ - routeId: selected.channel.routeId, - channelId: selected.channel.id, - accountId: selected.account.id, - modelRequested, - modelActual: selected.actualModel, - status, - httpStatus, - latencyMs, - promptTokens, - completionTokens, - totalTokens, - estimatedCost, - billingDetails: billingDetails ? JSON.stringify(billingDetails) : null, - errorMessage: normalizedErrorMessage, - retryCount, - createdAt, - }).run(); - } catch (error) { - console.warn('[proxy/completions] failed to write proxy log', error); - } -} + promptTokens = 0, + completionTokens = 0, + totalTokens = 0, + estimatedCost = 0, + billingDetails: unknown = null, +) { + try { + const createdAt = formatUtcSqlDateTime(new Date()); + const normalizedErrorMessage = composeProxyLogMessage({ + downstreamPath: '/v1/completions', + errorMessage, + }); + await db.insert(schema.proxyLogs).values({ + routeId: selected.channel.routeId, + channelId: selected.channel.id, + accountId: selected.account.id, + modelRequested, + modelActual: selected.actualModel, + status, + httpStatus, + latencyMs, + promptTokens, + completionTokens, + totalTokens, + estimatedCost, + billingDetails: billingDetails ? JSON.stringify(billingDetails) : null, + errorMessage: normalizedErrorMessage, + retryCount, + createdAt, + }).run(); + } catch (error) { + console.warn('[proxy/completions] failed to write proxy log', error); + } +} diff --git a/src/server/routes/proxy/embeddings.ts b/src/server/routes/proxy/embeddings.ts index 2299512..10412a7 100644 --- a/src/server/routes/proxy/embeddings.ts +++ b/src/server/routes/proxy/embeddings.ts @@ -1,44 +1,45 @@ import { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify'; import { fetch } from 'undici'; import { db, schema } from '../../db/index.js'; -import { tokenRouter } from '../../services/tokenRouter.js'; -import { refreshModelsAndRebuildRoutes } from '../../services/modelService.js'; -import { reportProxyAllFailed, reportTokenExpired } from '../../services/alertService.js'; -import { isTokenExpiredError } from '../../services/alertRules.js'; -import { shouldRetryProxyRequest } from '../../services/proxyRetryPolicy.js'; -import { resolveProxyUsageWithSelfLogFallback } from '../../services/proxyUsageFallbackService.js'; -import { parseProxyUsage } from '../../services/proxyUsageParser.js'; -import { ensureModelAllowedForDownstreamKey, getDownstreamRoutingPolicy, recordDownstreamCostUsage } from './downstreamPolicy.js'; -import { withSiteRecordProxyRequestInit } from '../../services/siteProxy.js'; -import { composeProxyLogMessage } from './logPathMeta.js'; -import { formatUtcSqlDateTime } from '../../services/localTimeService.js'; -import { resolveProxyLogBilling } from './proxyBilling.js'; -import { buildUpstreamUrl } from './upstreamUrl.js'; +import { tokenRouter } from '../../services/tokenRouter.js'; +import { refreshModelsAndRebuildRoutes } from '../../services/modelService.js'; +import { reportProxyAllFailed, reportTokenExpired } from '../../services/alertService.js'; +import { isTokenExpiredError } from '../../services/alertRules.js'; +import { shouldRetryProxyRequest } from '../../services/proxyRetryPolicy.js'; +import { resolveProxyUsageWithSelfLogFallback } from '../../services/proxyUsageFallbackService.js'; +import { parseProxyUsage } from '../../services/proxyUsageParser.js'; +import { ensureModelAllowedForDownstreamKey, getDownstreamRoutingPolicy, recordDownstreamCostUsage } from './downstreamPolicy.js'; +import { withSiteRecordProxyRequestInit } from '../../services/siteProxy.js'; +import { getProxyUrlFromExtraConfig } from '../../services/accountExtraConfig.js'; +import { composeProxyLogMessage } from './logPathMeta.js'; +import { formatUtcSqlDateTime } from '../../services/localTimeService.js'; +import { resolveProxyLogBilling } from './proxyBilling.js'; +import { buildUpstreamUrl } from './upstreamUrl.js'; const MAX_RETRIES = 2; export async function embeddingsProxyRoute(app: FastifyInstance) { app.post('/v1/embeddings', async (request: FastifyRequest, reply: FastifyReply) => { const body = request.body as any; - const requestedModel = body?.model; - if (!requestedModel) { - return reply.code(400).send({ error: { message: 'model is required', type: 'invalid_request_error' } }); - } - if (!await ensureModelAllowedForDownstreamKey(request, reply, requestedModel)) return; - const downstreamPolicy = getDownstreamRoutingPolicy(request); - - const excludeChannelIds: number[] = []; + const requestedModel = body?.model; + if (!requestedModel) { + return reply.code(400).send({ error: { message: 'model is required', type: 'invalid_request_error' } }); + } + if (!await ensureModelAllowedForDownstreamKey(request, reply, requestedModel)) return; + const downstreamPolicy = getDownstreamRoutingPolicy(request); + + const 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); - } + 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({ @@ -48,21 +49,21 @@ export async function embeddingsProxyRoute(app: FastifyInstance) { return reply.code(503).send({ error: { message: 'No available channels', type: 'server_error' } }); } - excludeChannelIds.push(selected.channel.id); - - const targetUrl = buildUpstreamUrl(selected.site.url, '/v1/embeddings'); - const forwardBody = { ...body, model: selected.actualModel }; - const startTime = Date.now(); + excludeChannelIds.push(selected.channel.id); + + const targetUrl = buildUpstreamUrl(selected.site.url, '/v1/embeddings'); + const forwardBody = { ...body, model: selected.actualModel }; + const startTime = Date.now(); try { - const upstream = await fetch(targetUrl, withSiteRecordProxyRequestInit(selected.site, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'Authorization': `Bearer ${selected.tokenValue}`, - }, - body: JSON.stringify(forwardBody), - })); + const upstream = await fetch(targetUrl, withSiteRecordProxyRequestInit(selected.site, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${selected.tokenValue}`, + }, + body: JSON.stringify(forwardBody), + }, getProxyUrlFromExtraConfig(selected.account.extraConfig))); const text = await upstream.text(); if (!upstream.ok) { @@ -78,10 +79,10 @@ export async function embeddingsProxyRoute(app: FastifyInstance) { }); } - if (shouldRetryProxyRequest(upstream.status, text) && retryCount < MAX_RETRIES) { - retryCount++; - continue; - } + if (shouldRetryProxyRequest(upstream.status, text) && retryCount < MAX_RETRIES) { + retryCount++; + continue; + } await reportProxyAllFailed({ model: requestedModel, @@ -90,40 +91,40 @@ export async function embeddingsProxyRoute(app: FastifyInstance) { return reply.code(upstream.status).send({ error: { message: text, type: 'upstream_error' } }); } - let data: any = {}; - try { data = JSON.parse(text); } catch { data = {}; } - const latency = Date.now() - startTime; - const parsedUsage = parseProxyUsage(data); - const resolvedUsage = await resolveProxyUsageWithSelfLogFallback({ - site: selected.site, - account: selected.account, - tokenValue: selected.tokenValue, - tokenName: selected.tokenName, - modelName: selected.actualModel || requestedModel, - requestStartedAtMs: startTime, - requestEndedAtMs: startTime + latency, - localLatencyMs: latency, - usage: { - promptTokens: parsedUsage.promptTokens, - completionTokens: parsedUsage.completionTokens, - totalTokens: parsedUsage.totalTokens, - }, - }); - const { estimatedCost, billingDetails } = await resolveProxyLogBilling({ - site: selected.site, - account: selected.account, - modelName: selected.actualModel || requestedModel, - parsedUsage, - resolvedUsage, - }); - - tokenRouter.recordSuccess(selected.channel.id, latency, estimatedCost); - recordDownstreamCostUsage(request, estimatedCost); - logProxy( - selected, requestedModel, 'success', upstream.status, latency, null, retryCount, - resolvedUsage.promptTokens, resolvedUsage.completionTokens, resolvedUsage.totalTokens, estimatedCost, billingDetails, - ); - return reply.code(upstream.status).send(data); + let data: any = {}; + try { data = JSON.parse(text); } catch { data = {}; } + const latency = Date.now() - startTime; + const parsedUsage = parseProxyUsage(data); + const resolvedUsage = await resolveProxyUsageWithSelfLogFallback({ + site: selected.site, + account: selected.account, + tokenValue: selected.tokenValue, + tokenName: selected.tokenName, + modelName: selected.actualModel || requestedModel, + requestStartedAtMs: startTime, + requestEndedAtMs: startTime + latency, + localLatencyMs: latency, + usage: { + promptTokens: parsedUsage.promptTokens, + completionTokens: parsedUsage.completionTokens, + totalTokens: parsedUsage.totalTokens, + }, + }); + const { estimatedCost, billingDetails } = await resolveProxyLogBilling({ + site: selected.site, + account: selected.account, + modelName: selected.actualModel || requestedModel, + parsedUsage, + resolvedUsage, + }); + + tokenRouter.recordSuccess(selected.channel.id, latency, estimatedCost); + recordDownstreamCostUsage(request, estimatedCost); + logProxy( + selected, requestedModel, 'success', upstream.status, latency, null, retryCount, + resolvedUsage.promptTokens, resolvedUsage.completionTokens, resolvedUsage.totalTokens, estimatedCost, billingDetails, + ); + return reply.code(upstream.status).send(data); } catch (err: any) { tokenRouter.recordFailure(selected.channel.id); logProxy(selected, requestedModel, 'failed', 0, Date.now() - startTime, err.message, retryCount); @@ -149,38 +150,38 @@ async function logProxy( latencyMs: number, errorMessage: string | null, retryCount: number, - promptTokens = 0, - completionTokens = 0, - totalTokens = 0, - estimatedCost = 0, - billingDetails: unknown = null, -) { - try { - const createdAt = formatUtcSqlDateTime(new Date()); - const normalizedErrorMessage = composeProxyLogMessage({ - downstreamPath: '/v1/embeddings', - errorMessage, - }); - await db.insert(schema.proxyLogs).values({ - routeId: selected.channel.routeId, - channelId: selected.channel.id, - accountId: selected.account.id, - modelRequested, - modelActual: selected.actualModel, - status, - httpStatus, - latencyMs, - promptTokens, - completionTokens, - totalTokens, - estimatedCost, - billingDetails: billingDetails ? JSON.stringify(billingDetails) : null, - errorMessage: normalizedErrorMessage, - retryCount, - createdAt, - }).run(); - } catch (error) { - console.warn('[proxy/embeddings] failed to write proxy log', error); - } -} + promptTokens = 0, + completionTokens = 0, + totalTokens = 0, + estimatedCost = 0, + billingDetails: unknown = null, +) { + try { + const createdAt = formatUtcSqlDateTime(new Date()); + const normalizedErrorMessage = composeProxyLogMessage({ + downstreamPath: '/v1/embeddings', + errorMessage, + }); + await db.insert(schema.proxyLogs).values({ + routeId: selected.channel.routeId, + channelId: selected.channel.id, + accountId: selected.account.id, + modelRequested, + modelActual: selected.actualModel, + status, + httpStatus, + latencyMs, + promptTokens, + completionTokens, + totalTokens, + estimatedCost, + billingDetails: billingDetails ? JSON.stringify(billingDetails) : null, + errorMessage: normalizedErrorMessage, + retryCount, + createdAt, + }).run(); + } catch (error) { + console.warn('[proxy/embeddings] failed to write proxy log', error); + } +} diff --git a/src/server/routes/proxy/images.ts b/src/server/routes/proxy/images.ts index 27ca200..4cc0eaa 100644 --- a/src/server/routes/proxy/images.ts +++ b/src/server/routes/proxy/images.ts @@ -2,40 +2,41 @@ import { fetch } from 'undici'; import { db, schema } from '../../db/index.js'; import { tokenRouter } from '../../services/tokenRouter.js'; -import { refreshModelsAndRebuildRoutes } from '../../services/modelService.js'; -import { reportProxyAllFailed, reportTokenExpired } from '../../services/alertService.js'; -import { isTokenExpiredError } from '../../services/alertRules.js'; -import { estimateProxyCost } from '../../services/modelPricingService.js'; -import { shouldRetryProxyRequest } from '../../services/proxyRetryPolicy.js'; -import { ensureModelAllowedForDownstreamKey, getDownstreamRoutingPolicy, recordDownstreamCostUsage } from './downstreamPolicy.js'; -import { withSiteRecordProxyRequestInit } from '../../services/siteProxy.js'; -import { composeProxyLogMessage } from './logPathMeta.js'; -import { formatUtcSqlDateTime } from '../../services/localTimeService.js'; -import { cloneFormDataWithOverrides, ensureMultipartBufferParser, parseMultipartFormData } from './multipart.js'; -import { buildUpstreamUrl } from './upstreamUrl.js'; +import { refreshModelsAndRebuildRoutes } from '../../services/modelService.js'; +import { reportProxyAllFailed, reportTokenExpired } from '../../services/alertService.js'; +import { isTokenExpiredError } from '../../services/alertRules.js'; +import { estimateProxyCost } from '../../services/modelPricingService.js'; +import { shouldRetryProxyRequest } from '../../services/proxyRetryPolicy.js'; +import { ensureModelAllowedForDownstreamKey, getDownstreamRoutingPolicy, recordDownstreamCostUsage } from './downstreamPolicy.js'; +import { withSiteRecordProxyRequestInit } from '../../services/siteProxy.js'; +import { getProxyUrlFromExtraConfig } from '../../services/accountExtraConfig.js'; +import { composeProxyLogMessage } from './logPathMeta.js'; +import { formatUtcSqlDateTime } from '../../services/localTimeService.js'; +import { cloneFormDataWithOverrides, ensureMultipartBufferParser, parseMultipartFormData } from './multipart.js'; +import { buildUpstreamUrl } from './upstreamUrl.js'; const MAX_RETRIES = 2; -export async function imagesProxyRoute(app: FastifyInstance) { - ensureMultipartBufferParser(app); - - app.post('/v1/images/generations', async (request: FastifyRequest, reply: FastifyReply) => { - const body = request.body as any; - const requestedModel = body?.model || 'gpt-image-1'; - if (!await ensureModelAllowedForDownstreamKey(request, reply, requestedModel)) return; - const downstreamPolicy = getDownstreamRoutingPolicy(request); - const excludeChannelIds: number[] = []; - let retryCount = 0; - - while (retryCount <= MAX_RETRIES) { - let selected = retryCount === 0 - ? await tokenRouter.selectChannel(requestedModel, downstreamPolicy) - : await tokenRouter.selectNextChannel(requestedModel, excludeChannelIds, downstreamPolicy); - - if (!selected && retryCount === 0) { - await refreshModelsAndRebuildRoutes(); - selected = await tokenRouter.selectChannel(requestedModel, downstreamPolicy); - } +export async function imagesProxyRoute(app: FastifyInstance) { + ensureMultipartBufferParser(app); + + app.post('/v1/images/generations', async (request: FastifyRequest, reply: FastifyReply) => { + const body = request.body as any; + const requestedModel = body?.model || 'gpt-image-1'; + if (!await ensureModelAllowedForDownstreamKey(request, reply, requestedModel)) return; + const downstreamPolicy = getDownstreamRoutingPolicy(request); + const 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({ @@ -49,19 +50,19 @@ export async function imagesProxyRoute(app: FastifyInstance) { excludeChannelIds.push(selected.channel.id); - const targetUrl = buildUpstreamUrl(selected.site.url, '/v1/images/generations'); + const targetUrl = buildUpstreamUrl(selected.site.url, '/v1/images/generations'); const forwardBody = { ...body, model: selected.actualModel }; const startTime = Date.now(); try { - const upstream = await fetch(targetUrl, withSiteRecordProxyRequestInit(selected.site, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'Authorization': `Bearer ${selected.tokenValue}`, - }, - body: JSON.stringify(forwardBody), - })); + const upstream = await fetch(targetUrl, withSiteRecordProxyRequestInit(selected.site, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${selected.tokenValue}`, + }, + body: JSON.stringify(forwardBody), + }, getProxyUrlFromExtraConfig(selected.account.extraConfig))); const text = await upstream.text(); if (!upstream.ok) { @@ -75,10 +76,10 @@ export async function imagesProxyRoute(app: FastifyInstance) { detail: `HTTP ${upstream.status}`, }); } - if (shouldRetryProxyRequest(upstream.status, text) && retryCount < MAX_RETRIES) { - retryCount++; - continue; - } + if (shouldRetryProxyRequest(upstream.status, text) && retryCount < MAX_RETRIES) { + retryCount++; + continue; + } await reportProxyAllFailed({ model: requestedModel, reason: `upstream returned HTTP ${upstream.status}`, @@ -90,18 +91,18 @@ export async function imagesProxyRoute(app: FastifyInstance) { try { data = JSON.parse(text); } catch { data = { data: [] }; } const latency = Date.now() - startTime; - const estimatedCost = await estimateProxyCost({ - site: selected.site, - account: selected.account, - modelName: selected.actualModel || requestedModel, + const estimatedCost = await estimateProxyCost({ + site: selected.site, + account: selected.account, + modelName: selected.actualModel || requestedModel, promptTokens: 0, completionTokens: 0, totalTokens: 0, - }); - tokenRouter.recordSuccess(selected.channel.id, latency, estimatedCost); - recordDownstreamCostUsage(request, estimatedCost); - logProxy(selected, requestedModel, 'success', upstream.status, latency, null, retryCount, estimatedCost); - return reply.code(upstream.status).send(data); + }); + tokenRouter.recordSuccess(selected.channel.id, latency, estimatedCost); + recordDownstreamCostUsage(request, estimatedCost); + logProxy(selected, requestedModel, 'success', upstream.status, latency, null, retryCount, estimatedCost); + return reply.code(upstream.status).send(data); } catch (err: any) { tokenRouter.recordFailure(selected.channel.id); logProxy(selected, requestedModel, 'failed', 0, Date.now() - startTime, err.message, retryCount); @@ -116,175 +117,176 @@ export async function imagesProxyRoute(app: FastifyInstance) { return reply.code(502).send({ error: { message: `Upstream error: ${err.message}`, type: 'upstream_error' }, }); - } - } - }); - - app.post('/v1/images/edits', async (request: FastifyRequest, reply: FastifyReply) => { - const multipartForm = await parseMultipartFormData(request); - const jsonBody = (!multipartForm && request.body && typeof request.body === 'object') - ? request.body as Record - : 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 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); - logProxy(selected, requestedModel, 'failed', upstream.status, Date.now() - startTime, text, retryCount, 0, '/v1/images/edits'); - 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); - recordDownstreamCostUsage(request, estimatedCost); - logProxy(selected, requestedModel, 'success', upstream.status, latency, null, retryCount, estimatedCost, '/v1/images/edits'); - return reply.code(upstream.status).send(data); - } catch (err: any) { - tokenRouter.recordFailure(selected.channel.id); - logProxy(selected, requestedModel, 'failed', 0, Date.now() - startTime, err.message, retryCount, 0, '/v1/images/edits'); - 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', - }, - }); - }); -} + } + } + }); + + 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 + : 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 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 accountProxy = getProxyUrlFromExtraConfig(selected.account.extraConfig); + const requestInit = multipartForm + ? withSiteRecordProxyRequestInit(selected.site, { + method: 'POST', + headers: { + Authorization: `Bearer ${selected.tokenValue}`, + }, + body: cloneFormDataWithOverrides(multipartForm, { + model: selected.actualModel || requestedModel, + }) as any, + }, accountProxy) + : withSiteRecordProxyRequestInit(selected.site, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${selected.tokenValue}`, + }, + body: JSON.stringify({ + ...(jsonBody || {}), + model: selected.actualModel || requestedModel, + }), + }, accountProxy); + + const upstream = await fetch(targetUrl, requestInit); + const text = await upstream.text(); + if (!upstream.ok) { + tokenRouter.recordFailure(selected.channel.id); + logProxy(selected, requestedModel, 'failed', upstream.status, Date.now() - startTime, text, retryCount, 0, '/v1/images/edits'); + 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); + recordDownstreamCostUsage(request, estimatedCost); + logProxy(selected, requestedModel, 'success', upstream.status, latency, null, retryCount, estimatedCost, '/v1/images/edits'); + return reply.code(upstream.status).send(data); + } catch (err: any) { + tokenRouter.recordFailure(selected.channel.id); + logProxy(selected, requestedModel, 'failed', 0, Date.now() - startTime, err.message, retryCount, 0, '/v1/images/edits'); + 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, - estimatedCost = 0, - downstreamPath = '/v1/images/generations', -) { - try { - const createdAt = formatUtcSqlDateTime(new Date()); - const normalizedErrorMessage = composeProxyLogMessage({ - downstreamPath, - errorMessage, - }); - await db.insert(schema.proxyLogs).values({ - routeId: selected.channel.routeId, - channelId: selected.channel.id, - accountId: selected.account.id, - modelRequested, - modelActual: selected.actualModel, - status, - httpStatus, - latencyMs, - promptTokens: 0, - completionTokens: 0, - totalTokens: 0, - estimatedCost, - errorMessage: normalizedErrorMessage, - retryCount, - createdAt, - }).run(); - } catch (error) { - console.warn('[proxy/images] failed to write proxy log', error); - } -} + latencyMs: number, + errorMessage: string | null, + retryCount: number, + estimatedCost = 0, + downstreamPath = '/v1/images/generations', +) { + try { + const createdAt = formatUtcSqlDateTime(new Date()); + const normalizedErrorMessage = composeProxyLogMessage({ + downstreamPath, + errorMessage, + }); + await db.insert(schema.proxyLogs).values({ + routeId: selected.channel.routeId, + channelId: selected.channel.id, + accountId: selected.account.id, + modelRequested, + modelActual: selected.actualModel, + status, + httpStatus, + latencyMs, + promptTokens: 0, + completionTokens: 0, + totalTokens: 0, + estimatedCost, + errorMessage: normalizedErrorMessage, + retryCount, + createdAt, + }).run(); + } catch (error) { + console.warn('[proxy/images] failed to write proxy log', error); + } +} diff --git a/src/server/routes/proxy/responses.ts b/src/server/routes/proxy/responses.ts index 0cd79aa..c336416 100644 --- a/src/server/routes/proxy/responses.ts +++ b/src/server/routes/proxy/responses.ts @@ -8,7 +8,8 @@ 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 { resolveProxyUrlForSite, withSiteRecordProxyRequestInit } from '../../services/siteProxy.js'; +import { resolveChannelProxyUrl, withSiteRecordProxyRequestInit } from '../../services/siteProxy.js'; +import { getProxyUrlFromExtraConfig } from '../../services/accountExtraConfig.js'; import { openAiResponsesTransformer } from '../../transformers/openai/responses/index.js'; import { buildUpstreamEndpointRequest, @@ -259,7 +260,7 @@ export async function responsesProxyRoute(app: FastifyInstance) { method: 'POST', headers: compatibilityRequest.headers, body: JSON.stringify(compatibilityRequest.body), - }), + }, getProxyUrlFromExtraConfig(selected.account.extraConfig)), ), }); @@ -268,7 +269,7 @@ export async function responsesProxyRoute(app: FastifyInstance) { try { const endpointResult = await executeEndpointFlow({ siteUrl: selected.site.url, - proxyUrl: resolveProxyUrlForSite(selected.site), + proxyUrl: resolveChannelProxyUrl(selected.site, selected.account.extraConfig), endpointCandidates, buildRequest: (endpoint) => buildEndpointRequest(endpoint), tryRecover: endpointStrategy.tryRecover, diff --git a/src/server/routes/proxy/search.ts b/src/server/routes/proxy/search.ts index 4a3c8b0..5c161c2 100644 --- a/src/server/routes/proxy/search.ts +++ b/src/server/routes/proxy/search.ts @@ -8,6 +8,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 { buildUpstreamUrl } from './upstreamUrl.js'; @@ -92,7 +93,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) { diff --git a/src/server/routes/proxy/videos.ts b/src/server/routes/proxy/videos.ts index a5b1438..7f52344 100644 --- a/src/server/routes/proxy/videos.ts +++ b/src/server/routes/proxy/videos.ts @@ -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(); From ffc2ad039cbf587cf10161e578f539b95bd1eb0c Mon Sep 17 00:00:00 2001 From: Hureru <3507039083@qq.com> Date: Mon, 16 Mar 2026 01:06:40 +0800 Subject: [PATCH 04/17] feat(proxy): wrap platform adapter calls with account proxy override Use withAccountProxyOverride to propagate account-level proxy URL through AsyncLocalStorage for checkin, balance refresh, model discovery, token sync, rebind-session verify, and token CRUD operations. Also accept proxyUrl field in PUT /api/accounts/:id with validation via parseSiteProxyUrlInput. --- src/server/routes/api/accountTokens.ts | 59 ++++++++++++++++---------- src/server/routes/api/accounts.ts | 20 ++++++++- src/server/services/balanceService.ts | 12 ++++-- src/server/services/checkinService.ts | 14 ++++-- src/server/services/modelService.ts | 13 ++++-- 5 files changed, 83 insertions(+), 35 deletions(-) diff --git a/src/server/routes/api/accountTokens.ts b/src/server/routes/api/accountTokens.ts index 2ab0279..8763485 100644 --- a/src/server/routes/api/accountTokens.ts +++ b/src/server/routes/api/accountTokens.ts @@ -12,8 +12,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'; type AccountWithSiteRow = { accounts: typeof schema.accounts.$inferSelect; @@ -231,15 +232,18 @@ async function executeAccountTokenSync(row: AccountWithSiteRow): Promise 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)`, ); @@ -468,20 +472,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: '站点创建令牌失败' }); @@ -536,11 +543,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: '站点删除令牌失败,本地未删除' }; @@ -771,7 +781,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) { diff --git a/src/server/routes/api/accounts.ts b/src/server/routes/api/accounts.ts index c298ec8..e9d451c 100644 --- a/src/server/routes/api/accounts.ts +++ b/src/server/routes/api/accounts.ts @@ -7,6 +7,7 @@ import { refreshModelsForAccount, rebuildTokenRoutesFromAvailability } from '../ import { ensureDefaultTokenForAccount, syncTokensFromUpstream } from '../../services/accountTokenService.js'; import { getCredentialModeFromExtraConfig, + getProxyUrlFromExtraConfig, guessPlatformUserIdFromUsername, getSub2ApiAuthFromExtraConfig, mergeAccountExtraConfig, @@ -25,7 +26,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 = { @@ -890,7 +891,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, @@ -1210,6 +1214,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(); diff --git a/src/server/services/balanceService.ts b/src/server/services/balanceService.ts index f5806b2..b9afa19 100644 --- a/src/server/services/balanceService.ts +++ b/src/server/services/balanceService.ts @@ -6,6 +6,7 @@ import { reportTokenExpired } from './alertService.js'; import { getAutoReloginConfig, getCredentialModeFromExtraConfig, + getProxyUrlFromExtraConfig, getSub2ApiAuthFromExtraConfig, mergeAccountExtraConfig, resolvePlatformUserId, @@ -14,7 +15,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'; @@ -316,7 +317,10 @@ async function tryAutoRelogin(account: any, site: any): Promise { 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) @@ -393,7 +397,9 @@ export async function refreshBalance(accountId: number) { } } - const readBalance = async (token: string) => adapter.getBalance(site.url, token, platformUserId); + const accountProxyUrl = getProxyUrlFromExtraConfig(account.extraConfig); + 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, { diff --git a/src/server/services/checkinService.ts b/src/server/services/checkinService.ts index fc5c77c..4ada9f7 100644 --- a/src/server/services/checkinService.ts +++ b/src/server/services/checkinService.ts @@ -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 { 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)); } } diff --git a/src/server/services/modelService.ts b/src/server/services/modelService.ts index b97ad79..df70983 100644 --- a/src/server/services/modelService.ts +++ b/src/server/services/modelService.ts @@ -2,10 +2,11 @@ import { and, eq } from 'drizzle-orm'; import { db, schema } from '../db/index.js'; import { getAdapter } from './platforms/index.js'; import { ensureDefaultTokenForAccount, getPreferredAccountToken } from './accountTokenService.js'; -import { getCredentialModeFromExtraConfig, resolvePlatformUserId } from './accountExtraConfig.js'; +import { getCredentialModeFromExtraConfig, getProxyUrlFromExtraConfig, resolvePlatformUserId } from './accountExtraConfig.js'; import { invalidateTokenRouterCache } from './tokenRouter.js'; import { setAccountRuntimeHealth } from './accountHealthService.js'; import { clearAllRouteDecisionSnapshots } from './routeDecisionSnapshotStore.js'; +import { withAccountProxyOverride } from './siteProxy.js'; const API_TOKEN_DISCOVERY_TIMEOUT_MS = 8_000; const MODEL_DISCOVERY_TIMEOUT_MS = 12_000; @@ -129,12 +130,14 @@ export async function refreshModelsForAccount(accountId: number) { } const platformUserId = resolvePlatformUserId(account.extraConfig, account.username); + const accountProxyUrl = getProxyUrlFromExtraConfig(account.extraConfig); let discoveredApiToken: string | null = null; 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)`, ); @@ -200,7 +203,8 @@ export async function refreshModelsForAccount(accountId: number) { 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)`, ), @@ -227,7 +231,8 @@ export async function refreshModelsForAccount(accountId: number) { 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)`, ), From f7b163c8b04749104e01c456c1a5b08290ace74a Mon Sep 17 00:00:00 2001 From: Hureru <3507039083@qq.com> Date: Mon, 16 Mar 2026 01:06:57 +0800 Subject: [PATCH 05/17] feat(web): add proxy URL field to account edit panel Add proxyUrl input to the account edit form with a hint about supported protocols. Display a proxy badge on accounts that have a custom proxy configured in both mobile and desktop list views. --- src/web/pages/Accounts.tsx | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/src/web/pages/Accounts.tsx b/src/web/pages/Accounts.tsx index 1659ddd..498d274 100644 --- a/src/web/pages/Accounts.tsx +++ b/src/web/pages/Accounts.tsx @@ -110,6 +110,7 @@ export default function Accounts() { isPinned: false, refreshToken: '', tokenExpiresAt: '', + proxyUrl: '', }); const [savingEdit, setSavingEdit] = useState(false); const [rebindTarget, setRebindTarget] = useState(null); @@ -664,6 +665,10 @@ export default function Accounts() { const openEditPanel = (account: any) => { const managedAuth = extractManagedSub2ApiAuth(account); + const proxyUrl = (() => { + try { return JSON.parse(account?.extraConfig || '{}')?.proxyUrl || ''; } + catch { return ''; } + })(); closeAddPanel(); setRebindTarget(null); setEditingAccount(account); @@ -677,6 +682,7 @@ export default function Accounts() { isPinned: !!account?.isPinned, refreshToken: managedAuth.refreshToken, tokenExpiresAt: managedAuth.tokenExpiresAt, + proxyUrl, }); }; @@ -699,6 +705,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(); @@ -1534,6 +1541,15 @@ export default function Accounts() { onChange={(e) => setEditForm((prev) => ({ ...prev, apiToken: e.target.value }))} style={{ ...inputStyle, fontFamily: 'var(--font-mono)' }} /> + setEditForm((prev) => ({ ...prev, proxyUrl: e.target.value }))} + style={inputStyle} + /> +
+ 覆盖站点和系统代理,留空则使用站点设置。支持 http/https/socks5 协议。 +
{((editingAccount?.site?.platform || '').toLowerCase() === 'sub2api') && ( <> {connectionMode === 'apikey' ? 'API Key' : 'Session'} + {(() => { try { return JSON.parse(a.extraConfig || '{}')?.proxyUrl; } catch { return null; } })() && ( + 代理 + )} )} > @@ -1800,6 +1819,9 @@ export default function Accounts() { {connectionMode === 'apikey' ? 'API Key' : 'Session'} + {(() => { try { return JSON.parse(a.extraConfig || '{}')?.proxyUrl; } catch { return null; } })() && ( + 代理 + )} From f36e867e1db670febac9f654388c21338386bb45 Mon Sep 17 00:00:00 2001 From: Hureru <3507039083@qq.com> Date: Mon, 16 Mar 2026 01:07:14 +0800 Subject: [PATCH 06/17] test(proxy): add tests for account-level proxy resolution Cover getProxyUrlFromExtraConfig, resolveChannelProxyUrl, withSiteRecordProxyRequestInit third parameter, and withAccountProxyOverride ALS context propagation. --- .../services/accountExtraConfig.test.ts | 15 ++++ src/server/services/siteProxy.test.ts | 73 +++++++++++++++++++ 2 files changed, 88 insertions(+) diff --git a/src/server/services/accountExtraConfig.test.ts b/src/server/services/accountExtraConfig.test.ts index a691796..2143dca 100644 --- a/src/server/services/accountExtraConfig.test.ts +++ b/src/server/services/accountExtraConfig.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest'; import { getCredentialModeFromExtraConfig, getPlatformUserIdFromExtraConfig, + getProxyUrlFromExtraConfig, getSub2ApiAuthFromExtraConfig, guessPlatformUserIdFromUsername, mergeAccountExtraConfig, @@ -67,4 +68,18 @@ describe('accountExtraConfig', () => { sub2apiAuth: { refreshToken: ' ' }, }))).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(); + }); }); diff --git a/src/server/services/siteProxy.test.ts b/src/server/services/siteProxy.test.ts index 5cdb5f9..b1051e7 100644 --- a/src/server/services/siteProxy.test.ts +++ b/src/server/services/siteProxy.test.ts @@ -115,4 +115,77 @@ describe('siteProxy', () => { expect(headers.get('x-site-scope')).toBe('site-level'); expect(headers.get('x-request-id')).toBe('req-1'); }); + + 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); + }); }); From 252d40d8978e9940de01c063ba86809b59bee77f Mon Sep 17 00:00:00 2001 From: Hureru <3507039083@qq.com> Date: Thu, 19 Mar 2026 00:48:12 +0800 Subject: [PATCH 07/17] fix(proxy): fix merge conflict syntax error in images.ts catch block --- src/server/routes/proxy/images.ts | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/src/server/routes/proxy/images.ts b/src/server/routes/proxy/images.ts index 7b42dad..dce19f6 100644 --- a/src/server/routes/proxy/images.ts +++ b/src/server/routes/proxy/images.ts @@ -109,7 +109,23 @@ export async function imagesProxyRoute(app: FastifyInstance) { return reply.code(upstream.status).send(data); } catch (err: any) { tokenRouter.recordFailure(selected.channel.id); - logProxy(selected, requestedModel, 'failed', 0, Date.now() - startTime, err.message, retryCount, logDownstreamApiKeyId ? downstreamApiKeyId : null);, async (request: FastifyRequest, reply: FastifyReply) => { + logProxy(selected, requestedModel, 'failed', 0, Date.now() - startTime, err.message, retryCount, logDownstreamApiKeyId ? downstreamApiKeyId : null); + if (retryCount < MAX_RETRIES) { + retryCount++; + continue; + } + await reportProxyAllFailed({ + model: requestedModel, + reason: err.message || 'network failure', + }); + return reply.code(502).send({ + error: { message: `Upstream error: ${err.message}`, type: 'upstream_error' }, + }); + } + } + }); + + app.post('/v1/images/edits', async (request: FastifyRequest, reply: FastifyReply) => { const multipartForm = await parseMultipartFormData(request); const jsonBody = (!multipartForm && request.body && typeof request.body === 'object') ? request.body as Record From 849800c4cdbd6bb21b5da4a9867b0bdeda4c9e4b Mon Sep 17 00:00:00 2001 From: Hureru <3507039083@qq.com> Date: Thu, 19 Mar 2026 01:12:39 +0800 Subject: [PATCH 08/17] feat(web): use purple badge capsule for account proxy indicator --- src/web/index.css | 5 +++++ src/web/pages/Accounts.tsx | 4 ++-- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/src/web/index.css b/src/web/index.css index d30401a..114d064 100644 --- a/src/web/index.css +++ b/src/web/index.css @@ -1869,6 +1869,11 @@ body { color: var(--color-text-muted); } +.badge-purple { + background: #ede9fe; + color: #6d28d9; +} + .badge-link { display: inline-flex; text-decoration: none; diff --git a/src/web/pages/Accounts.tsx b/src/web/pages/Accounts.tsx index 9b279e0..548d7ce 100644 --- a/src/web/pages/Accounts.tsx +++ b/src/web/pages/Accounts.tsx @@ -1601,7 +1601,7 @@ export default function Accounts() { {connectionMode === 'apikey' ? 'API Key' : 'Session'} {(() => { try { return JSON.parse(a.extraConfig || '{}')?.proxyUrl; } catch { return null; } })() && ( - 代理 + 代理 )} )} @@ -1814,7 +1814,7 @@ export default function Accounts() { {connectionMode === 'apikey' ? 'API Key' : 'Session'} {(() => { try { return JSON.parse(a.extraConfig || '{}')?.proxyUrl; } catch { return null; } })() && ( - 代理 + 代理 )} From 17afe3319e92c2ff9bd058ff398e3bdadf7e081a Mon Sep 17 00:00:00 2001 From: Hureru <3507039083@qq.com> Date: Thu, 19 Mar 2026 01:21:00 +0800 Subject: [PATCH 09/17] fix(proxy): fix merge conflict data loss in embeddings.ts success path --- src/server/routes/proxy/embeddings.ts | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/src/server/routes/proxy/embeddings.ts b/src/server/routes/proxy/embeddings.ts index f79f55b..6838f73 100644 --- a/src/server/routes/proxy/embeddings.ts +++ b/src/server/routes/proxy/embeddings.ts @@ -95,6 +95,32 @@ export async function embeddingsProxyRoute(app: FastifyInstance) { return reply.code(upstream.status).send({ error: { message: text, type: 'upstream_error' } }); } + let data: any = {}; + try { data = JSON.parse(text); } catch { data = {}; } + const parsedUsage = parseProxyUsage(data); + const latency = Date.now() - startTime; + const resolvedUsage = await resolveProxyUsageWithSelfLogFallback({ + site: selected.site, + account: selected.account, + tokenValue: selected.tokenValue, + tokenName: selected.tokenName, + modelName: selected.actualModel || requestedModel, + requestStartedAtMs: startTime, + requestEndedAtMs: startTime + latency, + localLatencyMs: latency, + usage: { + promptTokens: parsedUsage.promptTokens, + completionTokens: parsedUsage.completionTokens, + totalTokens: parsedUsage.totalTokens, + }, + }); + const { estimatedCost, billingDetails } = await resolveProxyLogBilling({ + site: selected.site, + account: selected.account, + modelName: selected.actualModel || requestedModel, + parsedUsage, + resolvedUsage, + }); tokenRouter.recordSuccess(selected.channel.id, latency, estimatedCost); recordDownstreamCostUsage(request, estimatedCost); logProxy( From 619df1429122517565350f267d4fb6ae7c4da3ba Mon Sep 17 00:00:00 2001 From: Hureru <3507039083@qq.com> Date: Thu, 19 Mar 2026 01:30:37 +0800 Subject: [PATCH 10/17] fix(proxy): apply account-level proxy to count_tokens and completions routes --- src/server/routes/proxy/chat.ts | 4 ++-- src/server/routes/proxy/completions.ts | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/server/routes/proxy/chat.ts b/src/server/routes/proxy/chat.ts index 51531bb..d4c758f 100644 --- a/src/server/routes/proxy/chat.ts +++ b/src/server/routes/proxy/chat.ts @@ -839,7 +839,7 @@ async function handleClaudeCountTokensRequest( method: 'POST', headers: requestForFetch.headers, body: JSON.stringify(requestForFetch.body), - }), + }, resolveChannelProxyUrl(selected.site, selected.account.extraConfig)), }); if (upstream.status === 401 && oauth) { @@ -859,7 +859,7 @@ async function handleClaudeCountTokensRequest( 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. diff --git a/src/server/routes/proxy/completions.ts b/src/server/routes/proxy/completions.ts index 1a2bdcd..7e7d1b5 100644 --- a/src/server/routes/proxy/completions.ts +++ b/src/server/routes/proxy/completions.ts @@ -71,7 +71,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'); From bece34ae5b984a3c6b28afe3019e88223f12c439 Mon Sep 17 00:00:00 2001 From: Hureru <3507039083@qq.com> Date: Thu, 19 Mar 2026 01:39:35 +0800 Subject: [PATCH 11/17] fix(proxy): skip dispatcher cache for account-level proxy URLs --- src/server/services/siteProxy.ts | 22 ++++++++++++++-------- src/web/index.css | 5 +++++ 2 files changed, 19 insertions(+), 8 deletions(-) diff --git a/src/server/services/siteProxy.ts b/src/server/services/siteProxy.ts index 9cdc5e7..3f846f6 100644 --- a/src/server/services/siteProxy.ts +++ b/src/server/services/siteProxy.ts @@ -154,19 +154,23 @@ async function getCachedSiteProxyRows(nowMs = Date.now()): Promise Date: Thu, 19 Mar 2026 01:43:22 +0800 Subject: [PATCH 12/17] fix(proxy): apply account proxy to OAuth model discovery calls --- src/server/services/modelService.ts | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/src/server/services/modelService.ts b/src/server/services/modelService.ts index fe5a3bc..4bc094c 100644 --- a/src/server/services/modelService.ts +++ b/src/server/services/modelService.ts @@ -496,6 +496,7 @@ export async function refreshModelsForAccount(accountId: number): Promise discoverCodexModelsFromCloud({ site, account }), + () => withAccountProxyOverride(accountProxyUrl, + () => discoverCodexModelsFromCloud({ site, account })), MODEL_DISCOVERY_TIMEOUT_MS, `codex model discovery timeout (${Math.round(MODEL_DISCOVERY_TIMEOUT_MS / 1000)}s)`, ); @@ -595,7 +597,8 @@ export async function refreshModelsForAccount(accountId: number): Promise validateClaudeOauthConnection({ site, account }), + () => withAccountProxyOverride(accountProxyUrl, + () => validateClaudeOauthConnection({ site, account })), MODEL_DISCOVERY_TIMEOUT_MS, `claude oauth validation timeout (${Math.round(MODEL_DISCOVERY_TIMEOUT_MS / 1000)}s)`, ); @@ -661,7 +664,8 @@ export async function refreshModelsForAccount(accountId: number): Promise validateGeminiCliOauthConnection({ account }), + () => withAccountProxyOverride(accountProxyUrl, + () => validateGeminiCliOauthConnection({ account })), MODEL_DISCOVERY_TIMEOUT_MS, `gemini cli oauth validation timeout (${Math.round(MODEL_DISCOVERY_TIMEOUT_MS / 1000)}s)`, ); @@ -727,7 +731,8 @@ export async function refreshModelsForAccount(accountId: number): Promise discoverAntigravityModelsFromCloud({ site, account }), + () => withAccountProxyOverride(accountProxyUrl, + () => discoverAntigravityModelsFromCloud({ site, account })), MODEL_DISCOVERY_TIMEOUT_MS, `antigravity model discovery timeout (${Math.round(MODEL_DISCOVERY_TIMEOUT_MS / 1000)}s)`, ); @@ -797,7 +802,6 @@ export async function refreshModelsForAccount(accountId: number): Promise Date: Thu, 19 Mar 2026 01:45:15 +0800 Subject: [PATCH 13/17] fix(proxy): apply account proxy to balance refresh and income fallback calls --- src/server/services/balanceService.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/server/services/balanceService.ts b/src/server/services/balanceService.ts index b9afa19..ec8902f 100644 --- a/src/server/services/balanceService.ts +++ b/src/server/services/balanceService.ts @@ -429,12 +429,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); @@ -467,12 +467,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; } From bef1cd05eaa070366e847a71880a6a9793dae2f9 Mon Sep 17 00:00:00 2001 From: Hureru <3507039083@qq.com> Date: Thu, 19 Mar 2026 01:47:39 +0800 Subject: [PATCH 14/17] fix(proxy): apply account proxy to image edits route --- src/server/routes/proxy/images.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/server/routes/proxy/images.ts b/src/server/routes/proxy/images.ts index dce19f6..66141a5 100644 --- a/src/server/routes/proxy/images.ts +++ b/src/server/routes/proxy/images.ts @@ -176,7 +176,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: { @@ -187,7 +187,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(); From 7c6b729ce1a41ae5f42ad487472b86c0dedec205 Mon Sep 17 00:00:00 2001 From: Hureru <3507039083@qq.com> Date: Thu, 19 Mar 2026 01:48:52 +0800 Subject: [PATCH 15/17] fix(proxy): preserve requestedModel fallback in embeddings forwardBody --- src/server/routes/proxy/embeddings.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/server/routes/proxy/embeddings.ts b/src/server/routes/proxy/embeddings.ts index 6838f73..a0715c3 100644 --- a/src/server/routes/proxy/embeddings.ts +++ b/src/server/routes/proxy/embeddings.ts @@ -56,7 +56,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 { From 4d0c4b812963731f1c49394ffb506515dc31d59e Mon Sep 17 00:00:00 2001 From: Hureru <3507039083@qq.com> Date: Sat, 21 Mar 2026 11:44:57 +0800 Subject: [PATCH 16/17] feat(proxy): propagate account-level proxy across all surfaces - chatSurface/openAiResponsesSurface: extract channelProxyUrl once and pass to withSiteRecordProxyRequestInit - geminiSurface: switch from withSiteProxyRequestInit to withSiteRecordProxyRequestInit with channelProxyUrl - balanceService: pass accountProxyUrl to sub2api token refresh requests - siteProxy: fix isAccountOverride to only be true when account proxy differs from site proxy - Accounts.tsx: use parseAccountExtraConfig helper for proxy badge display --- src/server/proxy-core/surfaces/chatSurface.ts | 4 +- .../proxy-core/surfaces/geminiSurface.ts | 12 +++--- .../surfaces/openAiResponsesSurface.ts | 4 +- src/server/services/balanceService.ts | 10 ++--- src/server/services/siteProxy.ts | 6 ++- src/web/pages/Accounts.tsx | 40 +++++++++---------- 6 files changed, 38 insertions(+), 38 deletions(-) diff --git a/src/server/proxy-core/surfaces/chatSurface.ts b/src/server/proxy-core/surfaces/chatSurface.ts index 45a59bb..b1809ef 100644 --- a/src/server/proxy-core/surfaces/chatSurface.ts +++ b/src/server/proxy-core/surfaces/chatSurface.ts @@ -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({ @@ -265,7 +266,6 @@ export async function handleChatSurfaceRequest( try { const endpointResult = await executeEndpointFlow({ siteUrl: selected.site.url, - proxyUrl: resolveChannelProxyUrl(selected.site, selected.account.extraConfig), endpointCandidates, buildRequest: (endpoint) => buildEndpointRequest(endpoint), dispatchRequest, diff --git a/src/server/proxy-core/surfaces/geminiSurface.ts b/src/server/proxy-core/surfaces/geminiSurface.ts index bbeb0be..03fae42 100644 --- a/src/server/proxy-core/surfaces/geminiSurface.ts +++ b/src/server/proxy-core/surfaces/geminiSurface.ts @@ -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({ diff --git a/src/server/proxy-core/surfaces/openAiResponsesSurface.ts b/src/server/proxy-core/surfaces/openAiResponsesSurface.ts index 332f099..006284e 100644 --- a/src/server/proxy-core/surfaces/openAiResponsesSurface.ts +++ b/src/server/proxy-core/surfaces/openAiResponsesSurface.ts @@ -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({ @@ -355,7 +356,6 @@ export async function handleOpenAiResponsesSurfaceRequest( try { const endpointResult = await executeEndpointFlow({ siteUrl: selected.site.url, - proxyUrl: resolveChannelProxyUrl(selected.site, selected.account.extraConfig), endpointCandidates, buildRequest: (endpoint) => buildEndpointRequest(endpoint), dispatchRequest, diff --git a/src/server/services/balanceService.ts b/src/server/services/balanceService.ts index 1e7d393..5540a12 100644 --- a/src/server/services/balanceService.ts +++ b/src/server/services/balanceService.ts @@ -201,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'); @@ -382,23 +382,23 @@ 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 accountProxyUrl = getProxyUrlFromExtraConfig(account.extraConfig); const readBalance = async (token: string) => withAccountProxyOverride(accountProxyUrl, () => adapter.getBalance(site.url, token, platformUserId)); const handleBalanceError = async (err: any) => { diff --git a/src/server/services/siteProxy.ts b/src/server/services/siteProxy.ts index 3c54afe..c36d2c0 100644 --- a/src/server/services/siteProxy.ts +++ b/src/server/services/siteProxy.ts @@ -470,8 +470,10 @@ export function withSiteRecordProxyRequestInit( nextOptions.headers = mergedHeaders; } const accountNormalized = normalizeSiteProxyUrl(accountProxyUrl); - const proxyUrl = accountNormalized || resolveProxyUrlForSite(site); - return withExplicitProxyRequestInit(proxyUrl, nextOptions, !!accountNormalized); + const siteProxyUrl = resolveProxyUrlForSite(site); + const proxyUrl = accountNormalized || siteProxyUrl; + const isAccountOverride = !!accountNormalized && accountNormalized !== siteProxyUrl; + return withExplicitProxyRequestInit(proxyUrl, nextOptions, isAccountOverride); } export function resolveChannelProxyUrl( diff --git a/src/web/pages/Accounts.tsx b/src/web/pages/Accounts.tsx index 9f05c3c..179096e 100644 --- a/src/web/pages/Accounts.tsx +++ b/src/web/pages/Accounts.tsx @@ -653,25 +653,23 @@ export default function Accounts() { } }; + const parseAccountExtraConfig = (account: any): Record => { + 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 = (() => { - try { return JSON.parse(account?.extraConfig || '{}')?.proxyUrl || ''; } - catch { return ''; } - })(); + const proxyUrl = parseAccountExtraConfig(account)?.proxyUrl || ''; closeAddPanel(); setRebindTarget(null); setEditingAccount(account); @@ -793,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) : ''; }; @@ -1605,7 +1601,7 @@ export default function Accounts() { {connectionMode === 'apikey' ? 'API Key' : 'Session'} - {(() => { try { return JSON.parse(a.extraConfig || '{}')?.proxyUrl; } catch { return null; } })() && ( + {parseAccountExtraConfig(a)?.proxyUrl && ( 代理 )} @@ -1818,7 +1814,7 @@ export default function Accounts() { {connectionMode === 'apikey' ? 'API Key' : 'Session'} - {(() => { try { return JSON.parse(a.extraConfig || '{}')?.proxyUrl; } catch { return null; } })() && ( + {parseAccountExtraConfig(a)?.proxyUrl && ( 代理 )} From 04655a8b17cb1c0fe2ad1b145380743d16b3f243 Mon Sep 17 00:00:00 2001 From: ciat-777 Date: Sat, 21 Mar 2026 12:46:46 +0800 Subject: [PATCH 17/17] fix(proxy): honor account proxy during oauth discovery --- .../services/modelService.discovery.test.ts | 124 ++++++++++++++++++ src/server/services/modelService.ts | 6 +- src/server/services/siteProxy.ts | 2 +- 3 files changed, 128 insertions(+), 4 deletions(-) diff --git a/src/server/services/modelService.discovery.test.ts b/src/server/services/modelService.discovery.test.ts index 9de6827..3cef888 100644 --- a/src/server/services/modelService.discovery.test.ts +++ b/src/server/services/modelService.discovery.test.ts @@ -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')); diff --git a/src/server/services/modelService.ts b/src/server/services/modelService.ts index 98fb546..9f5dbbf 100644 --- a/src/server/services/modelService.ts +++ b/src/server/services/modelService.ts @@ -20,7 +20,7 @@ import { import { invalidateTokenRouterCache } from './tokenRouter.js'; import { setAccountRuntimeHealth } from './accountHealthService.js'; import { clearAllRouteDecisionSnapshots } from './routeDecisionSnapshotStore.js'; -import { withAccountProxyOverride, 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'; @@ -323,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}`, @@ -331,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(() => ''); diff --git a/src/server/services/siteProxy.ts b/src/server/services/siteProxy.ts index c36d2c0..59ac42f 100644 --- a/src/server/services/siteProxy.ts +++ b/src/server/services/siteProxy.ts @@ -469,7 +469,7 @@ export function withSiteRecordProxyRequestInit( if (mergedHeaders) { nextOptions.headers = mergedHeaders; } - const accountNormalized = normalizeSiteProxyUrl(accountProxyUrl); + const accountNormalized = normalizeSiteProxyUrl(accountProxyUrl) ?? accountProxyOverride.getStore(); const siteProxyUrl = resolveProxyUrlForSite(site); const proxyUrl = accountNormalized || siteProxyUrl; const isAccountOverride = !!accountNormalized && accountNormalized !== siteProxyUrl;