增加了空回为失败和自定义失败关键词的功能,修复了机器人提出的问题 (#153)
* Add proxy failure rules for keywords and empty responses * fix(proxy): detect failure keywords for streamed chat responses * Fix(tester): treat empty stream output as error * feat(server): default listen host to IPv6 any-address * fix(proxy): treat empty upstream content as failure * Revert "feat(server): default listen host to IPv6 any-address" This reverts commit 2ca49a3891cc3a2e37138156b17b660b6a3cb7ea. * fix(settings): validate proxy failure settings * fix: address CodeRabbit feedback in proxy failure detection * test: force NODE_ENV=test for vitest to avoid React act() prod build * fix(proxy/chat): preserve true SSE streaming (no upstream.text buffering) * fix: avoid http fallback after hijacked SSE stream * fix: wire proxy failure detection into chat and responses --------- Co-authored-by: metapi-bot <metapi-bot@local> Co-authored-by: openclaw <bot@local> Co-authored-by: openclaw <openclaw@local> Co-authored-by: openclaw-executor <executor@openclaw.local> Co-authored-by: xuyufengfei <xuyufengfei@users.noreply.github.com> Co-authored-by: cita <juricek.chen@gmail.com>
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
import Fastify, { type FastifyInstance } from 'fastify';
|
||||
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { config } from '../../config.js';
|
||||
|
||||
const fetchMock = vi.fn();
|
||||
const selectChannelMock = vi.fn();
|
||||
@@ -109,6 +110,8 @@ describe('chat proxy stream behavior', () => {
|
||||
});
|
||||
selectNextChannelMock.mockReturnValue(null);
|
||||
fetchModelPricingCatalogMock.mockResolvedValue(null);
|
||||
config.proxyEmptyContentFailEnabled = false;
|
||||
config.proxyErrorKeywords = [];
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
@@ -150,6 +153,77 @@ describe('chat proxy stream behavior', () => {
|
||||
expect(response.body).toContain('data: [DONE]');
|
||||
});
|
||||
|
||||
it('returns upstream_error for empty non-stream chat responses when empty-content failure is enabled', async () => {
|
||||
config.proxyEmptyContentFailEnabled = true;
|
||||
|
||||
fetchMock.mockResolvedValue(new Response(JSON.stringify({
|
||||
id: 'chatcmpl-empty',
|
||||
object: 'chat.completion',
|
||||
created: 1_706_000_000,
|
||||
model: 'upstream-gpt',
|
||||
choices: [{
|
||||
index: 0,
|
||||
message: { role: 'assistant', content: '' },
|
||||
finish_reason: 'stop',
|
||||
}],
|
||||
usage: { prompt_tokens: 6, completion_tokens: 0, total_tokens: 6 },
|
||||
}), {
|
||||
status: 200,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
}));
|
||||
|
||||
const response = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/v1/chat/completions',
|
||||
payload: {
|
||||
model: 'gpt-4o-mini',
|
||||
messages: [{ role: 'user', content: 'hi' }],
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(502);
|
||||
expect(response.json()?.error?.type).toBe('upstream_error');
|
||||
expect(response.json()?.error?.message).toContain('empty content');
|
||||
expect(recordSuccessMock).not.toHaveBeenCalled();
|
||||
expect(recordFailureMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('returns HTTP upstream_error instead of hijacking when streamed chat requests receive empty non-SSE payloads', async () => {
|
||||
config.proxyEmptyContentFailEnabled = true;
|
||||
|
||||
fetchMock.mockResolvedValue(new Response(JSON.stringify({
|
||||
id: 'chatcmpl-empty-stream',
|
||||
object: 'chat.completion',
|
||||
created: 1_706_000_000,
|
||||
model: 'upstream-gpt',
|
||||
choices: [{
|
||||
index: 0,
|
||||
message: { role: 'assistant', content: '' },
|
||||
finish_reason: 'stop',
|
||||
}],
|
||||
usage: { prompt_tokens: 4, completion_tokens: 0, total_tokens: 4 },
|
||||
}), {
|
||||
status: 200,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
}));
|
||||
|
||||
const response = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/v1/chat/completions',
|
||||
payload: {
|
||||
model: 'gpt-4o-mini',
|
||||
stream: true,
|
||||
messages: [{ role: 'user', content: 'hi' }],
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(502);
|
||||
expect(response.headers['content-type']).not.toContain('text/event-stream');
|
||||
expect(response.json()?.error?.type).toBe('upstream_error');
|
||||
expect(recordSuccessMock).not.toHaveBeenCalled();
|
||||
expect(recordFailureMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('returns clear 400 when /v1/chat/completions receives responses-style input without messages', async () => {
|
||||
const response = await app.inject({
|
||||
method: 'POST',
|
||||
@@ -1989,6 +2063,71 @@ describe('chat proxy stream behavior', () => {
|
||||
expect(targetUrl).toContain('/v1/responses');
|
||||
});
|
||||
|
||||
it('returns upstream_error for empty non-stream /v1/responses payloads when empty-content failure is enabled', async () => {
|
||||
config.proxyEmptyContentFailEnabled = true;
|
||||
|
||||
fetchMock.mockResolvedValue(new Response(JSON.stringify({
|
||||
id: 'resp-empty',
|
||||
object: 'response',
|
||||
model: 'gpt-5.4',
|
||||
status: 'completed',
|
||||
output: [],
|
||||
output_text: '',
|
||||
usage: { input_tokens: 3, output_tokens: 0, total_tokens: 3 },
|
||||
}), {
|
||||
status: 200,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
}));
|
||||
|
||||
const response = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/v1/responses',
|
||||
payload: {
|
||||
model: 'gpt-5.4',
|
||||
input: 'hello',
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(502);
|
||||
expect(response.json()?.error?.type).toBe('upstream_error');
|
||||
expect(response.json()?.error?.message).toContain('empty content');
|
||||
expect(recordSuccessMock).not.toHaveBeenCalled();
|
||||
expect(recordFailureMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('returns HTTP upstream_error instead of hijacking when streamed /v1/responses receives empty non-SSE payloads', async () => {
|
||||
config.proxyEmptyContentFailEnabled = true;
|
||||
|
||||
fetchMock.mockResolvedValue(new Response(JSON.stringify({
|
||||
id: 'resp-empty-stream',
|
||||
object: 'response',
|
||||
model: 'gpt-5.4',
|
||||
status: 'completed',
|
||||
output: [],
|
||||
output_text: '',
|
||||
usage: { input_tokens: 2, output_tokens: 0, total_tokens: 2 },
|
||||
}), {
|
||||
status: 200,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
}));
|
||||
|
||||
const response = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/v1/responses',
|
||||
payload: {
|
||||
model: 'gpt-5.4',
|
||||
input: 'hello',
|
||||
stream: true,
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(502);
|
||||
expect(response.headers['content-type']).not.toContain('text/event-stream');
|
||||
expect(response.json()?.error?.type).toBe('upstream_error');
|
||||
expect(recordSuccessMock).not.toHaveBeenCalled();
|
||||
expect(recordFailureMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('prefers native /v1/responses for claude-family /v1/responses requests that include input_file file_url', async () => {
|
||||
selectChannelMock.mockReturnValue({
|
||||
channel: { id: 11, routeId: 22 },
|
||||
|
||||
+142
-82
@@ -20,7 +20,8 @@ import {
|
||||
recordDownstreamCostUsage,
|
||||
} from './downstreamPolicy.js';
|
||||
import { composeProxyLogMessage } from './logPathMeta.js';
|
||||
import { executeEndpointFlow, type BuiltEndpointRequest } from './endpointFlow.js';
|
||||
import { executeEndpointFlow, withUpstreamPath, type BuiltEndpointRequest } from './endpointFlow.js';
|
||||
import { detectProxyFailure } from './proxyFailureJudge.js';
|
||||
import { formatUtcSqlDateTime } from '../../services/localTimeService.js';
|
||||
import { resolveProxyLogBilling } from './proxyBilling.js';
|
||||
import { openAiChatTransformer } from '../../transformers/openai/chat/index.js';
|
||||
@@ -342,12 +343,15 @@ async function handleChatProxyRequest(
|
||||
const successfulUpstreamPath = endpointResult.upstreamPath;
|
||||
|
||||
if (isStream) {
|
||||
reply.hijack();
|
||||
reply.raw.statusCode = 200;
|
||||
reply.raw.setHeader('Content-Type', 'text/event-stream; charset=utf-8');
|
||||
reply.raw.setHeader('Cache-Control', 'no-cache, no-transform');
|
||||
reply.raw.setHeader('Connection', 'keep-alive');
|
||||
reply.raw.setHeader('X-Accel-Buffering', 'no');
|
||||
const upstreamContentType = (upstream.headers.get('content-type') || '').toLowerCase();
|
||||
const startSseResponse = () => {
|
||||
reply.hijack();
|
||||
reply.raw.statusCode = 200;
|
||||
reply.raw.setHeader('Content-Type', 'text/event-stream; charset=utf-8');
|
||||
reply.raw.setHeader('Cache-Control', 'no-cache, no-transform');
|
||||
reply.raw.setHeader('Connection', 'keep-alive');
|
||||
reply.raw.setHeader('X-Accel-Buffering', 'no');
|
||||
};
|
||||
|
||||
let parsedUsage: ReturnType<typeof parseProxyUsage> = {
|
||||
promptTokens: 0,
|
||||
@@ -377,10 +381,11 @@ async function handleChatProxyRequest(
|
||||
reply.raw.write(chunk);
|
||||
},
|
||||
});
|
||||
let rawText = '';
|
||||
|
||||
const upstreamContentType = (upstream.headers.get('content-type') || '').toLowerCase();
|
||||
if (!upstreamContentType.includes('text/event-stream')) {
|
||||
const fallbackText = await upstream.text();
|
||||
rawText = fallbackText;
|
||||
let fallbackData: unknown = null;
|
||||
try {
|
||||
fallbackData = JSON.parse(fallbackText);
|
||||
@@ -390,7 +395,96 @@ async function handleChatProxyRequest(
|
||||
if (String(selected.site.platform || '').trim().toLowerCase() === 'gemini-cli') {
|
||||
fallbackData = unwrapGeminiCliPayload(fallbackData);
|
||||
}
|
||||
parsedUsage = mergeProxyUsage(parsedUsage, parseProxyUsage(fallbackData));
|
||||
const latency = Date.now() - startTime;
|
||||
const failure = detectProxyFailure({ rawText, usage: parsedUsage });
|
||||
if (failure) {
|
||||
tokenRouter.recordFailure(selected.channel.id);
|
||||
logProxy(
|
||||
selected,
|
||||
requestedModel,
|
||||
'failed',
|
||||
failure.status,
|
||||
latency,
|
||||
failure.reason,
|
||||
retryCount,
|
||||
downstreamPath,
|
||||
parsedUsage.promptTokens,
|
||||
parsedUsage.completionTokens,
|
||||
parsedUsage.totalTokens,
|
||||
0,
|
||||
null,
|
||||
successfulUpstreamPath,
|
||||
clientContext,
|
||||
logDownstreamApiKeyId ? downstreamApiKeyId : null,
|
||||
);
|
||||
|
||||
if (shouldRetryProxyRequest(failure.status, failure.reason) && retryCount < MAX_RETRIES) {
|
||||
retryCount += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
await reportProxyAllFailed({
|
||||
model: requestedModel,
|
||||
reason: failure.reason,
|
||||
});
|
||||
|
||||
return reply.code(failure.status).send({
|
||||
error: { message: failure.reason, type: 'upstream_error' },
|
||||
});
|
||||
}
|
||||
|
||||
startSseResponse();
|
||||
const streamResult = streamSession.consumeUpstreamFinalPayload(fallbackData, fallbackText, reply.raw);
|
||||
if (streamResult.status === 'failed') {
|
||||
tokenRouter.recordFailure(selected.channel.id);
|
||||
logProxy(
|
||||
selected,
|
||||
requestedModel,
|
||||
'failed',
|
||||
200,
|
||||
latency,
|
||||
streamResult.errorMessage,
|
||||
retryCount,
|
||||
downstreamPath,
|
||||
parsedUsage.promptTokens,
|
||||
parsedUsage.completionTokens,
|
||||
parsedUsage.totalTokens,
|
||||
0,
|
||||
null,
|
||||
successfulUpstreamPath,
|
||||
clientContext,
|
||||
logDownstreamApiKeyId ? downstreamApiKeyId : null,
|
||||
);
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
startSseResponse();
|
||||
// Preserve real SSE streaming instead of buffering the whole upstream body.
|
||||
const upstreamReader = upstream.body?.getReader();
|
||||
const baseReader = String(selected.site.platform || '').trim().toLowerCase() === 'gemini-cli' && upstreamReader
|
||||
? createGeminiCliStreamReader(upstreamReader)
|
||||
: upstreamReader;
|
||||
const decoder = new TextDecoder();
|
||||
const reader = baseReader
|
||||
? {
|
||||
async read() {
|
||||
const result = await baseReader.read();
|
||||
if (result.value) {
|
||||
rawText += decoder.decode(result.value, { stream: true });
|
||||
}
|
||||
return result;
|
||||
},
|
||||
async cancel(reason?: unknown) {
|
||||
return baseReader.cancel(reason);
|
||||
},
|
||||
releaseLock() {
|
||||
return baseReader.releaseLock();
|
||||
},
|
||||
}
|
||||
: baseReader;
|
||||
const streamResult = await streamSession.run(reader, reply.raw);
|
||||
rawText += decoder.decode();
|
||||
|
||||
const latency = Date.now() - startTime;
|
||||
if (streamResult.status === 'failed') {
|
||||
@@ -416,83 +510,13 @@ async function handleChatProxyRequest(
|
||||
return;
|
||||
}
|
||||
|
||||
const resolvedUsage = await resolveProxyUsageWithSelfLogFallback({
|
||||
site: selected.site,
|
||||
account: selected.account,
|
||||
tokenValue: selected.tokenValue,
|
||||
tokenName: selected.tokenName,
|
||||
modelName,
|
||||
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,
|
||||
parsedUsage,
|
||||
resolvedUsage,
|
||||
});
|
||||
|
||||
tokenRouter.recordSuccess(selected.channel.id, latency, estimatedCost);
|
||||
recordDownstreamCostUsage(request, estimatedCost);
|
||||
logProxy(
|
||||
selected,
|
||||
requestedModel,
|
||||
'success',
|
||||
200,
|
||||
latency,
|
||||
null,
|
||||
retryCount,
|
||||
downstreamPath,
|
||||
resolvedUsage.promptTokens,
|
||||
resolvedUsage.completionTokens,
|
||||
resolvedUsage.totalTokens,
|
||||
estimatedCost,
|
||||
billingDetails,
|
||||
successfulUpstreamPath,
|
||||
clientContext,
|
||||
logDownstreamApiKeyId ? downstreamApiKeyId : null,
|
||||
);
|
||||
return;
|
||||
// Once SSE has been hijacked and streamed downstream, we can no longer
|
||||
// safely fall back to an HTTP error response or retry by switching the
|
||||
// channel mid-flight. Stream-level failures must be handled in-band by
|
||||
// the proxy stream session itself.
|
||||
}
|
||||
|
||||
const upstreamReader = upstream.body?.getReader();
|
||||
const reader = String(selected.site.platform || '').trim().toLowerCase() === 'gemini-cli' && upstreamReader
|
||||
? createGeminiCliStreamReader(upstreamReader)
|
||||
: upstreamReader;
|
||||
const streamResult = await streamSession.run(reader, reply.raw);
|
||||
|
||||
const latency = Date.now() - startTime;
|
||||
if (streamResult.status === 'failed') {
|
||||
tokenRouter.recordFailure(selected.channel.id);
|
||||
logProxy(
|
||||
selected,
|
||||
requestedModel,
|
||||
'failed',
|
||||
200,
|
||||
latency,
|
||||
streamResult.errorMessage,
|
||||
retryCount,
|
||||
downstreamPath,
|
||||
parsedUsage.promptTokens,
|
||||
parsedUsage.completionTokens,
|
||||
parsedUsage.totalTokens,
|
||||
0,
|
||||
null,
|
||||
successfulUpstreamPath,
|
||||
clientContext,
|
||||
logDownstreamApiKeyId ? downstreamApiKeyId : null,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const resolvedUsage = await resolveProxyUsageWithSelfLogFallback({
|
||||
site: selected.site,
|
||||
account: selected.account,
|
||||
@@ -562,6 +586,42 @@ async function handleChatProxyRequest(
|
||||
|
||||
const latency = Date.now() - startTime;
|
||||
const parsedUsage = parseProxyUsage(upstreamData);
|
||||
const failure = detectProxyFailure({ rawText, usage: parsedUsage });
|
||||
if (failure) {
|
||||
tokenRouter.recordFailure(selected.channel.id);
|
||||
logProxy(
|
||||
selected,
|
||||
requestedModel,
|
||||
'failed',
|
||||
failure.status,
|
||||
latency,
|
||||
failure.reason,
|
||||
retryCount,
|
||||
downstreamPath,
|
||||
parsedUsage.promptTokens,
|
||||
parsedUsage.completionTokens,
|
||||
parsedUsage.totalTokens,
|
||||
0,
|
||||
null,
|
||||
successfulUpstreamPath,
|
||||
clientContext,
|
||||
logDownstreamApiKeyId ? downstreamApiKeyId : null,
|
||||
);
|
||||
|
||||
if (shouldRetryProxyRequest(failure.status, failure.reason) && retryCount < MAX_RETRIES) {
|
||||
retryCount += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
await reportProxyAllFailed({
|
||||
model: requestedModel,
|
||||
reason: failure.reason,
|
||||
});
|
||||
|
||||
return reply.code(failure.status).send({
|
||||
error: { message: failure.reason, type: 'upstream_error' },
|
||||
});
|
||||
}
|
||||
const normalizedFinal = downstreamTransformer.transformFinalResponse(upstreamData, modelName, rawText);
|
||||
const downstreamResponse = downstreamTransformer.serializeFinalResponse(normalizedFinal, parsedUsage);
|
||||
|
||||
|
||||
@@ -1,49 +1,50 @@
|
||||
import { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
|
||||
import { fetch } from 'undici';
|
||||
import { db, hasProxyLogDownstreamApiKeyIdColumn, 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 { getProxyAuthContext } from '../../middleware/auth.js';
|
||||
import { buildUpstreamUrl } from './upstreamUrl.js';
|
||||
import { db, hasProxyLogDownstreamApiKeyIdColumn, 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 { detectProxyFailure } from './proxyFailureJudge.js';
|
||||
import { resolveProxyLogBilling } from './proxyBilling.js';
|
||||
import { getProxyAuthContext } from '../../middleware/auth.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 downstreamApiKeyId = getProxyAuthContext(request)?.keyId ?? null;
|
||||
const logDownstreamApiKeyId = downstreamApiKeyId !== null
|
||||
&& await hasProxyLogDownstreamApiKeyIdColumn();
|
||||
|
||||
const isStream = body.stream === true;
|
||||
const excludeChannelIds: number[] = [];
|
||||
let retryCount = 0;
|
||||
const requestedModel = body?.model;
|
||||
if (!requestedModel) {
|
||||
return reply.code(400).send({ error: { message: 'model is required', type: 'invalid_request_error' } });
|
||||
}
|
||||
if (!await ensureModelAllowedForDownstreamKey(request, reply, requestedModel)) return;
|
||||
const downstreamPolicy = getDownstreamRoutingPolicy(request);
|
||||
const downstreamApiKeyId = getProxyAuthContext(request)?.keyId ?? null;
|
||||
const logDownstreamApiKeyId = downstreamApiKeyId !== null
|
||||
&& await hasProxyLogDownstreamApiKeyIdColumn();
|
||||
|
||||
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 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);
|
||||
}
|
||||
|
||||
if (!selected) {
|
||||
await reportProxyAllFailed({
|
||||
@@ -55,26 +56,26 @@ 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),
|
||||
}));
|
||||
|
||||
if (!upstream.ok) {
|
||||
const errText = await upstream.text().catch(() => 'unknown error');
|
||||
tokenRouter.recordFailure(selected.channel.id);
|
||||
logProxy(selected, requestedModel, 'failed', upstream.status, Date.now() - startTime, errText, retryCount, logDownstreamApiKeyId ? downstreamApiKeyId : null);
|
||||
if (!upstream.ok) {
|
||||
const errText = await upstream.text().catch(() => 'unknown error');
|
||||
tokenRouter.recordFailure(selected.channel.id);
|
||||
logProxy(selected, requestedModel, 'failed', upstream.status, Date.now() - startTime, errText, retryCount, logDownstreamApiKeyId ? downstreamApiKeyId : null);
|
||||
|
||||
if (isTokenExpiredError({ status: upstream.status, message: errText })) {
|
||||
await reportTokenExpired({
|
||||
@@ -85,10 +86,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,
|
||||
@@ -110,117 +111,153 @@ export async function completionsProxyRoute(app: FastifyInstance) {
|
||||
return;
|
||||
}
|
||||
|
||||
const decoder = new TextDecoder();
|
||||
let parsedUsage: ReturnType<typeof parseProxyUsage> = {
|
||||
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<typeof parseProxyUsage> = {
|
||||
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, logDownstreamApiKeyId ? downstreamApiKeyId : null,
|
||||
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, logDownstreamApiKeyId ? downstreamApiKeyId : null,
|
||||
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, logDownstreamApiKeyId ? downstreamApiKeyId : null);
|
||||
if (retryCount < MAX_RETRIES) {
|
||||
retryCount++;
|
||||
continue;
|
||||
}
|
||||
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, logDownstreamApiKeyId ? downstreamApiKeyId : null,
|
||||
resolvedUsage.promptTokens, resolvedUsage.completionTokens, resolvedUsage.totalTokens, estimatedCost, billingDetails,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const rawText = await upstream.text();
|
||||
let data: any = rawText;
|
||||
try {
|
||||
data = JSON.parse(rawText);
|
||||
} catch {
|
||||
data = rawText;
|
||||
}
|
||||
const latency = Date.now() - startTime;
|
||||
const parsedUsage = parseProxyUsage(data);
|
||||
const failure = detectProxyFailure({ rawText, usage: parsedUsage });
|
||||
if (failure) {
|
||||
const errText = failure.reason;
|
||||
tokenRouter.recordFailure(selected.channel.id);
|
||||
logProxy(
|
||||
selected,
|
||||
requestedModel,
|
||||
'failed',
|
||||
failure.status,
|
||||
latency,
|
||||
errText,
|
||||
retryCount,
|
||||
logDownstreamApiKeyId ? downstreamApiKeyId : null,
|
||||
);
|
||||
|
||||
if (shouldRetryProxyRequest(failure.status, errText) && retryCount < MAX_RETRIES) {
|
||||
retryCount += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
await reportProxyAllFailed({
|
||||
model: requestedModel,
|
||||
reason: failure.reason,
|
||||
});
|
||||
|
||||
return reply.code(failure.status).send({
|
||||
error: { message: errText, type: 'upstream_error' },
|
||||
});
|
||||
}
|
||||
|
||||
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, logDownstreamApiKeyId ? downstreamApiKeyId : null,
|
||||
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, logDownstreamApiKeyId ? downstreamApiKeyId : null);
|
||||
if (retryCount < MAX_RETRIES) {
|
||||
retryCount++;
|
||||
continue;
|
||||
}
|
||||
await reportProxyAllFailed({
|
||||
model: requestedModel,
|
||||
reason: err.message || 'network failure',
|
||||
@@ -233,48 +270,48 @@ export async function completionsProxyRoute(app: FastifyInstance) {
|
||||
});
|
||||
}
|
||||
|
||||
async function logProxy(
|
||||
selected: any,
|
||||
modelRequested: string,
|
||||
status: string,
|
||||
httpStatus: number,
|
||||
latencyMs: number,
|
||||
errorMessage: string | null,
|
||||
retryCount: number,
|
||||
downstreamApiKeyId: number | null = null,
|
||||
promptTokens = 0,
|
||||
completionTokens = 0,
|
||||
totalTokens = 0,
|
||||
estimatedCost = 0,
|
||||
billingDetails: unknown = null,
|
||||
) {
|
||||
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,
|
||||
...(downstreamApiKeyId !== null ? { downstreamApiKeyId } : {}),
|
||||
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);
|
||||
}
|
||||
}
|
||||
async function logProxy(
|
||||
selected: any,
|
||||
modelRequested: string,
|
||||
status: string,
|
||||
httpStatus: number,
|
||||
latencyMs: number,
|
||||
errorMessage: string | null,
|
||||
retryCount: number,
|
||||
downstreamApiKeyId: number | null = null,
|
||||
promptTokens = 0,
|
||||
completionTokens = 0,
|
||||
totalTokens = 0,
|
||||
estimatedCost = 0,
|
||||
billingDetails: unknown = null,
|
||||
) {
|
||||
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,
|
||||
...(downstreamApiKeyId !== null ? { downstreamApiKeyId } : {}),
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
import { config } from '../../config.js';
|
||||
import { detectProxyFailure } from './proxyFailureJudge.js';
|
||||
|
||||
describe('detectProxyFailure (empty content)', () => {
|
||||
const originalEmptyFail = config.proxyEmptyContentFailEnabled;
|
||||
const originalKeywords = Array.isArray(config.proxyErrorKeywords) ? [...config.proxyErrorKeywords] : config.proxyErrorKeywords;
|
||||
|
||||
afterEach(() => {
|
||||
config.proxyEmptyContentFailEnabled = originalEmptyFail;
|
||||
config.proxyErrorKeywords = originalKeywords as any;
|
||||
});
|
||||
|
||||
it('flags empty assistant content even when total tokens > 0', () => {
|
||||
config.proxyEmptyContentFailEnabled = true;
|
||||
|
||||
const rawText = JSON.stringify({
|
||||
id: 'chatcmpl_empty',
|
||||
object: 'chat.completion',
|
||||
choices: [{
|
||||
index: 0,
|
||||
message: { role: 'assistant', content: '' },
|
||||
finish_reason: 'stop',
|
||||
}],
|
||||
usage: { prompt_tokens: 12, completion_tokens: 0, total_tokens: 12 },
|
||||
});
|
||||
|
||||
const failure = detectProxyFailure({
|
||||
rawText,
|
||||
usage: { promptTokens: 12, completionTokens: 0, totalTokens: 12 },
|
||||
});
|
||||
|
||||
expect(failure).toMatchObject({ status: 502 });
|
||||
});
|
||||
|
||||
it('does not flag when output exists even if usage is missing', () => {
|
||||
config.proxyEmptyContentFailEnabled = true;
|
||||
|
||||
const rawText = JSON.stringify({
|
||||
id: 'chatcmpl_has_output',
|
||||
object: 'chat.completion',
|
||||
choices: [{
|
||||
index: 0,
|
||||
message: { role: 'assistant', content: 'hi' },
|
||||
finish_reason: 'stop',
|
||||
}],
|
||||
});
|
||||
|
||||
const failure = detectProxyFailure({
|
||||
rawText,
|
||||
usage: { promptTokens: 0, completionTokens: 0, totalTokens: 0 },
|
||||
});
|
||||
|
||||
expect(failure).toBeNull();
|
||||
});
|
||||
|
||||
it('does not treat tool call payloads as empty content', () => {
|
||||
config.proxyEmptyContentFailEnabled = true;
|
||||
|
||||
const rawText = JSON.stringify({
|
||||
id: 'resp_tool',
|
||||
object: 'response',
|
||||
status: 'completed',
|
||||
output: [{
|
||||
type: 'function_call',
|
||||
id: 'fc_1',
|
||||
call_id: 'call_abc',
|
||||
name: 'Glob',
|
||||
arguments: '{"pattern":"README*"}',
|
||||
}],
|
||||
output_text: '',
|
||||
});
|
||||
|
||||
const failure = detectProxyFailure({
|
||||
rawText,
|
||||
usage: { promptTokens: 0, completionTokens: 0, totalTokens: 0 },
|
||||
});
|
||||
|
||||
expect(failure).toBeNull();
|
||||
});
|
||||
|
||||
it('flags empty SSE streams that contain no content deltas', () => {
|
||||
config.proxyEmptyContentFailEnabled = true;
|
||||
|
||||
const rawText = [
|
||||
'data: {"id":"evt_1","choices":[{"delta":{}}]}',
|
||||
'',
|
||||
'data: [DONE]',
|
||||
'',
|
||||
'',
|
||||
].join('\n');
|
||||
|
||||
const failure = detectProxyFailure({
|
||||
rawText,
|
||||
usage: { promptTokens: 0, completionTokens: 0, totalTokens: 0 },
|
||||
});
|
||||
|
||||
expect(failure).toMatchObject({ status: 502 });
|
||||
});
|
||||
|
||||
it('treats DONE-only SSE as no output and flags failure', () => {
|
||||
config.proxyEmptyContentFailEnabled = true;
|
||||
|
||||
const rawText = [
|
||||
'data: [DONE]',
|
||||
'',
|
||||
'',
|
||||
].join('\n');
|
||||
|
||||
const failure = detectProxyFailure({
|
||||
rawText,
|
||||
usage: { promptTokens: 0, completionTokens: 0, totalTokens: 0 },
|
||||
});
|
||||
|
||||
expect(failure).toMatchObject({ status: 502 });
|
||||
});
|
||||
|
||||
it('truncates fractional token counts before empty-content detection', () => {
|
||||
config.proxyEmptyContentFailEnabled = true;
|
||||
|
||||
const rawText = JSON.stringify({
|
||||
id: 'chatcmpl_fractional_empty',
|
||||
object: 'chat.completion',
|
||||
choices: [{
|
||||
index: 0,
|
||||
message: { role: 'assistant', content: '' },
|
||||
finish_reason: 'stop',
|
||||
}],
|
||||
usage: { prompt_tokens: 3.9, completion_tokens: 0.6, total_tokens: 4.5 },
|
||||
});
|
||||
|
||||
const failure = detectProxyFailure({
|
||||
rawText,
|
||||
usage: { promptTokens: 3.9, completionTokens: 0.6, totalTokens: 4.5 } as any,
|
||||
});
|
||||
|
||||
expect(failure).toMatchObject({ status: 502 });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,203 @@
|
||||
import { config } from '../../config.js';
|
||||
import { pullSseDataEvents } from '../../services/proxyUsageParser.js';
|
||||
|
||||
type FailureResult = {
|
||||
status: number;
|
||||
reason: string;
|
||||
};
|
||||
|
||||
type UsageSummary = {
|
||||
promptTokens?: number;
|
||||
completionTokens?: number;
|
||||
totalTokens?: number;
|
||||
};
|
||||
|
||||
function normalizeKeywords(values: string[]): string[] {
|
||||
return values
|
||||
.map((item) => (typeof item === 'string' ? item.trim() : ''))
|
||||
.filter((item) => item.length > 0)
|
||||
.map((item) => item.toLowerCase());
|
||||
}
|
||||
|
||||
function toNonNegativeInt(value: unknown): number {
|
||||
const n = typeof value === 'number' ? value : Number(value);
|
||||
if (!Number.isFinite(n)) return 0;
|
||||
return Math.max(0, Math.trunc(n));
|
||||
}
|
||||
|
||||
function hasNonEmptyString(value: unknown): boolean {
|
||||
return typeof value === 'string' && value.trim().length > 0;
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return !!value && typeof value === 'object' && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function hasToolCallLike(value: unknown): boolean {
|
||||
if (!value) return false;
|
||||
if (Array.isArray(value)) return value.length > 0;
|
||||
if (isRecord(value)) return Object.keys(value).length > 0;
|
||||
return false;
|
||||
}
|
||||
|
||||
function hasCompletionContentFromChoice(choice: any): boolean {
|
||||
if (hasNonEmptyString(choice?.text)) return true;
|
||||
if (hasNonEmptyString(choice?.completion)) return true;
|
||||
if (hasNonEmptyString(choice?.output_text)) return true;
|
||||
|
||||
const message = choice?.message;
|
||||
if (hasNonEmptyString(message?.content)) return true;
|
||||
if (Array.isArray(message?.content)) {
|
||||
for (const part of message.content) {
|
||||
if (hasNonEmptyString(part?.text) || hasNonEmptyString(part?.output_text) || hasNonEmptyString(part?.content)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (hasNonEmptyString(message?.refusal)) return true;
|
||||
if (hasToolCallLike(message?.tool_calls)) return true;
|
||||
if (hasToolCallLike(message?.toolCalls)) return true;
|
||||
if (hasToolCallLike(message?.function_call)) return true;
|
||||
if (hasToolCallLike(message?.functionCall)) return true;
|
||||
|
||||
if (hasToolCallLike(choice?.tool_calls)) return true;
|
||||
if (hasToolCallLike(choice?.toolCalls)) return true;
|
||||
if (hasToolCallLike(choice?.function_call)) return true;
|
||||
if (hasToolCallLike(choice?.functionCall)) return true;
|
||||
|
||||
const delta = choice?.delta;
|
||||
if (hasNonEmptyString(delta?.content)) return true;
|
||||
if (hasNonEmptyString(delta?.refusal)) return true;
|
||||
if (hasToolCallLike(delta?.tool_calls)) return true;
|
||||
if (hasToolCallLike(delta?.toolCalls)) return true;
|
||||
if (hasToolCallLike(delta?.function_call)) return true;
|
||||
if (hasToolCallLike(delta?.functionCall)) return true;
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
function hasCompletionContentFromPayload(payload: unknown): boolean {
|
||||
if (!payload || typeof payload !== 'object') return false;
|
||||
const obj: any = payload;
|
||||
|
||||
if (Array.isArray(obj?.choices)) {
|
||||
for (const choice of obj.choices) {
|
||||
if (hasCompletionContentFromChoice(choice)) return true;
|
||||
}
|
||||
if (hasCompletionContentFromChoice(obj)) return true;
|
||||
}
|
||||
|
||||
if (hasNonEmptyString(obj?.output_text)) return true;
|
||||
if (hasNonEmptyString(obj?.outputText)) return true;
|
||||
|
||||
if (Array.isArray(obj?.output)) {
|
||||
for (const item of obj.output) {
|
||||
if (!isRecord(item)) continue;
|
||||
const type = String((item as any).type || '').toLowerCase();
|
||||
if (type.includes('function_call') || type.includes('tool_call')) return true;
|
||||
|
||||
if (hasNonEmptyString((item as any).text) || hasNonEmptyString((item as any).output_text)) return true;
|
||||
|
||||
if (Array.isArray((item as any).content)) {
|
||||
for (const part of (item as any).content) {
|
||||
if (hasNonEmptyString((part as any)?.text) || hasNonEmptyString((part as any)?.output_text) || hasNonEmptyString((part as any)?.content)) {
|
||||
return true;
|
||||
}
|
||||
const partType = String((part as any)?.type || '').toLowerCase();
|
||||
if (partType.includes('function_call') || partType.includes('tool_call')) return true;
|
||||
}
|
||||
}
|
||||
|
||||
if (hasToolCallLike((item as any).tool_calls) || hasToolCallLike((item as any).toolCalls)) return true;
|
||||
if (hasToolCallLike((item as any).function_call) || hasToolCallLike((item as any).functionCall)) return true;
|
||||
}
|
||||
}
|
||||
|
||||
if (Array.isArray(obj?.content)) {
|
||||
for (const part of obj.content) {
|
||||
if (hasNonEmptyString((part as any)?.text) || hasNonEmptyString((part as any)?.output_text) || hasNonEmptyString((part as any)?.content)) {
|
||||
return true;
|
||||
}
|
||||
const partType = String((part as any)?.type || '').toLowerCase();
|
||||
if (partType.includes('function_call') || partType.includes('tool_call')) return true;
|
||||
}
|
||||
}
|
||||
|
||||
if (hasNonEmptyString(obj?.delta)) return true;
|
||||
if (hasNonEmptyString(obj?.text)) return true;
|
||||
if (hasToolCallLike(obj?.tool_calls) || hasToolCallLike(obj?.toolCalls)) return true;
|
||||
if (hasToolCallLike(obj?.function_call) || hasToolCallLike(obj?.functionCall)) return true;
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
function detectHasUpstreamOutput(rawText: string): boolean {
|
||||
const text = typeof rawText === 'string' ? rawText : '';
|
||||
const trimmed = text.trim();
|
||||
if (!trimmed) return false;
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(trimmed);
|
||||
return hasCompletionContentFromPayload(parsed);
|
||||
} catch {
|
||||
// Important: don't trim before SSE parsing, otherwise `data: [DONE]\n\n` can be lost.
|
||||
const pulled = pullSseDataEvents(text);
|
||||
if (pulled.events.length > 0) {
|
||||
for (const event of pulled.events) {
|
||||
const payload = event.trim();
|
||||
if (!payload || payload === '[DONE]') continue;
|
||||
try {
|
||||
const parsedEvent = JSON.parse(payload);
|
||||
if (hasCompletionContentFromPayload(parsedEvent)) return true;
|
||||
} catch {
|
||||
// Non-JSON payload still counts as upstream output.
|
||||
return true;
|
||||
}
|
||||
}
|
||||
// SSE payloads exist but none contain output.
|
||||
return false;
|
||||
}
|
||||
|
||||
// Looks like SSE but contains no non-DONE payloads.
|
||||
if (text.includes('data:')) return false;
|
||||
|
||||
// Not JSON and not SSE: assume it's plain text output.
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
export function detectProxyFailure(input: {
|
||||
rawText: string;
|
||||
usage?: UsageSummary | null;
|
||||
// Backward-compatible fields (older call sites)
|
||||
completionTokens?: number;
|
||||
totalTokens?: number;
|
||||
}): FailureResult | null {
|
||||
const rawText = typeof input.rawText === 'string' ? input.rawText : '';
|
||||
const keywords = normalizeKeywords(config.proxyErrorKeywords || []);
|
||||
if (keywords.length > 0) {
|
||||
const normalizedText = rawText.toLowerCase();
|
||||
const matched = keywords.find((keyword) => normalizedText.includes(keyword));
|
||||
if (matched) {
|
||||
return {
|
||||
status: 502,
|
||||
reason: `Upstream response matched failure keyword: ${matched}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (config.proxyEmptyContentFailEnabled) {
|
||||
const completionTokens = toNonNegativeInt(input.usage?.completionTokens ?? input.completionTokens);
|
||||
const hasOutput = detectHasUpstreamOutput(rawText);
|
||||
|
||||
if (!hasOutput && completionTokens <= 0) {
|
||||
return {
|
||||
status: 502,
|
||||
reason: 'Upstream returned empty content',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
import { ensureModelAllowedForDownstreamKey, getDownstreamRoutingPolicy, recordDownstreamCostUsage } from './downstreamPolicy.js';
|
||||
import { composeProxyLogMessage } from './logPathMeta.js';
|
||||
import { executeEndpointFlow, withUpstreamPath, type BuiltEndpointRequest } from './endpointFlow.js';
|
||||
import { detectProxyFailure } from './proxyFailureJudge.js';
|
||||
import { formatUtcSqlDateTime } from '../../services/localTimeService.js';
|
||||
import { resolveProxyLogBilling } from './proxyBilling.js';
|
||||
import { getProxyAuthContext, getProxyResourceOwner } from '../../middleware/auth.js';
|
||||
@@ -424,15 +425,140 @@ export async function responsesProxyRoute(app: FastifyInstance) {
|
||||
const successfulUpstreamPath = endpointResult.upstreamPath;
|
||||
|
||||
if (isStream) {
|
||||
reply.hijack();
|
||||
reply.raw.statusCode = 200;
|
||||
reply.raw.setHeader('Content-Type', 'text/event-stream; charset=utf-8');
|
||||
reply.raw.setHeader('Cache-Control', 'no-cache, no-transform');
|
||||
reply.raw.setHeader('Connection', 'keep-alive');
|
||||
reply.raw.setHeader('X-Accel-Buffering', 'no');
|
||||
const upstreamContentType = (upstream.headers.get('content-type') || '').toLowerCase();
|
||||
const startSseResponse = () => {
|
||||
reply.hijack();
|
||||
reply.raw.statusCode = 200;
|
||||
reply.raw.setHeader('Content-Type', 'text/event-stream; charset=utf-8');
|
||||
reply.raw.setHeader('Cache-Control', 'no-cache, no-transform');
|
||||
reply.raw.setHeader('Connection', 'keep-alive');
|
||||
reply.raw.setHeader('X-Accel-Buffering', 'no');
|
||||
};
|
||||
|
||||
if (!upstreamContentType.includes('text/event-stream')) {
|
||||
const rawText = await upstream.text();
|
||||
let upstreamData: unknown = rawText;
|
||||
try {
|
||||
upstreamData = JSON.parse(rawText);
|
||||
} catch {
|
||||
upstreamData = rawText;
|
||||
}
|
||||
if (String(selected.site.platform || '').trim().toLowerCase() === 'gemini-cli') {
|
||||
upstreamData = unwrapGeminiCliPayload(upstreamData);
|
||||
}
|
||||
|
||||
const parsedUsage = parseProxyUsage(upstreamData);
|
||||
const latency = Date.now() - startTime;
|
||||
const failure = detectProxyFailure({ rawText, usage: parsedUsage });
|
||||
if (failure) {
|
||||
tokenRouter.recordFailure(selected.channel.id);
|
||||
logProxy(
|
||||
selected,
|
||||
requestedModel,
|
||||
'failed',
|
||||
failure.status,
|
||||
latency,
|
||||
failure.reason,
|
||||
retryCount,
|
||||
downstreamPath,
|
||||
parsedUsage.promptTokens,
|
||||
parsedUsage.completionTokens,
|
||||
parsedUsage.totalTokens,
|
||||
0,
|
||||
null,
|
||||
successfulUpstreamPath,
|
||||
clientContext,
|
||||
logDownstreamApiKeyId ? downstreamApiKeyId : null,
|
||||
);
|
||||
|
||||
if (shouldRetryProxyRequest(failure.status, failure.reason) && retryCount < MAX_RETRIES) {
|
||||
retryCount += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
await reportProxyAllFailed({
|
||||
model: requestedModel,
|
||||
reason: failure.reason,
|
||||
});
|
||||
return reply.code(failure.status).send({ error: { message: failure.reason, type: 'upstream_error' } });
|
||||
}
|
||||
|
||||
const normalized = openAiResponsesTransformer.transformFinalResponse(
|
||||
upstreamData,
|
||||
modelName,
|
||||
rawText,
|
||||
);
|
||||
const downstreamData = openAiResponsesTransformer.outbound.serializeFinal({
|
||||
upstreamPayload: upstreamData,
|
||||
normalized,
|
||||
usage: parsedUsage,
|
||||
serializationMode: 'response',
|
||||
});
|
||||
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, downstreamPath,
|
||||
resolvedUsage.promptTokens, resolvedUsage.completionTokens, resolvedUsage.totalTokens, estimatedCost, billingDetails,
|
||||
successfulUpstreamPath,
|
||||
clientContext,
|
||||
logDownstreamApiKeyId ? downstreamApiKeyId : null,
|
||||
);
|
||||
|
||||
const streamPayload = isRecord(downstreamData) ? downstreamData : {
|
||||
id: `resp_${Date.now()}`,
|
||||
object: 'response',
|
||||
status: 'completed',
|
||||
model: modelName,
|
||||
output: [],
|
||||
output_text: '',
|
||||
usage: {
|
||||
input_tokens: parsedUsage.promptTokens,
|
||||
output_tokens: parsedUsage.completionTokens,
|
||||
total_tokens: parsedUsage.totalTokens,
|
||||
},
|
||||
};
|
||||
const createdPayload = {
|
||||
...streamPayload,
|
||||
status: 'in_progress',
|
||||
output: [],
|
||||
output_text: '',
|
||||
};
|
||||
|
||||
startSseResponse();
|
||||
reply.raw.write(`event: response.created\ndata: ${JSON.stringify({ type: 'response.created', response: createdPayload })}\n\n`);
|
||||
reply.raw.write(`event: response.completed\ndata: ${JSON.stringify({ type: 'response.completed', response: streamPayload })}\n\n`);
|
||||
reply.raw.write('data: [DONE]\n\n');
|
||||
reply.raw.end();
|
||||
return;
|
||||
}
|
||||
|
||||
startSseResponse();
|
||||
|
||||
const upstreamReader = upstream.body?.getReader();
|
||||
const reader = String(selected.site.platform || '').trim().toLowerCase() === 'gemini-cli' && upstreamReader
|
||||
const baseReader = String(selected.site.platform || '').trim().toLowerCase() === 'gemini-cli' && upstreamReader
|
||||
? createGeminiCliStreamReader(upstreamReader)
|
||||
: upstreamReader;
|
||||
let parsedUsage: UsageSummary = {
|
||||
@@ -443,6 +569,25 @@ export async function responsesProxyRoute(app: FastifyInstance) {
|
||||
cacheCreationTokens: 0,
|
||||
promptTokensIncludeCache: null,
|
||||
};
|
||||
let rawText = '';
|
||||
const decoder = new TextDecoder();
|
||||
const reader = baseReader
|
||||
? {
|
||||
async read() {
|
||||
const result = await baseReader.read();
|
||||
if (result.value) {
|
||||
rawText += decoder.decode(result.value, { stream: true });
|
||||
}
|
||||
return result;
|
||||
},
|
||||
async cancel(reason?: unknown) {
|
||||
return baseReader.cancel(reason);
|
||||
},
|
||||
releaseLock() {
|
||||
return baseReader.releaseLock();
|
||||
},
|
||||
}
|
||||
: baseReader;
|
||||
const writeLines = (lines: string[]) => {
|
||||
for (const line of lines) reply.raw.write(line);
|
||||
};
|
||||
@@ -464,6 +609,7 @@ export async function responsesProxyRoute(app: FastifyInstance) {
|
||||
},
|
||||
});
|
||||
const streamResult = await streamSession.run(reader, reply.raw);
|
||||
rawText += decoder.decode();
|
||||
|
||||
const latency = Date.now() - startTime;
|
||||
if (streamResult.status === 'failed') {
|
||||
@@ -489,6 +635,11 @@ export async function responsesProxyRoute(app: FastifyInstance) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Once SSE has been hijacked and bytes may already be on the wire, we
|
||||
// must not attempt to convert stream failures into a fresh HTTP error
|
||||
// response or retry on another channel. Responses stream failures are
|
||||
// handled in-band by the proxy stream session.
|
||||
|
||||
const resolvedUsage = await resolveProxyUsageWithSelfLogFallback({
|
||||
site: selected.site,
|
||||
account: selected.account,
|
||||
@@ -544,6 +695,38 @@ export async function responsesProxyRoute(app: FastifyInstance) {
|
||||
}
|
||||
const latency = Date.now() - startTime;
|
||||
const parsedUsage = parseProxyUsage(upstreamData);
|
||||
const failure = detectProxyFailure({ rawText, usage: parsedUsage });
|
||||
if (failure) {
|
||||
tokenRouter.recordFailure(selected.channel.id);
|
||||
logProxy(
|
||||
selected,
|
||||
requestedModel,
|
||||
'failed',
|
||||
failure.status,
|
||||
latency,
|
||||
failure.reason,
|
||||
retryCount,
|
||||
downstreamPath,
|
||||
parsedUsage.promptTokens,
|
||||
parsedUsage.completionTokens,
|
||||
parsedUsage.totalTokens,
|
||||
0,
|
||||
null,
|
||||
successfulUpstreamPath,
|
||||
clientContext,
|
||||
logDownstreamApiKeyId ? downstreamApiKeyId : null,
|
||||
);
|
||||
if (shouldRetryProxyRequest(failure.status, failure.reason) && retryCount < MAX_RETRIES) {
|
||||
retryCount += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
await reportProxyAllFailed({
|
||||
model: requestedModel,
|
||||
reason: failure.reason,
|
||||
});
|
||||
return reply.code(failure.status).send({ error: { message: failure.reason, type: 'upstream_error' } });
|
||||
}
|
||||
const normalized = openAiResponsesTransformer.transformFinalResponse(
|
||||
upstreamData,
|
||||
modelName,
|
||||
|
||||
Reference in New Issue
Block a user