forked from boos/BoosAPI
refactor: consolidate single-source routing and protocol helpers
This commit is contained in:
@@ -1,13 +1,9 @@
|
||||
import { TextDecoder } from 'node:util';
|
||||
import type { FastifyRequest, FastifyReply } from 'fastify';
|
||||
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 { reportProxyAllFailed } from '../../services/alertService.js';
|
||||
import { resolveProxyUsageWithSelfLogFallback } from '../../services/proxyUsageFallbackService.js';
|
||||
import { mergeProxyUsage, parseProxyUsage } from '../../services/proxyUsageParser.js';
|
||||
import { resolveChannelProxyUrl, withSiteRecordProxyRequestInit } from '../../services/siteProxy.js';
|
||||
import { type DownstreamFormat } from '../../transformers/shared/normalized.js';
|
||||
import {
|
||||
buildClaudeCountTokensUpstreamRequest,
|
||||
@@ -21,11 +17,9 @@ import {
|
||||
getDownstreamRoutingPolicy,
|
||||
recordDownstreamCostUsage,
|
||||
} from '../../routes/proxy/downstreamPolicy.js';
|
||||
import { composeProxyLogMessage } from '../../routes/proxy/logPathMeta.js';
|
||||
import { executeEndpointFlow, type BuiltEndpointRequest } from '../../routes/proxy/endpointFlow.js';
|
||||
import { detectProxyFailure } from '../../routes/proxy/proxyFailureJudge.js';
|
||||
import { buildUpstreamUrl } from '../../routes/proxy/upstreamUrl.js';
|
||||
import { formatUtcSqlDateTime } from '../../services/localTimeService.js';
|
||||
import { resolveProxyLogBilling } from '../../routes/proxy/proxyBilling.js';
|
||||
import { openAiChatTransformer } from '../../transformers/openai/chat/index.js';
|
||||
import { anthropicMessagesTransformer } from '../../transformers/anthropic/messages/index.js';
|
||||
@@ -37,8 +31,7 @@ import {
|
||||
import {
|
||||
buildOauthProviderHeaders,
|
||||
} from '../../services/oauth/service.js';
|
||||
import { getOauthInfoFromExtraConfig } from '../../services/oauth/oauthAccount.js';
|
||||
import { recordOauthQuotaResetHint } from '../../services/oauth/quota.js';
|
||||
import { getOauthInfoFromAccount } from '../../services/oauth/oauthAccount.js';
|
||||
import { refreshOauthAccessTokenSingleflight } from '../../services/oauth/refreshSingleflight.js';
|
||||
import {
|
||||
collectResponsesFinalPayloadFromSse,
|
||||
@@ -50,10 +43,13 @@ import {
|
||||
createGeminiCliStreamReader,
|
||||
unwrapGeminiCliPayload,
|
||||
} from '../../routes/proxy/geminiCliCompat.js';
|
||||
import { dispatchRuntimeRequest } from '../../routes/proxy/runtimeExecutor.js';
|
||||
import { summarizeConversationFileInputsInOpenAiBody } from '../capabilities/conversationFileCapabilities.js';
|
||||
import { detectDownstreamClientContext, type DownstreamClientContext } from '../../routes/proxy/downstreamClientContext.js';
|
||||
import { insertProxyLog } from '../../services/proxyLogStore.js';
|
||||
import { detectDownstreamClientContext } from '../../routes/proxy/downstreamClientContext.js';
|
||||
import {
|
||||
createSurfaceFailureToolkit,
|
||||
createSurfaceDispatchRequest,
|
||||
selectSurfaceChannelForAttempt,
|
||||
} from './sharedSurface.js';
|
||||
|
||||
const MAX_RETRIES = 2;
|
||||
|
||||
@@ -114,19 +110,24 @@ export async function handleChatSurfaceRequest(
|
||||
proxyToken: getProxyAuthContext(request)?.token || null,
|
||||
});
|
||||
const downstreamApiKeyId = getProxyAuthContext(request)?.keyId ?? null;
|
||||
const failureToolkit = createSurfaceFailureToolkit({
|
||||
warningScope: 'chat',
|
||||
downstreamPath,
|
||||
maxRetries: MAX_RETRIES,
|
||||
clientContext,
|
||||
downstreamApiKeyId,
|
||||
});
|
||||
|
||||
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);
|
||||
}
|
||||
const selected = await selectSurfaceChannelForAttempt({
|
||||
requestedModel,
|
||||
downstreamPolicy,
|
||||
excludeChannelIds,
|
||||
retryCount,
|
||||
});
|
||||
|
||||
if (!selected) {
|
||||
await reportProxyAllFailed({
|
||||
@@ -141,7 +142,7 @@ export async function handleChatSurfaceRequest(
|
||||
excludeChannelIds.push(selected.channel.id);
|
||||
|
||||
const modelName = selected.actualModel || requestedModel;
|
||||
const oauth = getOauthInfoFromExtraConfig(selected.account.extraConfig);
|
||||
const oauth = getOauthInfoFromAccount(selected.account);
|
||||
const isCodexSite = String(selected.site.platform || '').trim().toLowerCase() === 'codex';
|
||||
const endpointCandidates = [
|
||||
...await resolveUpstreamEndpointCandidates(
|
||||
@@ -170,7 +171,7 @@ export async function handleChatSurfaceRequest(
|
||||
};
|
||||
const buildProviderHeaders = () => (
|
||||
buildOauthProviderHeaders({
|
||||
extraConfig: typeof selected.account.extraConfig === 'string' ? selected.account.extraConfig : null,
|
||||
account: selected.account,
|
||||
downstreamHeaders: request.headers as Record<string, unknown>,
|
||||
})
|
||||
);
|
||||
@@ -204,22 +205,10 @@ export async function handleChatSurfaceRequest(
|
||||
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: (_requestUrl, requestForFetch) => withSiteRecordProxyRequestInit(selected.site, {
|
||||
method: 'POST',
|
||||
headers: requestForFetch.headers,
|
||||
body: JSON.stringify(requestForFetch.body),
|
||||
}, channelProxyUrl),
|
||||
})
|
||||
);
|
||||
const dispatchRequest = createSurfaceDispatchRequest({
|
||||
site: selected.site,
|
||||
accountExtraConfig: selected.account.extraConfig,
|
||||
});
|
||||
const endpointStrategy = downstreamTransformer.compatibility.createEndpointStrategy({
|
||||
downstreamFormat,
|
||||
endpointCandidates,
|
||||
@@ -286,82 +275,34 @@ export async function handleChatSurfaceRequest(
|
||||
},
|
||||
shouldDowngrade: endpointStrategy.shouldDowngrade,
|
||||
onDowngrade: (ctx) => {
|
||||
logProxy(
|
||||
selected,
|
||||
requestedModel,
|
||||
'failed',
|
||||
ctx.response.status,
|
||||
Date.now() - startTime,
|
||||
ctx.errText,
|
||||
retryCount,
|
||||
downstreamPath,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
null,
|
||||
null,
|
||||
clientContext,
|
||||
downstreamApiKeyId,
|
||||
);
|
||||
},
|
||||
});
|
||||
return failureToolkit.log({
|
||||
selected,
|
||||
modelRequested: requestedModel,
|
||||
status: 'failed',
|
||||
httpStatus: ctx.response.status,
|
||||
latencyMs: Date.now() - startTime,
|
||||
errorMessage: ctx.errText,
|
||||
retryCount,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
if (!endpointResult.ok) {
|
||||
const status = endpointResult.status || 502;
|
||||
const errText = endpointResult.errText || 'unknown error';
|
||||
const rawErrText = endpointResult.rawErrText || errText;
|
||||
tokenRouter.recordFailure(selected.channel.id, {
|
||||
status,
|
||||
errorText: rawErrText,
|
||||
modelName,
|
||||
});
|
||||
logProxy(
|
||||
const failureOutcome = await failureToolkit.handleUpstreamFailure({
|
||||
selected,
|
||||
requestedModel,
|
||||
'failed',
|
||||
status,
|
||||
Date.now() - startTime,
|
||||
errText,
|
||||
modelName,
|
||||
status: endpointResult.status || 502,
|
||||
errText: endpointResult.errText || 'unknown error',
|
||||
rawErrText: endpointResult.rawErrText,
|
||||
latencyMs: Date.now() - startTime,
|
||||
retryCount,
|
||||
downstreamPath,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
null,
|
||||
null,
|
||||
clientContext,
|
||||
downstreamApiKeyId,
|
||||
);
|
||||
await recordOauthQuotaResetHint({
|
||||
accountId: selected.account.id,
|
||||
statusCode: status,
|
||||
errorText: rawErrText,
|
||||
});
|
||||
|
||||
if (isTokenExpiredError({ status, message: errText })) {
|
||||
await reportTokenExpired({
|
||||
accountId: selected.account.id,
|
||||
username: selected.account.username,
|
||||
siteName: selected.site.name,
|
||||
detail: `HTTP ${status}`,
|
||||
});
|
||||
}
|
||||
|
||||
if (shouldRetryProxyRequest(status, errText) && retryCount < MAX_RETRIES) {
|
||||
if (failureOutcome.action === 'retry') {
|
||||
retryCount += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
await reportProxyAllFailed({
|
||||
model: requestedModel,
|
||||
reason: `upstream returned HTTP ${status}`,
|
||||
});
|
||||
|
||||
return reply.code(status).send({
|
||||
error: { message: errText, type: 'upstream_error' },
|
||||
});
|
||||
return reply.code(failureOutcome.status).send(failureOutcome.payload);
|
||||
}
|
||||
|
||||
const upstream = endpointResult.upstream;
|
||||
@@ -418,25 +359,18 @@ export async function handleChatSurfaceRequest(
|
||||
);
|
||||
const latency = Date.now() - startTime;
|
||||
if (streamResult.status === 'failed') {
|
||||
tokenRouter.recordFailure(selected.channel.id, modelName);
|
||||
logProxy(
|
||||
await failureToolkit.recordStreamFailure({
|
||||
selected,
|
||||
requestedModel,
|
||||
'failed',
|
||||
200,
|
||||
latency,
|
||||
streamResult.errorMessage,
|
||||
modelName,
|
||||
errorMessage: streamResult.errorMessage,
|
||||
latencyMs: latency,
|
||||
retryCount,
|
||||
downstreamPath,
|
||||
parsedUsage.promptTokens,
|
||||
parsedUsage.completionTokens,
|
||||
parsedUsage.totalTokens,
|
||||
0,
|
||||
null,
|
||||
successfulUpstreamPath,
|
||||
clientContext,
|
||||
downstreamApiKeyId,
|
||||
);
|
||||
promptTokens: parsedUsage.promptTokens,
|
||||
completionTokens: parsedUsage.completionTokens,
|
||||
totalTokens: parsedUsage.totalTokens,
|
||||
upstreamPath: successfulUpstreamPath,
|
||||
});
|
||||
return;
|
||||
}
|
||||
return;
|
||||
@@ -454,71 +388,41 @@ export async function handleChatSurfaceRequest(
|
||||
const latency = Date.now() - startTime;
|
||||
const failure = detectProxyFailure({ rawText, usage: parsedUsage });
|
||||
if (failure) {
|
||||
tokenRouter.recordFailure(selected.channel.id, {
|
||||
status: failure.status,
|
||||
errorText: failure.reason,
|
||||
modelName,
|
||||
});
|
||||
logProxy(
|
||||
const failureOutcome = await failureToolkit.handleDetectedFailure({
|
||||
selected,
|
||||
requestedModel,
|
||||
'failed',
|
||||
failure.status,
|
||||
latency,
|
||||
failure.reason,
|
||||
modelName,
|
||||
failure,
|
||||
latencyMs: latency,
|
||||
retryCount,
|
||||
downstreamPath,
|
||||
parsedUsage.promptTokens,
|
||||
parsedUsage.completionTokens,
|
||||
parsedUsage.totalTokens,
|
||||
0,
|
||||
null,
|
||||
successfulUpstreamPath,
|
||||
clientContext,
|
||||
downstreamApiKeyId,
|
||||
);
|
||||
|
||||
if (shouldRetryProxyRequest(failure.status, failure.reason) && retryCount < MAX_RETRIES) {
|
||||
promptTokens: parsedUsage.promptTokens,
|
||||
completionTokens: parsedUsage.completionTokens,
|
||||
totalTokens: parsedUsage.totalTokens,
|
||||
upstreamPath: successfulUpstreamPath,
|
||||
});
|
||||
if (failureOutcome.action === 'retry') {
|
||||
retryCount += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
await reportProxyAllFailed({
|
||||
model: requestedModel,
|
||||
reason: failure.reason,
|
||||
});
|
||||
|
||||
return reply.code(failure.status).send({
|
||||
error: { message: failure.reason, type: 'upstream_error' },
|
||||
});
|
||||
return reply.code(failureOutcome.status).send(failureOutcome.payload);
|
||||
}
|
||||
|
||||
startSseResponse();
|
||||
const streamResult = streamSession.consumeUpstreamFinalPayload(fallbackData, fallbackText, reply.raw);
|
||||
if (streamResult.status === 'failed') {
|
||||
tokenRouter.recordFailure(selected.channel.id, {
|
||||
status: 502,
|
||||
errorText: streamResult.errorMessage,
|
||||
modelName,
|
||||
});
|
||||
logProxy(
|
||||
await failureToolkit.recordStreamFailure({
|
||||
selected,
|
||||
requestedModel,
|
||||
'failed',
|
||||
200,
|
||||
latency,
|
||||
streamResult.errorMessage,
|
||||
modelName,
|
||||
errorMessage: streamResult.errorMessage,
|
||||
latencyMs: latency,
|
||||
retryCount,
|
||||
downstreamPath,
|
||||
parsedUsage.promptTokens,
|
||||
parsedUsage.completionTokens,
|
||||
parsedUsage.totalTokens,
|
||||
0,
|
||||
null,
|
||||
successfulUpstreamPath,
|
||||
clientContext,
|
||||
downstreamApiKeyId,
|
||||
);
|
||||
promptTokens: parsedUsage.promptTokens,
|
||||
completionTokens: parsedUsage.completionTokens,
|
||||
totalTokens: parsedUsage.totalTokens,
|
||||
upstreamPath: successfulUpstreamPath,
|
||||
runtimeFailureStatus: 502,
|
||||
});
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
@@ -550,29 +454,19 @@ export async function handleChatSurfaceRequest(
|
||||
|
||||
const latency = Date.now() - startTime;
|
||||
if (streamResult.status === 'failed') {
|
||||
tokenRouter.recordFailure(selected.channel.id, {
|
||||
status: 502,
|
||||
errorText: streamResult.errorMessage,
|
||||
modelName,
|
||||
});
|
||||
logProxy(
|
||||
await failureToolkit.recordStreamFailure({
|
||||
selected,
|
||||
requestedModel,
|
||||
'failed',
|
||||
200,
|
||||
latency,
|
||||
streamResult.errorMessage,
|
||||
modelName,
|
||||
errorMessage: streamResult.errorMessage,
|
||||
latencyMs: latency,
|
||||
retryCount,
|
||||
downstreamPath,
|
||||
parsedUsage.promptTokens,
|
||||
parsedUsage.completionTokens,
|
||||
parsedUsage.totalTokens,
|
||||
0,
|
||||
null,
|
||||
successfulUpstreamPath,
|
||||
clientContext,
|
||||
downstreamApiKeyId,
|
||||
);
|
||||
promptTokens: parsedUsage.promptTokens,
|
||||
completionTokens: parsedUsage.completionTokens,
|
||||
totalTokens: parsedUsage.totalTokens,
|
||||
upstreamPath: successfulUpstreamPath,
|
||||
runtimeFailureStatus: 502,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -609,24 +503,21 @@ export async function handleChatSurfaceRequest(
|
||||
|
||||
tokenRouter.recordSuccess(selected.channel.id, latency, estimatedCost, modelName);
|
||||
recordDownstreamCostUsage(request, estimatedCost);
|
||||
logProxy(
|
||||
await failureToolkit.log({
|
||||
selected,
|
||||
requestedModel,
|
||||
'success',
|
||||
200,
|
||||
latency,
|
||||
null,
|
||||
modelRequested: requestedModel,
|
||||
status: 'success',
|
||||
httpStatus: 200,
|
||||
latencyMs: latency,
|
||||
errorMessage: null,
|
||||
retryCount,
|
||||
downstreamPath,
|
||||
resolvedUsage.promptTokens,
|
||||
resolvedUsage.completionTokens,
|
||||
resolvedUsage.totalTokens,
|
||||
promptTokens: resolvedUsage.promptTokens,
|
||||
completionTokens: resolvedUsage.completionTokens,
|
||||
totalTokens: resolvedUsage.totalTokens,
|
||||
estimatedCost,
|
||||
billingDetails,
|
||||
successfulUpstreamPath,
|
||||
clientContext,
|
||||
downstreamApiKeyId,
|
||||
);
|
||||
upstreamPath: successfulUpstreamPath,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -658,43 +549,23 @@ export async function handleChatSurfaceRequest(
|
||||
const parsedUsage = parseProxyUsage(upstreamData);
|
||||
const failure = detectProxyFailure({ rawText, usage: parsedUsage });
|
||||
if (failure) {
|
||||
tokenRouter.recordFailure(selected.channel.id, {
|
||||
status: failure.status,
|
||||
errorText: failure.reason,
|
||||
modelName,
|
||||
});
|
||||
logProxy(
|
||||
const failureOutcome = await failureToolkit.handleDetectedFailure({
|
||||
selected,
|
||||
requestedModel,
|
||||
'failed',
|
||||
failure.status,
|
||||
latency,
|
||||
failure.reason,
|
||||
modelName,
|
||||
failure,
|
||||
latencyMs: latency,
|
||||
retryCount,
|
||||
downstreamPath,
|
||||
parsedUsage.promptTokens,
|
||||
parsedUsage.completionTokens,
|
||||
parsedUsage.totalTokens,
|
||||
0,
|
||||
null,
|
||||
successfulUpstreamPath,
|
||||
clientContext,
|
||||
downstreamApiKeyId,
|
||||
);
|
||||
|
||||
if (shouldRetryProxyRequest(failure.status, failure.reason) && retryCount < MAX_RETRIES) {
|
||||
promptTokens: parsedUsage.promptTokens,
|
||||
completionTokens: parsedUsage.completionTokens,
|
||||
totalTokens: parsedUsage.totalTokens,
|
||||
upstreamPath: successfulUpstreamPath,
|
||||
});
|
||||
if (failureOutcome.action === 'retry') {
|
||||
retryCount += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
await reportProxyAllFailed({
|
||||
model: requestedModel,
|
||||
reason: failure.reason,
|
||||
});
|
||||
|
||||
return reply.code(failure.status).send({
|
||||
error: { message: failure.reason, type: 'upstream_error' },
|
||||
});
|
||||
return reply.code(failureOutcome.status).send(failureOutcome.payload);
|
||||
}
|
||||
const normalizedFinal = downstreamTransformer.transformFinalResponse(upstreamData, modelName, rawText);
|
||||
const downstreamResponse = downstreamTransformer.serializeFinalResponse(normalizedFinal, parsedUsage);
|
||||
@@ -725,66 +596,37 @@ export async function handleChatSurfaceRequest(
|
||||
|
||||
tokenRouter.recordSuccess(selected.channel.id, latency, estimatedCost, modelName);
|
||||
recordDownstreamCostUsage(request, estimatedCost);
|
||||
logProxy(
|
||||
await failureToolkit.log({
|
||||
selected,
|
||||
requestedModel,
|
||||
'success',
|
||||
200,
|
||||
latency,
|
||||
null,
|
||||
modelRequested: requestedModel,
|
||||
status: 'success',
|
||||
httpStatus: 200,
|
||||
latencyMs: latency,
|
||||
errorMessage: null,
|
||||
retryCount,
|
||||
downstreamPath,
|
||||
resolvedUsage.promptTokens,
|
||||
resolvedUsage.completionTokens,
|
||||
resolvedUsage.totalTokens,
|
||||
promptTokens: resolvedUsage.promptTokens,
|
||||
completionTokens: resolvedUsage.completionTokens,
|
||||
totalTokens: resolvedUsage.totalTokens,
|
||||
estimatedCost,
|
||||
billingDetails,
|
||||
successfulUpstreamPath,
|
||||
clientContext,
|
||||
downstreamApiKeyId,
|
||||
);
|
||||
upstreamPath: successfulUpstreamPath,
|
||||
});
|
||||
|
||||
return reply.send(downstreamResponse);
|
||||
} catch (err: any) {
|
||||
tokenRouter.recordFailure(selected.channel.id, {
|
||||
errorText: err?.message,
|
||||
modelName,
|
||||
});
|
||||
logProxy(
|
||||
const failureOutcome = await failureToolkit.handleExecutionError({
|
||||
selected,
|
||||
requestedModel,
|
||||
'failed',
|
||||
0,
|
||||
Date.now() - startTime,
|
||||
err?.message || 'network error',
|
||||
modelName,
|
||||
errorMessage: err?.message || 'network failure',
|
||||
latencyMs: Date.now() - startTime,
|
||||
retryCount,
|
||||
downstreamPath,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
null,
|
||||
null,
|
||||
clientContext,
|
||||
downstreamApiKeyId,
|
||||
);
|
||||
|
||||
if (retryCount < MAX_RETRIES) {
|
||||
});
|
||||
if (failureOutcome.action === 'retry') {
|
||||
retryCount += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
await reportProxyAllFailed({
|
||||
model: requestedModel,
|
||||
reason: err?.message || 'network failure',
|
||||
});
|
||||
|
||||
return reply.code(502).send({
|
||||
error: {
|
||||
message: `Upstream error: ${err?.message || 'network failure'}`,
|
||||
type: 'upstream_error',
|
||||
},
|
||||
});
|
||||
return reply.code(failureOutcome.status).send(failureOutcome.payload);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -845,18 +687,23 @@ export async function handleClaudeCountTokensSurfaceRequest(
|
||||
});
|
||||
const downstreamPolicy = getDownstreamRoutingPolicy(request);
|
||||
const downstreamApiKeyId = getProxyAuthContext(request)?.keyId ?? null;
|
||||
const failureToolkit = createSurfaceFailureToolkit({
|
||||
warningScope: 'chat',
|
||||
downstreamPath,
|
||||
maxRetries: MAX_RETRIES,
|
||||
clientContext,
|
||||
downstreamApiKeyId,
|
||||
});
|
||||
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);
|
||||
}
|
||||
const selected = await selectSurfaceChannelForAttempt({
|
||||
requestedModel,
|
||||
downstreamPolicy,
|
||||
excludeChannelIds,
|
||||
retryCount,
|
||||
});
|
||||
|
||||
if (!selected) {
|
||||
await reportProxyAllFailed({
|
||||
@@ -891,7 +738,7 @@ export async function handleClaudeCountTokensSurfaceRequest(
|
||||
},
|
||||
});
|
||||
}
|
||||
const oauth = getOauthInfoFromExtraConfig(selected.account.extraConfig);
|
||||
const oauth = getOauthInfoFromAccount(selected.account);
|
||||
const startTime = Date.now();
|
||||
|
||||
const buildRequest = () => {
|
||||
@@ -914,15 +761,11 @@ export async function handleClaudeCountTokensSurfaceRequest(
|
||||
|
||||
try {
|
||||
let upstreamRequest = buildRequest();
|
||||
let upstream = await dispatchRuntimeRequest({
|
||||
siteUrl: selected.site.url,
|
||||
request: upstreamRequest,
|
||||
buildInit: (_requestUrl, requestForFetch) => withSiteRecordProxyRequestInit(selected.site, {
|
||||
method: 'POST',
|
||||
headers: requestForFetch.headers,
|
||||
body: JSON.stringify(requestForFetch.body),
|
||||
}, resolveChannelProxyUrl(selected.site, selected.account.extraConfig)),
|
||||
const dispatchRequest = createSurfaceDispatchRequest({
|
||||
site: selected.site,
|
||||
accountExtraConfig: selected.account.extraConfig,
|
||||
});
|
||||
let upstream = await dispatchRequest(upstreamRequest);
|
||||
|
||||
if ((upstream.status === 401 || upstream.status === 403) && oauth) {
|
||||
try {
|
||||
@@ -934,15 +777,7 @@ export async function handleClaudeCountTokensSurfaceRequest(
|
||||
extraConfig: refreshed.extraConfig ?? selected.account.extraConfig,
|
||||
};
|
||||
upstreamRequest = buildRequest();
|
||||
upstream = await dispatchRuntimeRequest({
|
||||
siteUrl: selected.site.url,
|
||||
request: upstreamRequest,
|
||||
buildInit: (_requestUrl, requestForFetch) => withSiteRecordProxyRequestInit(selected.site, {
|
||||
method: 'POST',
|
||||
headers: requestForFetch.headers,
|
||||
body: JSON.stringify(requestForFetch.body),
|
||||
}, resolveChannelProxyUrl(selected.site, selected.account.extraConfig)),
|
||||
});
|
||||
upstream = await dispatchRequest(upstreamRequest);
|
||||
} catch {
|
||||
// Fall through to the regular upstream error handling below.
|
||||
}
|
||||
@@ -959,148 +794,50 @@ export async function handleClaudeCountTokensSurfaceRequest(
|
||||
}
|
||||
|
||||
if (!upstream.ok) {
|
||||
tokenRouter.recordFailure(selected.channel.id, {
|
||||
status: upstream.status,
|
||||
errorText: typeof payload === 'string' ? payload : text,
|
||||
modelName,
|
||||
});
|
||||
logProxy(
|
||||
const failureOutcome = await failureToolkit.handleUpstreamFailure({
|
||||
selected,
|
||||
requestedModel,
|
||||
'failed',
|
||||
upstream.status,
|
||||
latency,
|
||||
typeof payload === 'string' ? payload : JSON.stringify(payload),
|
||||
modelName,
|
||||
status: upstream.status,
|
||||
errText: typeof payload === 'string' ? payload : JSON.stringify(payload),
|
||||
rawErrText: typeof payload === 'string' ? payload : text,
|
||||
latencyMs: latency,
|
||||
retryCount,
|
||||
downstreamPath,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
null,
|
||||
upstreamRequest.path,
|
||||
clientContext,
|
||||
downstreamApiKeyId,
|
||||
);
|
||||
if (shouldRetryProxyRequest(upstream.status, typeof payload === 'string' ? payload : text) && retryCount < MAX_RETRIES) {
|
||||
});
|
||||
if (failureOutcome.action === 'retry') {
|
||||
retryCount += 1;
|
||||
continue;
|
||||
}
|
||||
return reply.code(upstream.status).type(contentType).send(payload);
|
||||
return reply.code(failureOutcome.status).type(contentType).send(payload);
|
||||
}
|
||||
|
||||
tokenRouter.recordSuccess(selected.channel.id, latency, 0, modelName);
|
||||
recordDownstreamCostUsage(request, 0);
|
||||
logProxy(
|
||||
await failureToolkit.log({
|
||||
selected,
|
||||
requestedModel,
|
||||
'success',
|
||||
upstream.status,
|
||||
latency,
|
||||
null,
|
||||
modelRequested: requestedModel,
|
||||
status: 'success',
|
||||
httpStatus: upstream.status,
|
||||
latencyMs: latency,
|
||||
errorMessage: null,
|
||||
retryCount,
|
||||
downstreamPath,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
null,
|
||||
upstreamRequest.path,
|
||||
clientContext,
|
||||
downstreamApiKeyId,
|
||||
);
|
||||
upstreamPath: upstreamRequest.path,
|
||||
});
|
||||
return reply.code(upstream.status).type(contentType).send(payload);
|
||||
} catch (error: any) {
|
||||
tokenRouter.recordFailure(selected.channel.id, {
|
||||
errorText: error?.message,
|
||||
modelName,
|
||||
});
|
||||
logProxy(
|
||||
const failureOutcome = await failureToolkit.handleExecutionError({
|
||||
selected,
|
||||
requestedModel,
|
||||
'failed',
|
||||
0,
|
||||
Date.now() - startTime,
|
||||
error?.message || 'network error',
|
||||
modelName,
|
||||
errorMessage: error?.message || 'network failure',
|
||||
latencyMs: Date.now() - startTime,
|
||||
retryCount,
|
||||
downstreamPath,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
null,
|
||||
null,
|
||||
clientContext,
|
||||
downstreamApiKeyId,
|
||||
);
|
||||
if (retryCount < MAX_RETRIES) {
|
||||
});
|
||||
if (failureOutcome.action === 'retry') {
|
||||
retryCount += 1;
|
||||
continue;
|
||||
}
|
||||
return reply.code(502).send({
|
||||
error: {
|
||||
message: `Upstream error: ${error?.message || 'network failure'}`,
|
||||
type: 'upstream_error',
|
||||
},
|
||||
});
|
||||
return reply.code(failureOutcome.status).send(failureOutcome.payload);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function logProxy(
|
||||
selected: any,
|
||||
modelRequested: string,
|
||||
status: string,
|
||||
httpStatus: number,
|
||||
latencyMs: number,
|
||||
errorMessage: string | null,
|
||||
retryCount: number,
|
||||
downstreamPath: string,
|
||||
promptTokens = 0,
|
||||
completionTokens = 0,
|
||||
totalTokens = 0,
|
||||
estimatedCost = 0,
|
||||
billingDetails: unknown = null,
|
||||
upstreamPath: string | null = null,
|
||||
clientContext: DownstreamClientContext | null = null,
|
||||
downstreamApiKeyId: number | null = null,
|
||||
) {
|
||||
try {
|
||||
const createdAt = formatUtcSqlDateTime(new Date());
|
||||
const normalizedErrorMessage = composeProxyLogMessage({
|
||||
clientKind: clientContext?.clientKind && clientContext.clientKind !== 'generic'
|
||||
? clientContext.clientKind
|
||||
: null,
|
||||
sessionId: clientContext?.sessionId || null,
|
||||
traceHint: clientContext?.traceHint || null,
|
||||
downstreamPath,
|
||||
upstreamPath,
|
||||
errorMessage,
|
||||
});
|
||||
await insertProxyLog({
|
||||
routeId: selected.channel.routeId,
|
||||
channelId: selected.channel.id,
|
||||
accountId: selected.account.id,
|
||||
downstreamApiKeyId,
|
||||
modelRequested,
|
||||
modelActual: selected.actualModel,
|
||||
status,
|
||||
httpStatus,
|
||||
latencyMs,
|
||||
promptTokens,
|
||||
completionTokens,
|
||||
totalTokens,
|
||||
estimatedCost,
|
||||
billingDetails,
|
||||
clientFamily: clientContext?.clientKind || null,
|
||||
clientAppId: clientContext?.clientAppId || null,
|
||||
clientAppName: clientContext?.clientAppName || null,
|
||||
clientConfidence: clientContext?.clientConfidence || null,
|
||||
errorMessage: normalizedErrorMessage,
|
||||
retryCount,
|
||||
createdAt,
|
||||
});
|
||||
} catch (error) {
|
||||
console.warn('[proxy/chat] failed to write proxy log', error);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ import { parseProxyUsage } from '../../services/proxyUsageParser.js';
|
||||
import { isModelAllowedByPolicyOrAllowedRoutes } from '../../services/downstreamApiKeyService.js';
|
||||
import { tokenRouter } from '../../services/tokenRouter.js';
|
||||
import { buildOauthProviderHeaders } from '../../services/oauth/service.js';
|
||||
import { getOauthInfoFromExtraConfig } from '../../services/oauth/oauthAccount.js';
|
||||
import { getOauthInfoFromAccount } from '../../services/oauth/oauthAccount.js';
|
||||
import { refreshOauthAccessTokenSingleflight } from '../../services/oauth/refreshSingleflight.js';
|
||||
import { resolveChannelProxyUrl, withSiteRecordProxyRequestInit } from '../../services/siteProxy.js';
|
||||
import { refreshModelsAndRebuildRoutes } from '../../services/modelService.js';
|
||||
@@ -407,7 +407,7 @@ export async function geminiProxyRoute(app: FastifyInstance) {
|
||||
isGeminiCliDownstream ? omitGeminiCliModelField(request.body) : (request.body || {}),
|
||||
actualModel,
|
||||
);
|
||||
let oauth = getOauthInfoFromExtraConfig(selected.account.extraConfig);
|
||||
let oauth = getOauthInfoFromAccount(selected.account);
|
||||
const isGeminiCli = isGeminiCliPlatform(selected.site.platform);
|
||||
const isInternalGemini = isInternalGeminiPlatform(selected.site.platform);
|
||||
const isDirectGeminiFamily = isDirectGeminiFamilyPlatform(selected.site.platform);
|
||||
@@ -463,7 +463,7 @@ export async function geminiProxyRoute(app: FastifyInstance) {
|
||||
...(isStreamAction ? { Accept: 'text/event-stream' } : {}),
|
||||
Authorization: `Bearer ${selected.tokenValue}`,
|
||||
...buildOauthProviderHeaders({
|
||||
extraConfig: typeof selected.account.extraConfig === 'string' ? selected.account.extraConfig : null,
|
||||
account: selected.account,
|
||||
downstreamHeaders: request.headers as Record<string, unknown>,
|
||||
}),
|
||||
}
|
||||
@@ -523,7 +523,7 @@ export async function geminiProxyRoute(app: FastifyInstance) {
|
||||
accessToken: refreshed.accessToken,
|
||||
extraConfig: refreshed.extraConfig ?? selected.account.extraConfig,
|
||||
};
|
||||
oauth = getOauthInfoFromExtraConfig(selected.account.extraConfig);
|
||||
oauth = getOauthInfoFromAccount(selected.account);
|
||||
upstream = await dispatchSelectedRequest();
|
||||
contentType = upstream.headers.get('content-type') || 'application/json';
|
||||
} catch {
|
||||
@@ -758,7 +758,7 @@ export async function geminiProxyRoute(app: FastifyInstance) {
|
||||
forceNormalizeClaudeBody: requestOptions.forceNormalizeClaudeBody,
|
||||
downstreamHeaders: request.headers as Record<string, unknown>,
|
||||
providerHeaders: buildOauthProviderHeaders({
|
||||
extraConfig: typeof selected.account.extraConfig === 'string' ? selected.account.extraConfig : null,
|
||||
account: selected.account,
|
||||
downstreamHeaders: request.headers as Record<string, unknown>,
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -1,114 +1,4 @@
|
||||
import type { ProxyResourceOwner } from '../../middleware/auth.js';
|
||||
import { getProxyFileByPublicIdForOwner } from '../../services/proxyFileStore.js';
|
||||
import {
|
||||
inferInputFileMimeType,
|
||||
normalizeInputFileBlock,
|
||||
toOpenAiChatFileBlock,
|
||||
toResponsesInputFileBlock,
|
||||
type NormalizedInputFile,
|
||||
} from '../../transformers/shared/inputFile.js';
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return !!value && typeof value === 'object';
|
||||
}
|
||||
|
||||
function isSupportedFileMimeType(mimeType: string): boolean {
|
||||
return mimeType === 'application/pdf'
|
||||
|| mimeType === 'text/plain'
|
||||
|| mimeType === 'text/markdown'
|
||||
|| mimeType === 'application/json'
|
||||
|| mimeType.startsWith('image/')
|
||||
|| mimeType.startsWith('audio/');
|
||||
}
|
||||
|
||||
export class ProxyInputFileResolutionError extends Error {
|
||||
statusCode: number;
|
||||
payload: unknown;
|
||||
|
||||
constructor(statusCode: number, payload: unknown) {
|
||||
super(typeof payload === 'object' && payload && 'error' in (payload as Record<string, unknown>)
|
||||
? String(((payload as { error?: { message?: unknown } }).error?.message) || 'input file resolution failed')
|
||||
: 'input file resolution failed');
|
||||
this.name = 'ProxyInputFileResolutionError';
|
||||
this.statusCode = statusCode;
|
||||
this.payload = payload;
|
||||
}
|
||||
}
|
||||
|
||||
function buildInvalidRequest(message: string): ProxyInputFileResolutionError {
|
||||
return new ProxyInputFileResolutionError(400, {
|
||||
error: {
|
||||
message,
|
||||
type: 'invalid_request_error',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function buildNotFound(message: string): ProxyInputFileResolutionError {
|
||||
return new ProxyInputFileResolutionError(404, {
|
||||
error: {
|
||||
message,
|
||||
type: 'not_found_error',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function resolveInputFileBlock(
|
||||
item: Record<string, unknown>,
|
||||
owner: ProxyResourceOwner,
|
||||
): Promise<Record<string, unknown>> {
|
||||
const normalized = normalizeInputFileBlock(item);
|
||||
if (!normalized) return item;
|
||||
|
||||
let resolved: NormalizedInputFile = { ...normalized };
|
||||
|
||||
if (!resolved.fileData && resolved.fileId?.startsWith('file-metapi-')) {
|
||||
const stored = await getProxyFileByPublicIdForOwner(resolved.fileId, owner);
|
||||
if (!stored) {
|
||||
throw buildNotFound(`file not found: ${resolved.fileId}`);
|
||||
}
|
||||
resolved = {
|
||||
filename: stored.filename,
|
||||
mimeType: stored.mimeType,
|
||||
fileData: stored.contentBase64,
|
||||
};
|
||||
}
|
||||
|
||||
const inferredMimeType = inferInputFileMimeType(resolved);
|
||||
if (resolved.fileData && (!inferredMimeType || !isSupportedFileMimeType(inferredMimeType))) {
|
||||
throw buildInvalidRequest(`unsupported file mime type: ${inferredMimeType || 'application/octet-stream'}`);
|
||||
}
|
||||
if (inferredMimeType) {
|
||||
resolved.mimeType = inferredMimeType;
|
||||
}
|
||||
|
||||
const type = typeof item.type === 'string' ? item.type : '';
|
||||
if (type === 'input_file') {
|
||||
return toResponsesInputFileBlock(resolved);
|
||||
}
|
||||
if (type === 'file') {
|
||||
return toOpenAiChatFileBlock(resolved);
|
||||
}
|
||||
return item;
|
||||
}
|
||||
|
||||
export async function inlineLocalInputFileReferences(
|
||||
value: unknown,
|
||||
owner: ProxyResourceOwner,
|
||||
): Promise<unknown> {
|
||||
if (Array.isArray(value)) {
|
||||
return Promise.all(value.map((item) => inlineLocalInputFileReferences(item, owner)));
|
||||
}
|
||||
|
||||
if (!isRecord(value)) return value;
|
||||
|
||||
const type = typeof value.type === 'string' ? value.type : '';
|
||||
if (type === 'input_file' || type === 'file') {
|
||||
return resolveInputFileBlock(value, owner);
|
||||
}
|
||||
|
||||
const entries = await Promise.all(
|
||||
Object.entries(value).map(async ([key, entryValue]) => [key, await inlineLocalInputFileReferences(entryValue, owner)] as const),
|
||||
);
|
||||
return Object.fromEntries(entries);
|
||||
}
|
||||
export {
|
||||
inlineLocalInputFileReferences,
|
||||
ProxyInputFileResolutionError,
|
||||
} from '../../services/proxyInputFileResolver.js';
|
||||
|
||||
@@ -1,13 +1,9 @@
|
||||
import { TextDecoder } from 'node:util';
|
||||
import type { FastifyReply, FastifyRequest } from 'fastify';
|
||||
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 { reportProxyAllFailed } from '../../services/alertService.js';
|
||||
import { resolveProxyUsageWithSelfLogFallback } from '../../services/proxyUsageFallbackService.js';
|
||||
import { mergeProxyUsage, parseProxyUsage } from '../../services/proxyUsageParser.js';
|
||||
import { resolveChannelProxyUrl, withSiteRecordProxyRequestInit } from '../../services/siteProxy.js';
|
||||
import { openAiResponsesTransformer } from '../../transformers/openai/responses/index.js';
|
||||
import {
|
||||
buildUpstreamEndpointRequest,
|
||||
@@ -16,14 +12,11 @@ import {
|
||||
resolveUpstreamEndpointCandidates,
|
||||
} from '../../routes/proxy/upstreamEndpoint.js';
|
||||
import { ensureModelAllowedForDownstreamKey, getDownstreamRoutingPolicy, recordDownstreamCostUsage } from '../../routes/proxy/downstreamPolicy.js';
|
||||
import { composeProxyLogMessage } from '../../routes/proxy/logPathMeta.js';
|
||||
import { executeEndpointFlow, type BuiltEndpointRequest } from '../../routes/proxy/endpointFlow.js';
|
||||
import { detectProxyFailure } from '../../routes/proxy/proxyFailureJudge.js';
|
||||
import { buildUpstreamUrl } from '../../routes/proxy/upstreamUrl.js';
|
||||
import { formatUtcSqlDateTime } from '../../services/localTimeService.js';
|
||||
import { resolveProxyLogBilling } from '../../routes/proxy/proxyBilling.js';
|
||||
import { getProxyAuthContext, getProxyResourceOwner } from '../../middleware/auth.js';
|
||||
import { dispatchRuntimeRequest } from '../../routes/proxy/runtimeExecutor.js';
|
||||
import { normalizeInputFileBlock } from '../../transformers/shared/inputFile.js';
|
||||
import {
|
||||
ProxyInputFileResolutionError,
|
||||
@@ -32,8 +25,7 @@ import {
|
||||
import {
|
||||
buildOauthProviderHeaders,
|
||||
} from '../../services/oauth/service.js';
|
||||
import { getOauthInfoFromExtraConfig } from '../../services/oauth/oauthAccount.js';
|
||||
import { recordOauthQuotaResetHint } from '../../services/oauth/quota.js';
|
||||
import { getOauthInfoFromAccount } from '../../services/oauth/oauthAccount.js';
|
||||
import { refreshOauthAccessTokenSingleflight } from '../../services/oauth/refreshSingleflight.js';
|
||||
import {
|
||||
collectResponsesFinalPayloadFromSse,
|
||||
@@ -50,8 +42,12 @@ import {
|
||||
summarizeConversationFileInputsInOpenAiBody,
|
||||
summarizeConversationFileInputsInResponsesBody,
|
||||
} from '../capabilities/conversationFileCapabilities.js';
|
||||
import { detectDownstreamClientContext, type DownstreamClientContext } from '../../routes/proxy/downstreamClientContext.js';
|
||||
import { insertProxyLog } from '../../services/proxyLogStore.js';
|
||||
import { detectDownstreamClientContext } from '../../routes/proxy/downstreamClientContext.js';
|
||||
import {
|
||||
createSurfaceFailureToolkit,
|
||||
createSurfaceDispatchRequest,
|
||||
selectSurfaceChannelForAttempt,
|
||||
} from './sharedSurface.js';
|
||||
|
||||
const MAX_RETRIES = 2;
|
||||
|
||||
@@ -185,18 +181,23 @@ export async function handleOpenAiResponsesSurfaceRequest(
|
||||
if (!await ensureModelAllowedForDownstreamKey(request, reply, requestedModel)) return;
|
||||
const downstreamPolicy = getDownstreamRoutingPolicy(request);
|
||||
const downstreamApiKeyId = getProxyAuthContext(request)?.keyId ?? null;
|
||||
const failureToolkit = createSurfaceFailureToolkit({
|
||||
warningScope: 'responses',
|
||||
downstreamPath,
|
||||
maxRetries: MAX_RETRIES,
|
||||
clientContext,
|
||||
downstreamApiKeyId,
|
||||
});
|
||||
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);
|
||||
}
|
||||
const selected = await selectSurfaceChannelForAttempt({
|
||||
requestedModel,
|
||||
downstreamPolicy,
|
||||
excludeChannelIds,
|
||||
retryCount,
|
||||
});
|
||||
|
||||
if (!selected) {
|
||||
await reportProxyAllFailed({
|
||||
@@ -211,7 +212,7 @@ export async function handleOpenAiResponsesSurfaceRequest(
|
||||
excludeChannelIds.push(selected.channel.id);
|
||||
|
||||
const modelName = selected.actualModel || requestedModel;
|
||||
const oauth = getOauthInfoFromExtraConfig(selected.account.extraConfig);
|
||||
const oauth = getOauthInfoFromAccount(selected.account);
|
||||
const isCodexSite = String(selected.site.platform || '').trim().toLowerCase() === 'codex';
|
||||
const owner = getProxyResourceOwner(request);
|
||||
let normalizedResponsesBody: Record<string, unknown> = {
|
||||
@@ -274,7 +275,7 @@ export async function handleOpenAiResponsesSurfaceRequest(
|
||||
};
|
||||
const buildProviderHeaders = () => (
|
||||
buildOauthProviderHeaders({
|
||||
extraConfig: typeof selected.account.extraConfig === 'string' ? selected.account.extraConfig : null,
|
||||
account: selected.account,
|
||||
downstreamHeaders: request.headers as Record<string, unknown>,
|
||||
})
|
||||
);
|
||||
@@ -308,19 +309,10 @@ 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,
|
||||
targetUrl,
|
||||
request: compatibilityRequest,
|
||||
buildInit: (_requestUrl, requestForFetch) => withSiteRecordProxyRequestInit(selected.site, {
|
||||
method: 'POST',
|
||||
headers: requestForFetch.headers,
|
||||
body: JSON.stringify(requestForFetch.body),
|
||||
}, channelProxyUrl),
|
||||
})
|
||||
);
|
||||
const dispatchRequest = createSurfaceDispatchRequest({
|
||||
site: selected.site,
|
||||
accountExtraConfig: selected.account.extraConfig,
|
||||
});
|
||||
const endpointStrategy = openAiResponsesTransformer.compatibility.createEndpointStrategy({
|
||||
isStream: isStream || isCodexSite,
|
||||
requiresNativeResponsesFileUrl,
|
||||
@@ -385,79 +377,34 @@ export async function handleOpenAiResponsesSurfaceRequest(
|
||||
},
|
||||
shouldDowngrade: endpointStrategy.shouldDowngrade,
|
||||
onDowngrade: (ctx) => {
|
||||
logProxy(
|
||||
return failureToolkit.log({
|
||||
selected,
|
||||
requestedModel,
|
||||
'failed',
|
||||
ctx.response.status,
|
||||
Date.now() - startTime,
|
||||
ctx.errText,
|
||||
modelRequested: requestedModel,
|
||||
status: 'failed',
|
||||
httpStatus: ctx.response.status,
|
||||
latencyMs: Date.now() - startTime,
|
||||
errorMessage: ctx.errText,
|
||||
retryCount,
|
||||
downstreamPath,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
null,
|
||||
null,
|
||||
clientContext,
|
||||
downstreamApiKeyId,
|
||||
);
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
if (!endpointResult.ok) {
|
||||
const status = endpointResult.status || 502;
|
||||
const errText = endpointResult.errText || 'unknown error';
|
||||
const rawErrText = endpointResult.rawErrText || errText;
|
||||
tokenRouter.recordFailure(selected.channel.id, {
|
||||
status,
|
||||
errorText: rawErrText,
|
||||
modelName,
|
||||
});
|
||||
logProxy(
|
||||
const failureOutcome = await failureToolkit.handleUpstreamFailure({
|
||||
selected,
|
||||
requestedModel,
|
||||
'failed',
|
||||
status,
|
||||
Date.now() - startTime,
|
||||
errText,
|
||||
modelName,
|
||||
status: endpointResult.status || 502,
|
||||
errText: endpointResult.errText || 'unknown error',
|
||||
rawErrText: endpointResult.rawErrText,
|
||||
latencyMs: Date.now() - startTime,
|
||||
retryCount,
|
||||
downstreamPath,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
null,
|
||||
null,
|
||||
clientContext,
|
||||
downstreamApiKeyId,
|
||||
);
|
||||
await recordOauthQuotaResetHint({
|
||||
accountId: selected.account.id,
|
||||
statusCode: status,
|
||||
errorText: rawErrText,
|
||||
});
|
||||
|
||||
if (isTokenExpiredError({ status, message: errText })) {
|
||||
await reportTokenExpired({
|
||||
accountId: selected.account.id,
|
||||
username: selected.account.username,
|
||||
siteName: selected.site.name,
|
||||
detail: `HTTP ${status}`,
|
||||
});
|
||||
}
|
||||
|
||||
if (shouldRetryProxyRequest(status, errText) && retryCount < MAX_RETRIES) {
|
||||
if (failureOutcome.action === 'retry') {
|
||||
retryCount += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
await reportProxyAllFailed({
|
||||
model: requestedModel,
|
||||
reason: `upstream returned HTTP ${status}`,
|
||||
});
|
||||
return reply.code(status).send({ error: { message: errText, type: 'upstream_error' } });
|
||||
return reply.code(failureOutcome.status).send(failureOutcome.payload);
|
||||
}
|
||||
|
||||
const upstream = endpointResult.upstream;
|
||||
@@ -508,13 +455,21 @@ export async function handleOpenAiResponsesSurfaceRequest(
|
||||
try {
|
||||
tokenRouter.recordSuccess(selected.channel.id, latency, estimatedCost, modelName);
|
||||
recordDownstreamCostUsage(request, estimatedCost);
|
||||
logProxy(
|
||||
selected, requestedModel, 'success', 200, latency, null, retryCount, downstreamPath,
|
||||
usageForLog.promptTokens, usageForLog.completionTokens, usageForLog.totalTokens, estimatedCost, billingDetails,
|
||||
successfulUpstreamPath,
|
||||
clientContext,
|
||||
downstreamApiKeyId,
|
||||
);
|
||||
await failureToolkit.log({
|
||||
selected,
|
||||
modelRequested: requestedModel,
|
||||
status: 'success',
|
||||
httpStatus: 200,
|
||||
latencyMs: latency,
|
||||
errorMessage: null,
|
||||
retryCount,
|
||||
promptTokens: usageForLog.promptTokens,
|
||||
completionTokens: usageForLog.completionTokens,
|
||||
totalTokens: usageForLog.totalTokens,
|
||||
estimatedCost,
|
||||
billingDetails,
|
||||
upstreamPath: successfulUpstreamPath,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('[responses] post-stream success logging failed:', error);
|
||||
}
|
||||
@@ -569,25 +524,18 @@ export async function handleOpenAiResponsesSurfaceRequest(
|
||||
);
|
||||
const latency = Date.now() - startTime;
|
||||
if (streamResult.status === 'failed') {
|
||||
tokenRouter.recordFailure(selected.channel.id, modelName);
|
||||
logProxy(
|
||||
await failureToolkit.recordStreamFailure({
|
||||
selected,
|
||||
requestedModel,
|
||||
'failed',
|
||||
200,
|
||||
latency,
|
||||
streamResult.errorMessage,
|
||||
modelName,
|
||||
errorMessage: streamResult.errorMessage,
|
||||
latencyMs: latency,
|
||||
retryCount,
|
||||
downstreamPath,
|
||||
parsedUsage.promptTokens,
|
||||
parsedUsage.completionTokens,
|
||||
parsedUsage.totalTokens,
|
||||
0,
|
||||
null,
|
||||
successfulUpstreamPath,
|
||||
clientContext,
|
||||
downstreamApiKeyId,
|
||||
);
|
||||
promptTokens: parsedUsage.promptTokens,
|
||||
completionTokens: parsedUsage.completionTokens,
|
||||
totalTokens: parsedUsage.totalTokens,
|
||||
upstreamPath: successfulUpstreamPath,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -608,68 +556,41 @@ export async function handleOpenAiResponsesSurfaceRequest(
|
||||
const latency = Date.now() - startTime;
|
||||
const failure = detectProxyFailure({ rawText, usage: parsedUsage });
|
||||
if (failure) {
|
||||
tokenRouter.recordFailure(selected.channel.id, {
|
||||
status: failure.status,
|
||||
errorText: failure.reason,
|
||||
modelName,
|
||||
});
|
||||
logProxy(
|
||||
const failureOutcome = await failureToolkit.handleDetectedFailure({
|
||||
selected,
|
||||
requestedModel,
|
||||
'failed',
|
||||
failure.status,
|
||||
latency,
|
||||
failure.reason,
|
||||
modelName,
|
||||
failure,
|
||||
latencyMs: latency,
|
||||
retryCount,
|
||||
downstreamPath,
|
||||
parsedUsage.promptTokens,
|
||||
parsedUsage.completionTokens,
|
||||
parsedUsage.totalTokens,
|
||||
0,
|
||||
null,
|
||||
successfulUpstreamPath,
|
||||
clientContext,
|
||||
downstreamApiKeyId,
|
||||
);
|
||||
|
||||
if (shouldRetryProxyRequest(failure.status, failure.reason) && retryCount < MAX_RETRIES) {
|
||||
promptTokens: parsedUsage.promptTokens,
|
||||
completionTokens: parsedUsage.completionTokens,
|
||||
totalTokens: parsedUsage.totalTokens,
|
||||
upstreamPath: successfulUpstreamPath,
|
||||
});
|
||||
if (failureOutcome.action === 'retry') {
|
||||
retryCount += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
await reportProxyAllFailed({
|
||||
model: requestedModel,
|
||||
reason: failure.reason,
|
||||
});
|
||||
return reply.code(failure.status).send({ error: { message: failure.reason, type: 'upstream_error' } });
|
||||
return reply.code(failureOutcome.status).send(failureOutcome.payload);
|
||||
}
|
||||
|
||||
startSseResponse();
|
||||
const streamResult = streamSession.consumeUpstreamFinalPayload(upstreamData, rawText, reply.raw);
|
||||
if (streamResult.status === 'failed') {
|
||||
tokenRouter.recordFailure(selected.channel.id, {
|
||||
status: 502,
|
||||
errorText: streamResult.errorMessage,
|
||||
modelName,
|
||||
});
|
||||
logProxy(
|
||||
await failureToolkit.recordStreamFailure({
|
||||
selected,
|
||||
requestedModel,
|
||||
'failed',
|
||||
200,
|
||||
latency,
|
||||
streamResult.errorMessage,
|
||||
modelName,
|
||||
errorMessage: streamResult.errorMessage,
|
||||
latencyMs: latency,
|
||||
retryCount,
|
||||
downstreamPath,
|
||||
parsedUsage.promptTokens,
|
||||
parsedUsage.completionTokens,
|
||||
parsedUsage.totalTokens,
|
||||
0,
|
||||
null,
|
||||
successfulUpstreamPath,
|
||||
clientContext,
|
||||
downstreamApiKeyId,
|
||||
);
|
||||
promptTokens: parsedUsage.promptTokens,
|
||||
completionTokens: parsedUsage.completionTokens,
|
||||
totalTokens: parsedUsage.totalTokens,
|
||||
upstreamPath: successfulUpstreamPath,
|
||||
runtimeFailureStatus: 502,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -707,29 +628,19 @@ export async function handleOpenAiResponsesSurfaceRequest(
|
||||
|
||||
const latency = Date.now() - startTime;
|
||||
if (streamResult.status === 'failed') {
|
||||
tokenRouter.recordFailure(selected.channel.id, {
|
||||
status: 502,
|
||||
errorText: streamResult.errorMessage,
|
||||
modelName,
|
||||
});
|
||||
logProxy(
|
||||
await failureToolkit.recordStreamFailure({
|
||||
selected,
|
||||
requestedModel,
|
||||
'failed',
|
||||
200,
|
||||
latency,
|
||||
streamResult.errorMessage,
|
||||
modelName,
|
||||
errorMessage: streamResult.errorMessage,
|
||||
latencyMs: latency,
|
||||
retryCount,
|
||||
downstreamPath,
|
||||
parsedUsage.promptTokens,
|
||||
parsedUsage.completionTokens,
|
||||
parsedUsage.totalTokens,
|
||||
0,
|
||||
null,
|
||||
successfulUpstreamPath,
|
||||
clientContext,
|
||||
downstreamApiKeyId,
|
||||
);
|
||||
promptTokens: parsedUsage.promptTokens,
|
||||
completionTokens: parsedUsage.completionTokens,
|
||||
totalTokens: parsedUsage.totalTokens,
|
||||
upstreamPath: successfulUpstreamPath,
|
||||
runtimeFailureStatus: 502,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -775,39 +686,23 @@ export async function handleOpenAiResponsesSurfaceRequest(
|
||||
const parsedUsage = parseProxyUsage(upstreamData);
|
||||
const failure = detectProxyFailure({ rawText, usage: parsedUsage });
|
||||
if (failure) {
|
||||
tokenRouter.recordFailure(selected.channel.id, {
|
||||
status: failure.status,
|
||||
errorText: failure.reason,
|
||||
modelName,
|
||||
});
|
||||
logProxy(
|
||||
const failureOutcome = await failureToolkit.handleDetectedFailure({
|
||||
selected,
|
||||
requestedModel,
|
||||
'failed',
|
||||
failure.status,
|
||||
latency,
|
||||
failure.reason,
|
||||
modelName,
|
||||
failure,
|
||||
latencyMs: latency,
|
||||
retryCount,
|
||||
downstreamPath,
|
||||
parsedUsage.promptTokens,
|
||||
parsedUsage.completionTokens,
|
||||
parsedUsage.totalTokens,
|
||||
0,
|
||||
null,
|
||||
successfulUpstreamPath,
|
||||
clientContext,
|
||||
downstreamApiKeyId,
|
||||
);
|
||||
if (shouldRetryProxyRequest(failure.status, failure.reason) && retryCount < MAX_RETRIES) {
|
||||
promptTokens: parsedUsage.promptTokens,
|
||||
completionTokens: parsedUsage.completionTokens,
|
||||
totalTokens: parsedUsage.totalTokens,
|
||||
upstreamPath: successfulUpstreamPath,
|
||||
});
|
||||
if (failureOutcome.action === 'retry') {
|
||||
retryCount += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
await reportProxyAllFailed({
|
||||
model: requestedModel,
|
||||
reason: failure.reason,
|
||||
});
|
||||
return reply.code(failure.status).send({ error: { message: failure.reason, type: 'upstream_error' } });
|
||||
return reply.code(failureOutcome.status).send(failureOutcome.payload);
|
||||
}
|
||||
const normalized = openAiResponsesTransformer.transformFinalResponse(
|
||||
upstreamData,
|
||||
@@ -845,106 +740,36 @@ export async function handleOpenAiResponsesSurfaceRequest(
|
||||
|
||||
tokenRouter.recordSuccess(selected.channel.id, latency, estimatedCost, modelName);
|
||||
recordDownstreamCostUsage(request, estimatedCost);
|
||||
logProxy(
|
||||
selected, requestedModel, 'success', 200, latency, null, retryCount, downstreamPath,
|
||||
resolvedUsage.promptTokens, resolvedUsage.completionTokens, resolvedUsage.totalTokens, estimatedCost, billingDetails,
|
||||
successfulUpstreamPath,
|
||||
clientContext,
|
||||
downstreamApiKeyId,
|
||||
);
|
||||
await failureToolkit.log({
|
||||
selected,
|
||||
modelRequested: requestedModel,
|
||||
status: 'success',
|
||||
httpStatus: 200,
|
||||
latencyMs: latency,
|
||||
errorMessage: null,
|
||||
retryCount,
|
||||
promptTokens: resolvedUsage.promptTokens,
|
||||
completionTokens: resolvedUsage.completionTokens,
|
||||
totalTokens: resolvedUsage.totalTokens,
|
||||
estimatedCost,
|
||||
billingDetails,
|
||||
upstreamPath: successfulUpstreamPath,
|
||||
});
|
||||
return reply.send(downstreamData);
|
||||
} catch (err: any) {
|
||||
tokenRouter.recordFailure(selected.channel.id, {
|
||||
errorText: err?.message,
|
||||
modelName,
|
||||
});
|
||||
logProxy(
|
||||
const failureOutcome = await failureToolkit.handleExecutionError({
|
||||
selected,
|
||||
requestedModel,
|
||||
'failed',
|
||||
0,
|
||||
Date.now() - startTime,
|
||||
err.message,
|
||||
modelName,
|
||||
errorMessage: err?.message || 'network failure',
|
||||
latencyMs: Date.now() - startTime,
|
||||
retryCount,
|
||||
downstreamPath,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
null,
|
||||
null,
|
||||
clientContext,
|
||||
downstreamApiKeyId,
|
||||
);
|
||||
if (retryCount < MAX_RETRIES) {
|
||||
});
|
||||
if (failureOutcome.action === 'retry') {
|
||||
retryCount += 1;
|
||||
continue;
|
||||
}
|
||||
await reportProxyAllFailed({
|
||||
model: requestedModel,
|
||||
reason: err.message || 'network failure',
|
||||
});
|
||||
return reply.code(502).send({
|
||||
error: { message: `Upstream error: ${err.message}`, type: 'upstream_error' },
|
||||
});
|
||||
return reply.code(failureOutcome.status).send(failureOutcome.payload);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function logProxy(
|
||||
selected: any,
|
||||
modelRequested: string,
|
||||
status: string,
|
||||
httpStatus: number,
|
||||
latencyMs: number,
|
||||
errorMessage: string | null,
|
||||
retryCount: number,
|
||||
downstreamPath: string,
|
||||
promptTokens = 0,
|
||||
completionTokens = 0,
|
||||
totalTokens = 0,
|
||||
estimatedCost = 0,
|
||||
billingDetails: unknown = null,
|
||||
upstreamPath: string | null = null,
|
||||
clientContext: DownstreamClientContext | null = null,
|
||||
downstreamApiKeyId: number | null = null,
|
||||
) {
|
||||
try {
|
||||
const createdAt = formatUtcSqlDateTime(new Date());
|
||||
const normalizedErrorMessage = composeProxyLogMessage({
|
||||
clientKind: clientContext?.clientKind && clientContext.clientKind !== 'generic'
|
||||
? clientContext.clientKind
|
||||
: null,
|
||||
sessionId: clientContext?.sessionId || null,
|
||||
traceHint: clientContext?.traceHint || null,
|
||||
downstreamPath,
|
||||
upstreamPath,
|
||||
errorMessage,
|
||||
});
|
||||
await insertProxyLog({
|
||||
routeId: selected.channel.routeId,
|
||||
channelId: selected.channel.id,
|
||||
accountId: selected.account.id,
|
||||
downstreamApiKeyId,
|
||||
modelRequested,
|
||||
modelActual: selected.actualModel,
|
||||
status,
|
||||
httpStatus,
|
||||
latencyMs,
|
||||
promptTokens,
|
||||
completionTokens,
|
||||
totalTokens,
|
||||
estimatedCost,
|
||||
billingDetails,
|
||||
clientFamily: clientContext?.clientKind || null,
|
||||
clientAppId: clientContext?.clientAppId || null,
|
||||
clientAppName: clientContext?.clientAppName || null,
|
||||
clientConfidence: clientContext?.clientConfidence || null,
|
||||
errorMessage: normalizedErrorMessage,
|
||||
retryCount,
|
||||
createdAt,
|
||||
});
|
||||
} catch (error) {
|
||||
console.warn('[proxy/responses] failed to write proxy log', error);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,471 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { EMPTY_DOWNSTREAM_ROUTING_POLICY } from '../../services/downstreamPolicyTypes.js';
|
||||
|
||||
const selectChannelMock = vi.fn();
|
||||
const selectNextChannelMock = vi.fn();
|
||||
const recordFailureMock = vi.fn();
|
||||
const refreshModelsAndRebuildRoutesMock = vi.fn();
|
||||
const composeProxyLogMessageMock = vi.fn();
|
||||
const formatUtcSqlDateTimeMock = vi.fn();
|
||||
const insertProxyLogMock = vi.fn();
|
||||
const resolveChannelProxyUrlMock = vi.fn();
|
||||
const withSiteRecordProxyRequestInitMock = vi.fn();
|
||||
const dispatchRuntimeRequestMock = vi.fn();
|
||||
const reportProxyAllFailedMock = vi.fn();
|
||||
const reportTokenExpiredMock = vi.fn();
|
||||
const isTokenExpiredErrorMock = vi.fn();
|
||||
const shouldRetryProxyRequestMock = vi.fn();
|
||||
const recordOauthQuotaResetHintMock = vi.fn();
|
||||
|
||||
vi.mock('../../services/tokenRouter.js', () => ({
|
||||
tokenRouter: {
|
||||
selectChannel: (...args: unknown[]) => selectChannelMock(...args),
|
||||
selectNextChannel: (...args: unknown[]) => selectNextChannelMock(...args),
|
||||
recordFailure: (...args: unknown[]) => recordFailureMock(...args),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../../services/modelService.js', () => ({
|
||||
refreshModelsAndRebuildRoutes: (...args: unknown[]) => refreshModelsAndRebuildRoutesMock(...args),
|
||||
}));
|
||||
|
||||
vi.mock('../../routes/proxy/logPathMeta.js', () => ({
|
||||
composeProxyLogMessage: (...args: unknown[]) => composeProxyLogMessageMock(...args),
|
||||
}));
|
||||
|
||||
vi.mock('../../services/localTimeService.js', () => ({
|
||||
formatUtcSqlDateTime: (...args: unknown[]) => formatUtcSqlDateTimeMock(...args),
|
||||
}));
|
||||
|
||||
vi.mock('../../services/proxyLogStore.js', () => ({
|
||||
insertProxyLog: (...args: unknown[]) => insertProxyLogMock(...args),
|
||||
}));
|
||||
|
||||
vi.mock('../../services/siteProxy.js', () => ({
|
||||
resolveChannelProxyUrl: (...args: unknown[]) => resolveChannelProxyUrlMock(...args),
|
||||
withSiteRecordProxyRequestInit: (...args: unknown[]) => withSiteRecordProxyRequestInitMock(...args),
|
||||
}));
|
||||
|
||||
vi.mock('../../routes/proxy/runtimeExecutor.js', () => ({
|
||||
dispatchRuntimeRequest: (...args: unknown[]) => dispatchRuntimeRequestMock(...args),
|
||||
}));
|
||||
|
||||
vi.mock('../../services/alertService.js', () => ({
|
||||
reportProxyAllFailed: (...args: unknown[]) => reportProxyAllFailedMock(...args),
|
||||
reportTokenExpired: (...args: unknown[]) => reportTokenExpiredMock(...args),
|
||||
}));
|
||||
|
||||
vi.mock('../../services/alertRules.js', () => ({
|
||||
isTokenExpiredError: (...args: unknown[]) => isTokenExpiredErrorMock(...args),
|
||||
}));
|
||||
|
||||
vi.mock('../../services/proxyRetryPolicy.js', () => ({
|
||||
shouldRetryProxyRequest: (...args: unknown[]) => shouldRetryProxyRequestMock(...args),
|
||||
}));
|
||||
|
||||
vi.mock('../../services/oauth/quota.js', () => ({
|
||||
recordOauthQuotaResetHint: (...args: unknown[]) => recordOauthQuotaResetHintMock(...args),
|
||||
}));
|
||||
|
||||
describe('selectSurfaceChannelForAttempt', () => {
|
||||
beforeEach(() => {
|
||||
selectChannelMock.mockReset();
|
||||
selectNextChannelMock.mockReset();
|
||||
recordFailureMock.mockReset();
|
||||
refreshModelsAndRebuildRoutesMock.mockReset();
|
||||
composeProxyLogMessageMock.mockReset();
|
||||
formatUtcSqlDateTimeMock.mockReset();
|
||||
insertProxyLogMock.mockReset();
|
||||
resolveChannelProxyUrlMock.mockReset();
|
||||
withSiteRecordProxyRequestInitMock.mockReset();
|
||||
dispatchRuntimeRequestMock.mockReset();
|
||||
reportProxyAllFailedMock.mockReset();
|
||||
reportTokenExpiredMock.mockReset();
|
||||
isTokenExpiredErrorMock.mockReset();
|
||||
shouldRetryProxyRequestMock.mockReset();
|
||||
recordOauthQuotaResetHintMock.mockReset();
|
||||
});
|
||||
|
||||
it('refreshes models and retries selectChannel on the first attempt when no channel is available', async () => {
|
||||
const selected = { channel: { id: 11 } };
|
||||
selectChannelMock
|
||||
.mockResolvedValueOnce(null)
|
||||
.mockResolvedValueOnce(selected);
|
||||
|
||||
const { selectSurfaceChannelForAttempt } = await import('./sharedSurface.js');
|
||||
const result = await selectSurfaceChannelForAttempt({
|
||||
requestedModel: 'gpt-5.2',
|
||||
downstreamPolicy: EMPTY_DOWNSTREAM_ROUTING_POLICY,
|
||||
excludeChannelIds: [],
|
||||
retryCount: 0,
|
||||
});
|
||||
|
||||
expect(result).toBe(selected);
|
||||
expect(selectChannelMock).toHaveBeenCalledTimes(2);
|
||||
expect(selectNextChannelMock).not.toHaveBeenCalled();
|
||||
expect(refreshModelsAndRebuildRoutesMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('uses selectNextChannel on retry attempts without refreshing models', async () => {
|
||||
const selected = { channel: { id: 22 } };
|
||||
selectNextChannelMock.mockResolvedValueOnce(selected);
|
||||
|
||||
const { selectSurfaceChannelForAttempt } = await import('./sharedSurface.js');
|
||||
const result = await selectSurfaceChannelForAttempt({
|
||||
requestedModel: 'gpt-5.2',
|
||||
downstreamPolicy: EMPTY_DOWNSTREAM_ROUTING_POLICY,
|
||||
excludeChannelIds: [11],
|
||||
retryCount: 1,
|
||||
});
|
||||
|
||||
expect(result).toBe(selected);
|
||||
expect(selectChannelMock).not.toHaveBeenCalled();
|
||||
expect(selectNextChannelMock).toHaveBeenCalledWith(
|
||||
'gpt-5.2',
|
||||
[11],
|
||||
EMPTY_DOWNSTREAM_ROUTING_POLICY,
|
||||
);
|
||||
expect(refreshModelsAndRebuildRoutesMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('writes proxy logs through the shared log formatter and store', async () => {
|
||||
composeProxyLogMessageMock.mockReturnValue('normalized error');
|
||||
formatUtcSqlDateTimeMock.mockReturnValue('2026-03-21 22:00:00');
|
||||
insertProxyLogMock.mockResolvedValue(undefined);
|
||||
|
||||
const { writeSurfaceProxyLog } = await import('./sharedSurface.js');
|
||||
await writeSurfaceProxyLog({
|
||||
warningScope: 'chat',
|
||||
selected: {
|
||||
channel: { id: 11, routeId: 22 },
|
||||
account: { id: 33 },
|
||||
actualModel: 'upstream-model',
|
||||
},
|
||||
modelRequested: 'gpt-5.2',
|
||||
status: 'failed',
|
||||
httpStatus: 502,
|
||||
latencyMs: 1200,
|
||||
errorMessage: 'upstream failed',
|
||||
retryCount: 1,
|
||||
downstreamPath: '/v1/chat/completions',
|
||||
promptTokens: 10,
|
||||
completionTokens: 5,
|
||||
totalTokens: 15,
|
||||
estimatedCost: 0.42,
|
||||
billingDetails: { source: 'test' },
|
||||
upstreamPath: '/v1/responses',
|
||||
clientContext: {
|
||||
clientKind: 'codex',
|
||||
clientAppId: 'app-id',
|
||||
clientAppName: 'App',
|
||||
clientConfidence: 'high',
|
||||
sessionId: 'sess-1',
|
||||
traceHint: 'trace-1',
|
||||
},
|
||||
downstreamApiKeyId: 44,
|
||||
});
|
||||
|
||||
expect(composeProxyLogMessageMock).toHaveBeenCalledWith({
|
||||
clientKind: 'codex',
|
||||
sessionId: 'sess-1',
|
||||
traceHint: 'trace-1',
|
||||
downstreamPath: '/v1/chat/completions',
|
||||
upstreamPath: '/v1/responses',
|
||||
errorMessage: 'upstream failed',
|
||||
});
|
||||
expect(insertProxyLogMock).toHaveBeenCalledWith({
|
||||
routeId: 22,
|
||||
channelId: 11,
|
||||
accountId: 33,
|
||||
downstreamApiKeyId: 44,
|
||||
modelRequested: 'gpt-5.2',
|
||||
modelActual: 'upstream-model',
|
||||
status: 'failed',
|
||||
httpStatus: 502,
|
||||
latencyMs: 1200,
|
||||
promptTokens: 10,
|
||||
completionTokens: 5,
|
||||
totalTokens: 15,
|
||||
estimatedCost: 0.42,
|
||||
billingDetails: { source: 'test' },
|
||||
clientFamily: 'codex',
|
||||
clientAppId: 'app-id',
|
||||
clientAppName: 'App',
|
||||
clientConfidence: 'high',
|
||||
errorMessage: 'normalized error',
|
||||
retryCount: 1,
|
||||
createdAt: '2026-03-21 22:00:00',
|
||||
});
|
||||
});
|
||||
|
||||
it('builds runtime dispatch requests with site proxy initialization', async () => {
|
||||
const site = { url: 'https://upstream.example.com' };
|
||||
const request = {
|
||||
endpoint: 'responses',
|
||||
path: '/v1/responses',
|
||||
headers: { authorization: 'Bearer test' },
|
||||
body: { model: 'gpt-5.2', input: 'hello' },
|
||||
runtime: { executor: 'default' },
|
||||
};
|
||||
resolveChannelProxyUrlMock.mockReturnValue('http://proxy.example.com');
|
||||
withSiteRecordProxyRequestInitMock.mockImplementation(async (_site, init, proxyUrl) => ({
|
||||
...init,
|
||||
proxyUrl,
|
||||
}));
|
||||
dispatchRuntimeRequestMock.mockResolvedValue('ok');
|
||||
|
||||
const { createSurfaceDispatchRequest } = await import('./sharedSurface.js');
|
||||
const dispatchRequest = createSurfaceDispatchRequest({
|
||||
site,
|
||||
accountExtraConfig: '{"proxyUrl":"http://proxy.example.com"}',
|
||||
});
|
||||
const result = await dispatchRequest(request, 'https://target.example.com/v1/responses');
|
||||
|
||||
expect(result).toBe('ok');
|
||||
expect(resolveChannelProxyUrlMock).toHaveBeenCalledWith(
|
||||
site,
|
||||
'{"proxyUrl":"http://proxy.example.com"}',
|
||||
);
|
||||
expect(dispatchRuntimeRequestMock).toHaveBeenCalledTimes(1);
|
||||
const dispatchArg = dispatchRuntimeRequestMock.mock.calls[0]?.[0];
|
||||
expect(dispatchArg.siteUrl).toBe('https://upstream.example.com');
|
||||
expect(dispatchArg.targetUrl).toBe('https://target.example.com/v1/responses');
|
||||
expect(dispatchArg.request).toBe(request);
|
||||
return dispatchArg.buildInit('https://target.example.com/v1/responses', {
|
||||
headers: { authorization: 'Bearer test' },
|
||||
body: { model: 'gpt-5.2', input: 'hello' },
|
||||
}).then((init: Record<string, unknown>) => {
|
||||
expect(withSiteRecordProxyRequestInitMock).toHaveBeenCalledWith(site, {
|
||||
method: 'POST',
|
||||
headers: { authorization: 'Bearer test' },
|
||||
body: JSON.stringify({ model: 'gpt-5.2', input: 'hello' }),
|
||||
}, 'http://proxy.example.com');
|
||||
expect(init).toEqual({
|
||||
method: 'POST',
|
||||
headers: { authorization: 'Bearer test' },
|
||||
body: JSON.stringify({ model: 'gpt-5.2', input: 'hello' }),
|
||||
proxyUrl: 'http://proxy.example.com',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('retries retryable upstream HTTP failures through the shared failure toolkit', async () => {
|
||||
composeProxyLogMessageMock.mockReturnValue('normalized error');
|
||||
formatUtcSqlDateTimeMock.mockReturnValue('2026-03-21 22:00:00');
|
||||
insertProxyLogMock.mockResolvedValue(undefined);
|
||||
shouldRetryProxyRequestMock.mockReturnValue(true);
|
||||
isTokenExpiredErrorMock.mockReturnValue(false);
|
||||
recordOauthQuotaResetHintMock.mockResolvedValue(null);
|
||||
|
||||
const { createSurfaceFailureToolkit } = await import('./sharedSurface.js');
|
||||
const toolkit = createSurfaceFailureToolkit({
|
||||
warningScope: 'chat',
|
||||
downstreamPath: '/v1/chat/completions',
|
||||
maxRetries: 2,
|
||||
clientContext: null,
|
||||
downstreamApiKeyId: 44,
|
||||
});
|
||||
|
||||
const result = await toolkit.handleUpstreamFailure({
|
||||
selected: {
|
||||
channel: { id: 11, routeId: 22 },
|
||||
account: { id: 33, username: 'oauth-user' },
|
||||
site: { name: 'Codex OAuth' },
|
||||
actualModel: 'upstream-model',
|
||||
},
|
||||
requestedModel: 'gpt-5.2',
|
||||
modelName: 'upstream-model',
|
||||
status: 429,
|
||||
errText: 'quota exceeded',
|
||||
rawErrText: '{"error":"quota exceeded"}',
|
||||
latencyMs: 1200,
|
||||
retryCount: 0,
|
||||
});
|
||||
|
||||
expect(result).toEqual({ action: 'retry' });
|
||||
expect(recordFailureMock).toHaveBeenCalledWith(11, {
|
||||
status: 429,
|
||||
errorText: '{"error":"quota exceeded"}',
|
||||
modelName: 'upstream-model',
|
||||
});
|
||||
expect(recordOauthQuotaResetHintMock).toHaveBeenCalledWith({
|
||||
accountId: 33,
|
||||
statusCode: 429,
|
||||
errorText: '{"error":"quota exceeded"}',
|
||||
});
|
||||
expect(reportProxyAllFailedMock).not.toHaveBeenCalled();
|
||||
expect(insertProxyLogMock).toHaveBeenCalledWith(expect.objectContaining({
|
||||
channelId: 11,
|
||||
accountId: 33,
|
||||
downstreamApiKeyId: 44,
|
||||
modelRequested: 'gpt-5.2',
|
||||
modelActual: 'upstream-model',
|
||||
status: 'failed',
|
||||
httpStatus: 429,
|
||||
latencyMs: 1200,
|
||||
errorMessage: 'normalized error',
|
||||
retryCount: 0,
|
||||
}));
|
||||
});
|
||||
|
||||
it('returns a terminal upstream error response and reports token expiration when retries stop', async () => {
|
||||
composeProxyLogMessageMock.mockReturnValue('normalized error');
|
||||
formatUtcSqlDateTimeMock.mockReturnValue('2026-03-21 22:00:00');
|
||||
insertProxyLogMock.mockResolvedValue(undefined);
|
||||
shouldRetryProxyRequestMock.mockReturnValue(false);
|
||||
isTokenExpiredErrorMock.mockReturnValue(true);
|
||||
recordOauthQuotaResetHintMock.mockResolvedValue(null);
|
||||
|
||||
const { createSurfaceFailureToolkit } = await import('./sharedSurface.js');
|
||||
const toolkit = createSurfaceFailureToolkit({
|
||||
warningScope: 'responses',
|
||||
downstreamPath: '/v1/responses',
|
||||
maxRetries: 2,
|
||||
clientContext: null,
|
||||
downstreamApiKeyId: null,
|
||||
});
|
||||
|
||||
const result = await toolkit.handleUpstreamFailure({
|
||||
selected: {
|
||||
channel: { id: 11, routeId: 22 },
|
||||
account: { id: 33, username: 'oauth-user' },
|
||||
site: { name: 'Codex OAuth' },
|
||||
actualModel: 'upstream-model',
|
||||
},
|
||||
requestedModel: 'gpt-5.2',
|
||||
modelName: 'upstream-model',
|
||||
status: 401,
|
||||
errText: 'expired token',
|
||||
rawErrText: 'expired token',
|
||||
latencyMs: 900,
|
||||
retryCount: 2,
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
action: 'respond',
|
||||
status: 401,
|
||||
payload: {
|
||||
error: {
|
||||
message: 'expired token',
|
||||
type: 'upstream_error',
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(reportTokenExpiredMock).toHaveBeenCalledWith({
|
||||
accountId: 33,
|
||||
username: 'oauth-user',
|
||||
siteName: 'Codex OAuth',
|
||||
detail: 'HTTP 401',
|
||||
});
|
||||
expect(reportProxyAllFailedMock).toHaveBeenCalledWith({
|
||||
model: 'gpt-5.2',
|
||||
reason: 'upstream returned HTTP 401',
|
||||
});
|
||||
});
|
||||
|
||||
it('handles detected proxy failures through the shared failure toolkit', async () => {
|
||||
composeProxyLogMessageMock.mockReturnValue('normalized error');
|
||||
formatUtcSqlDateTimeMock.mockReturnValue('2026-03-21 22:00:00');
|
||||
insertProxyLogMock.mockResolvedValue(undefined);
|
||||
shouldRetryProxyRequestMock.mockReturnValue(false);
|
||||
|
||||
const { createSurfaceFailureToolkit } = await import('./sharedSurface.js');
|
||||
const toolkit = createSurfaceFailureToolkit({
|
||||
warningScope: 'chat',
|
||||
downstreamPath: '/v1/chat/completions',
|
||||
maxRetries: 2,
|
||||
clientContext: null,
|
||||
downstreamApiKeyId: null,
|
||||
});
|
||||
|
||||
const result = await toolkit.handleDetectedFailure({
|
||||
selected: {
|
||||
channel: { id: 11, routeId: 22 },
|
||||
account: { id: 33, username: 'oauth-user' },
|
||||
site: { name: 'Codex OAuth' },
|
||||
actualModel: 'upstream-model',
|
||||
},
|
||||
requestedModel: 'gpt-5.2',
|
||||
modelName: 'upstream-model',
|
||||
failure: {
|
||||
status: 500,
|
||||
reason: 'upstream failure',
|
||||
},
|
||||
latencyMs: 700,
|
||||
retryCount: 2,
|
||||
promptTokens: 12,
|
||||
completionTokens: 4,
|
||||
totalTokens: 16,
|
||||
upstreamPath: '/v1/responses',
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
action: 'respond',
|
||||
status: 500,
|
||||
payload: {
|
||||
error: {
|
||||
message: 'upstream failure',
|
||||
type: 'upstream_error',
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(recordFailureMock).toHaveBeenCalledWith(11, {
|
||||
status: 500,
|
||||
errorText: 'upstream failure',
|
||||
modelName: 'upstream-model',
|
||||
});
|
||||
expect(reportProxyAllFailedMock).toHaveBeenCalledWith({
|
||||
model: 'gpt-5.2',
|
||||
reason: 'upstream failure',
|
||||
});
|
||||
expect(recordOauthQuotaResetHintMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns a terminal 502 for exhausted network failures through the shared failure toolkit', async () => {
|
||||
composeProxyLogMessageMock.mockReturnValue('normalized error');
|
||||
formatUtcSqlDateTimeMock.mockReturnValue('2026-03-21 22:00:00');
|
||||
insertProxyLogMock.mockResolvedValue(undefined);
|
||||
|
||||
const { createSurfaceFailureToolkit } = await import('./sharedSurface.js');
|
||||
const toolkit = createSurfaceFailureToolkit({
|
||||
warningScope: 'responses',
|
||||
downstreamPath: '/v1/responses',
|
||||
maxRetries: 2,
|
||||
clientContext: null,
|
||||
downstreamApiKeyId: null,
|
||||
});
|
||||
|
||||
const result = await toolkit.handleExecutionError({
|
||||
selected: {
|
||||
channel: { id: 11, routeId: 22 },
|
||||
account: { id: 33, username: 'oauth-user' },
|
||||
site: { name: 'Codex OAuth' },
|
||||
actualModel: 'upstream-model',
|
||||
},
|
||||
requestedModel: 'gpt-5.2',
|
||||
modelName: 'upstream-model',
|
||||
errorMessage: 'socket hang up',
|
||||
latencyMs: 650,
|
||||
retryCount: 2,
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
action: 'respond',
|
||||
status: 502,
|
||||
payload: {
|
||||
error: {
|
||||
message: 'Upstream error: socket hang up',
|
||||
type: 'upstream_error',
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(recordFailureMock).toHaveBeenCalledWith(11, {
|
||||
errorText: 'socket hang up',
|
||||
modelName: 'upstream-model',
|
||||
});
|
||||
expect(reportProxyAllFailedMock).toHaveBeenCalledWith({
|
||||
model: 'gpt-5.2',
|
||||
reason: 'socket hang up',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,394 @@
|
||||
import { refreshModelsAndRebuildRoutes } from '../../services/modelService.js';
|
||||
import { formatUtcSqlDateTime } from '../../services/localTimeService.js';
|
||||
import { resolveChannelProxyUrl, withSiteRecordProxyRequestInit } from '../../services/siteProxy.js';
|
||||
import type { SiteProxyConfigLike } from '../../services/siteProxy.js';
|
||||
import { tokenRouter } from '../../services/tokenRouter.js';
|
||||
import type { DownstreamRoutingPolicy } from '../../services/downstreamPolicyTypes.js';
|
||||
import { reportProxyAllFailed, reportTokenExpired } from '../../services/alertService.js';
|
||||
import { isTokenExpiredError } from '../../services/alertRules.js';
|
||||
import { shouldRetryProxyRequest } from '../../services/proxyRetryPolicy.js';
|
||||
import { composeProxyLogMessage } from '../../routes/proxy/logPathMeta.js';
|
||||
import type { DownstreamClientContext } from '../../routes/proxy/downstreamClientContext.js';
|
||||
import { insertProxyLog } from '../../services/proxyLogStore.js';
|
||||
import { dispatchRuntimeRequest } from '../../routes/proxy/runtimeExecutor.js';
|
||||
import type { BuiltEndpointRequest } from '../../routes/proxy/endpointFlow.js';
|
||||
import { recordOauthQuotaResetHint } from '../../services/oauth/quota.js';
|
||||
|
||||
type SelectedChannel = Awaited<ReturnType<typeof tokenRouter.selectChannel>>;
|
||||
type SurfaceWarningScope = 'chat' | 'responses';
|
||||
|
||||
type SurfaceSelectedChannel = {
|
||||
channel: { routeId: number | null; id: number };
|
||||
account: { id: number; username?: string | null };
|
||||
site: { name?: string | null };
|
||||
actualModel?: string | null;
|
||||
};
|
||||
|
||||
type SurfaceFailureResponse = {
|
||||
action: 'respond';
|
||||
status: number;
|
||||
payload: {
|
||||
error: {
|
||||
message: string;
|
||||
type: 'upstream_error';
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
type SurfaceFailureOutcome =
|
||||
| { action: 'retry' }
|
||||
| SurfaceFailureResponse;
|
||||
|
||||
export async function selectSurfaceChannelForAttempt(input: {
|
||||
requestedModel: string;
|
||||
downstreamPolicy: DownstreamRoutingPolicy;
|
||||
excludeChannelIds: number[];
|
||||
retryCount: number;
|
||||
}): Promise<SelectedChannel> {
|
||||
let selected = input.retryCount === 0
|
||||
? await tokenRouter.selectChannel(input.requestedModel, input.downstreamPolicy)
|
||||
: await tokenRouter.selectNextChannel(
|
||||
input.requestedModel,
|
||||
input.excludeChannelIds,
|
||||
input.downstreamPolicy,
|
||||
);
|
||||
|
||||
if (!selected && input.retryCount === 0) {
|
||||
await refreshModelsAndRebuildRoutes();
|
||||
selected = await tokenRouter.selectChannel(input.requestedModel, input.downstreamPolicy);
|
||||
}
|
||||
|
||||
return selected;
|
||||
}
|
||||
|
||||
export async function writeSurfaceProxyLog(input: {
|
||||
warningScope: string;
|
||||
selected: {
|
||||
channel: { routeId: number | null; id: number | null };
|
||||
account: { id: number | null };
|
||||
actualModel?: string | null;
|
||||
};
|
||||
modelRequested: string;
|
||||
status: string;
|
||||
httpStatus: number;
|
||||
latencyMs: number;
|
||||
errorMessage: string | null;
|
||||
retryCount: number;
|
||||
downstreamPath: string;
|
||||
promptTokens?: number;
|
||||
completionTokens?: number;
|
||||
totalTokens?: number;
|
||||
estimatedCost?: number;
|
||||
billingDetails?: unknown;
|
||||
upstreamPath?: string | null;
|
||||
clientContext?: DownstreamClientContext | null;
|
||||
downstreamApiKeyId?: number | null;
|
||||
}): Promise<void> {
|
||||
try {
|
||||
const createdAt = formatUtcSqlDateTime(new Date());
|
||||
const normalizedErrorMessage = composeProxyLogMessage({
|
||||
clientKind: input.clientContext?.clientKind && input.clientContext.clientKind !== 'generic'
|
||||
? input.clientContext.clientKind
|
||||
: null,
|
||||
sessionId: input.clientContext?.sessionId || null,
|
||||
traceHint: input.clientContext?.traceHint || null,
|
||||
downstreamPath: input.downstreamPath,
|
||||
upstreamPath: input.upstreamPath || null,
|
||||
errorMessage: input.errorMessage,
|
||||
});
|
||||
await insertProxyLog({
|
||||
routeId: input.selected.channel.routeId,
|
||||
channelId: input.selected.channel.id,
|
||||
accountId: input.selected.account.id,
|
||||
downstreamApiKeyId: input.downstreamApiKeyId ?? null,
|
||||
modelRequested: input.modelRequested,
|
||||
modelActual: input.selected.actualModel ?? null,
|
||||
status: input.status,
|
||||
httpStatus: input.httpStatus,
|
||||
latencyMs: input.latencyMs,
|
||||
promptTokens: input.promptTokens ?? 0,
|
||||
completionTokens: input.completionTokens ?? 0,
|
||||
totalTokens: input.totalTokens ?? 0,
|
||||
estimatedCost: input.estimatedCost ?? 0,
|
||||
billingDetails: input.billingDetails ?? null,
|
||||
clientFamily: input.clientContext?.clientKind || null,
|
||||
clientAppId: input.clientContext?.clientAppId || null,
|
||||
clientAppName: input.clientContext?.clientAppName || null,
|
||||
clientConfidence: input.clientContext?.clientConfidence || null,
|
||||
errorMessage: normalizedErrorMessage,
|
||||
retryCount: input.retryCount,
|
||||
createdAt,
|
||||
});
|
||||
} catch (error) {
|
||||
console.warn(`[proxy/${input.warningScope}] failed to write proxy log`, error);
|
||||
}
|
||||
}
|
||||
|
||||
export function createSurfaceDispatchRequest(input: {
|
||||
site: SiteProxyConfigLike & { url: string };
|
||||
accountExtraConfig?: string | null;
|
||||
}) {
|
||||
const channelProxyUrl = resolveChannelProxyUrl(input.site, input.accountExtraConfig);
|
||||
return (
|
||||
request: BuiltEndpointRequest,
|
||||
targetUrl?: string,
|
||||
) => (
|
||||
dispatchRuntimeRequest({
|
||||
siteUrl: input.site.url,
|
||||
targetUrl,
|
||||
request,
|
||||
buildInit: (_requestUrl, requestForFetch) => withSiteRecordProxyRequestInit(input.site, {
|
||||
method: 'POST',
|
||||
headers: requestForFetch.headers,
|
||||
body: JSON.stringify(requestForFetch.body),
|
||||
}, channelProxyUrl),
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
export function createSurfaceFailureToolkit(input: {
|
||||
warningScope: SurfaceWarningScope;
|
||||
downstreamPath: string;
|
||||
maxRetries: number;
|
||||
clientContext?: DownstreamClientContext | null;
|
||||
downstreamApiKeyId?: number | null;
|
||||
}) {
|
||||
const log = async (args: {
|
||||
selected: SurfaceSelectedChannel;
|
||||
modelRequested: string;
|
||||
status: string;
|
||||
httpStatus: number;
|
||||
latencyMs: number;
|
||||
errorMessage: string | null;
|
||||
retryCount: number;
|
||||
promptTokens?: number;
|
||||
completionTokens?: number;
|
||||
totalTokens?: number;
|
||||
estimatedCost?: number;
|
||||
billingDetails?: unknown;
|
||||
upstreamPath?: string | null;
|
||||
}) => {
|
||||
await writeSurfaceProxyLog({
|
||||
warningScope: input.warningScope,
|
||||
selected: args.selected,
|
||||
modelRequested: args.modelRequested,
|
||||
status: args.status,
|
||||
httpStatus: args.httpStatus,
|
||||
latencyMs: args.latencyMs,
|
||||
errorMessage: args.errorMessage,
|
||||
retryCount: args.retryCount,
|
||||
downstreamPath: input.downstreamPath,
|
||||
promptTokens: args.promptTokens,
|
||||
completionTokens: args.completionTokens,
|
||||
totalTokens: args.totalTokens,
|
||||
estimatedCost: args.estimatedCost,
|
||||
billingDetails: args.billingDetails,
|
||||
upstreamPath: args.upstreamPath,
|
||||
clientContext: input.clientContext,
|
||||
downstreamApiKeyId: input.downstreamApiKeyId,
|
||||
});
|
||||
};
|
||||
|
||||
const maybeRetry = (retryCount: number) => retryCount < input.maxRetries
|
||||
? { action: 'retry' as const }
|
||||
: null;
|
||||
|
||||
return {
|
||||
log,
|
||||
async handleUpstreamFailure(args: {
|
||||
selected: SurfaceSelectedChannel;
|
||||
requestedModel: string;
|
||||
modelName: string;
|
||||
status: number;
|
||||
errText: string;
|
||||
rawErrText?: string | null;
|
||||
latencyMs: number;
|
||||
retryCount: number;
|
||||
}): Promise<SurfaceFailureOutcome> {
|
||||
const rawErrText = args.rawErrText || args.errText;
|
||||
tokenRouter.recordFailure(args.selected.channel.id, {
|
||||
status: args.status,
|
||||
errorText: rawErrText,
|
||||
modelName: args.modelName,
|
||||
});
|
||||
await log({
|
||||
selected: args.selected,
|
||||
modelRequested: args.requestedModel,
|
||||
status: 'failed',
|
||||
httpStatus: args.status,
|
||||
latencyMs: args.latencyMs,
|
||||
errorMessage: args.errText,
|
||||
retryCount: args.retryCount,
|
||||
});
|
||||
await recordOauthQuotaResetHint({
|
||||
accountId: args.selected.account.id,
|
||||
statusCode: args.status,
|
||||
errorText: rawErrText,
|
||||
});
|
||||
|
||||
if (isTokenExpiredError({ status: args.status, message: args.errText })) {
|
||||
await reportTokenExpired({
|
||||
accountId: args.selected.account.id,
|
||||
username: args.selected.account.username,
|
||||
siteName: args.selected.site.name,
|
||||
detail: `HTTP ${args.status}`,
|
||||
});
|
||||
}
|
||||
|
||||
if (shouldRetryProxyRequest(args.status, args.errText)) {
|
||||
const retry = maybeRetry(args.retryCount);
|
||||
if (retry) return retry;
|
||||
}
|
||||
|
||||
await reportProxyAllFailed({
|
||||
model: args.requestedModel,
|
||||
reason: `upstream returned HTTP ${args.status}`,
|
||||
});
|
||||
|
||||
return {
|
||||
action: 'respond',
|
||||
status: args.status,
|
||||
payload: {
|
||||
error: {
|
||||
message: args.errText,
|
||||
type: 'upstream_error',
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
async handleDetectedFailure(args: {
|
||||
selected: SurfaceSelectedChannel;
|
||||
requestedModel: string;
|
||||
modelName: string;
|
||||
failure: { status: number; reason: string };
|
||||
latencyMs: number;
|
||||
retryCount: number;
|
||||
promptTokens?: number;
|
||||
completionTokens?: number;
|
||||
totalTokens?: number;
|
||||
upstreamPath?: string | null;
|
||||
}): Promise<SurfaceFailureOutcome> {
|
||||
tokenRouter.recordFailure(args.selected.channel.id, {
|
||||
status: args.failure.status,
|
||||
errorText: args.failure.reason,
|
||||
modelName: args.modelName,
|
||||
});
|
||||
await log({
|
||||
selected: args.selected,
|
||||
modelRequested: args.requestedModel,
|
||||
status: 'failed',
|
||||
httpStatus: args.failure.status,
|
||||
latencyMs: args.latencyMs,
|
||||
errorMessage: args.failure.reason,
|
||||
retryCount: args.retryCount,
|
||||
promptTokens: args.promptTokens,
|
||||
completionTokens: args.completionTokens,
|
||||
totalTokens: args.totalTokens,
|
||||
upstreamPath: args.upstreamPath,
|
||||
});
|
||||
|
||||
if (shouldRetryProxyRequest(args.failure.status, args.failure.reason)) {
|
||||
const retry = maybeRetry(args.retryCount);
|
||||
if (retry) return retry;
|
||||
}
|
||||
|
||||
await reportProxyAllFailed({
|
||||
model: args.requestedModel,
|
||||
reason: args.failure.reason,
|
||||
});
|
||||
|
||||
return {
|
||||
action: 'respond',
|
||||
status: args.failure.status,
|
||||
payload: {
|
||||
error: {
|
||||
message: args.failure.reason,
|
||||
type: 'upstream_error',
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
async handleExecutionError(args: {
|
||||
selected: SurfaceSelectedChannel;
|
||||
requestedModel: string;
|
||||
modelName: string;
|
||||
errorMessage: string;
|
||||
latencyMs: number;
|
||||
retryCount: number;
|
||||
}): Promise<SurfaceFailureOutcome> {
|
||||
tokenRouter.recordFailure(args.selected.channel.id, {
|
||||
errorText: args.errorMessage,
|
||||
modelName: args.modelName,
|
||||
});
|
||||
await log({
|
||||
selected: args.selected,
|
||||
modelRequested: args.requestedModel,
|
||||
status: 'failed',
|
||||
httpStatus: 0,
|
||||
latencyMs: args.latencyMs,
|
||||
errorMessage: args.errorMessage,
|
||||
retryCount: args.retryCount,
|
||||
});
|
||||
|
||||
const retry = maybeRetry(args.retryCount);
|
||||
if (retry) return retry;
|
||||
|
||||
await reportProxyAllFailed({
|
||||
model: args.requestedModel,
|
||||
reason: args.errorMessage || 'network failure',
|
||||
});
|
||||
|
||||
return {
|
||||
action: 'respond',
|
||||
status: 502,
|
||||
payload: {
|
||||
error: {
|
||||
message: `Upstream error: ${args.errorMessage || 'network failure'}`,
|
||||
type: 'upstream_error',
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
async recordStreamFailure(args: {
|
||||
selected: SurfaceSelectedChannel;
|
||||
requestedModel: string;
|
||||
modelName: string;
|
||||
errorMessage: string | null;
|
||||
latencyMs: number;
|
||||
retryCount: number;
|
||||
promptTokens?: number;
|
||||
completionTokens?: number;
|
||||
totalTokens?: number;
|
||||
upstreamPath?: string | null;
|
||||
httpStatus?: number;
|
||||
runtimeFailureStatus?: number | null;
|
||||
}) {
|
||||
const errorMessage = args.errorMessage || 'stream processing failed';
|
||||
if (typeof args.runtimeFailureStatus === 'number') {
|
||||
tokenRouter.recordFailure(args.selected.channel.id, {
|
||||
status: args.runtimeFailureStatus,
|
||||
errorText: errorMessage,
|
||||
modelName: args.modelName,
|
||||
});
|
||||
} else {
|
||||
tokenRouter.recordFailure(args.selected.channel.id, args.modelName);
|
||||
}
|
||||
await log({
|
||||
selected: args.selected,
|
||||
modelRequested: args.requestedModel,
|
||||
status: 'failed',
|
||||
httpStatus: args.httpStatus ?? 200,
|
||||
latencyMs: args.latencyMs,
|
||||
errorMessage,
|
||||
retryCount: args.retryCount,
|
||||
promptTokens: args.promptTokens,
|
||||
completionTokens: args.completionTokens,
|
||||
totalTokens: args.totalTokens,
|
||||
upstreamPath: args.upstreamPath,
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user