fix: bypass multimodal endpoint memory for gemini
This commit is contained in:
@@ -367,6 +367,11 @@ export async function geminiProxyRoute(app: FastifyInstance) {
|
|||||||
const isCountTokensAction = isGeminiCliDownstream
|
const isCountTokensAction = isGeminiCliDownstream
|
||||||
? options?.action === 'countTokens'
|
? options?.action === 'countTokens'
|
||||||
: modelActionPath.endsWith(':countTokens');
|
: modelActionPath.endsWith(':countTokens');
|
||||||
|
const rawUrl = request.raw.url || request.url || '';
|
||||||
|
const wantsSseEnvelope = (
|
||||||
|
isStreamAction
|
||||||
|
&& (isGeminiCliDownstream || /(?:^|[?&])alt=sse(?:&|$)/i.test(rawUrl))
|
||||||
|
);
|
||||||
if (!requestedModel) {
|
if (!requestedModel) {
|
||||||
return reply.code(400).send({
|
return reply.code(400).send({
|
||||||
error: { message: 'Gemini model path is required', type: 'invalid_request_error' },
|
error: { message: 'Gemini model path is required', type: 'invalid_request_error' },
|
||||||
@@ -857,11 +862,18 @@ export async function geminiProxyRoute(app: FastifyInstance) {
|
|||||||
parsedUsage.completionTokens,
|
parsedUsage.completionTokens,
|
||||||
parsedUsage.totalTokens,
|
parsedUsage.totalTokens,
|
||||||
);
|
);
|
||||||
return reply.code(upstream.status).send(
|
const downstreamPayload = isGeminiCliDownstream
|
||||||
isGeminiCliDownstream
|
? { response: geminiResponse }
|
||||||
? { response: geminiResponse }
|
: geminiResponse;
|
||||||
: geminiResponse,
|
if (wantsSseEnvelope) {
|
||||||
);
|
// Some compatibility upstreams finish stream requests with a final JSON payload.
|
||||||
|
// Preserve Gemini streaming UX by wrapping that terminal payload back into one SSE event.
|
||||||
|
return reply
|
||||||
|
.code(upstream.status)
|
||||||
|
.type('text/event-stream; charset=utf-8')
|
||||||
|
.send(geminiGenerateContentTransformer.stream.serializeSsePayload(downstreamPayload));
|
||||||
|
}
|
||||||
|
return reply.code(upstream.status).send(downstreamPayload);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
lastStatus = 502;
|
lastStatus = 502;
|
||||||
lastContentType = 'application/json';
|
lastContentType = 'application/json';
|
||||||
|
|||||||
@@ -602,6 +602,81 @@ describe('gemini native proxy routes', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('serializes non-streaming generic upstream JSON into Gemini SSE when alt=sse is requested', async () => {
|
||||||
|
selectChannelMock.mockReturnValue({
|
||||||
|
channel: { id: 41, routeId: 22 },
|
||||||
|
site: { id: 77, name: 'openai-site', url: 'https://api.openai.com', platform: 'openai' },
|
||||||
|
account: { id: 37, username: 'openai-user@example.com' },
|
||||||
|
tokenName: 'default',
|
||||||
|
tokenValue: 'openai-access-token',
|
||||||
|
actualModel: 'gpt-4.1',
|
||||||
|
});
|
||||||
|
fetchMock.mockResolvedValue(new Response(JSON.stringify({
|
||||||
|
id: 'chatcmpl-openai-stream-1',
|
||||||
|
object: 'chat.completion',
|
||||||
|
created: 1_742_160_003,
|
||||||
|
model: 'gpt-4.1',
|
||||||
|
choices: [
|
||||||
|
{
|
||||||
|
index: 0,
|
||||||
|
message: {
|
||||||
|
role: 'assistant',
|
||||||
|
content: 'hello from openai stream fallback',
|
||||||
|
},
|
||||||
|
finish_reason: 'stop',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
usage: {
|
||||||
|
prompt_tokens: 5,
|
||||||
|
completion_tokens: 6,
|
||||||
|
total_tokens: 11,
|
||||||
|
},
|
||||||
|
}), {
|
||||||
|
status: 200,
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
}));
|
||||||
|
|
||||||
|
const response = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/v1beta/models/gemini-2.5-flash:streamGenerateContent?alt=sse',
|
||||||
|
headers: {
|
||||||
|
authorization: 'Bearer sk-managed-gemini',
|
||||||
|
},
|
||||||
|
payload: {
|
||||||
|
contents: [
|
||||||
|
{
|
||||||
|
role: 'user',
|
||||||
|
parts: [{ text: 'hello' }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.statusCode).toBe(200);
|
||||||
|
expect(response.headers['content-type']).toContain('text/event-stream');
|
||||||
|
expect(parseSsePayloads(response.body)).toEqual([
|
||||||
|
{
|
||||||
|
responseId: 'chatcmpl-openai-stream-1',
|
||||||
|
modelVersion: 'gpt-4.1',
|
||||||
|
candidates: [
|
||||||
|
{
|
||||||
|
index: 0,
|
||||||
|
content: {
|
||||||
|
role: 'model',
|
||||||
|
parts: [{ text: 'hello from openai stream fallback' }],
|
||||||
|
},
|
||||||
|
finishReason: 'STOP',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
usageMetadata: {
|
||||||
|
promptTokenCount: 5,
|
||||||
|
candidatesTokenCount: 6,
|
||||||
|
totalTokenCount: 11,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
it('exposes GeminiCLI downstream generateContent endpoint and wraps the downstream response payload', async () => {
|
it('exposes GeminiCLI downstream generateContent endpoint and wraps the downstream response payload', async () => {
|
||||||
selectChannelMock.mockReturnValue({
|
selectChannelMock.mockReturnValue({
|
||||||
channel: { id: 42, routeId: 22 },
|
channel: { id: 42, routeId: 22 },
|
||||||
|
|||||||
@@ -196,6 +196,82 @@ describe('resolveUpstreamEndpointCandidates', () => {
|
|||||||
expect(order).toEqual(['responses', 'messages', 'chat']);
|
expect(order).toEqual(['responses', 'messages', 'chat']);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('does not apply runtime endpoint memory to image attachments', async () => {
|
||||||
|
recordUpstreamEndpointSuccess({
|
||||||
|
siteId: baseContext.site.id,
|
||||||
|
endpoint: 'responses',
|
||||||
|
downstreamFormat: 'openai',
|
||||||
|
modelName: 'gpt-5.3',
|
||||||
|
requestCapabilities: {
|
||||||
|
conversationFileSummary: {
|
||||||
|
hasImage: true,
|
||||||
|
hasAudio: false,
|
||||||
|
hasDocument: false,
|
||||||
|
hasRemoteDocumentUrl: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const order = await resolveUpstreamEndpointCandidates(
|
||||||
|
{
|
||||||
|
...baseContext,
|
||||||
|
site: { ...baseContext.site, platform: 'new-api' },
|
||||||
|
},
|
||||||
|
'gpt-5.3',
|
||||||
|
'openai',
|
||||||
|
undefined,
|
||||||
|
{
|
||||||
|
conversationFileSummary: {
|
||||||
|
hasImage: true,
|
||||||
|
hasAudio: false,
|
||||||
|
hasDocument: false,
|
||||||
|
hasRemoteDocumentUrl: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(order).toEqual(['chat', 'messages', 'responses']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not apply runtime endpoint memory to document attachments', async () => {
|
||||||
|
recordUpstreamEndpointSuccess({
|
||||||
|
siteId: baseContext.site.id,
|
||||||
|
endpoint: 'messages',
|
||||||
|
downstreamFormat: 'openai',
|
||||||
|
modelName: 'gpt-5.3',
|
||||||
|
requestCapabilities: {
|
||||||
|
hasNonImageFileInput: true,
|
||||||
|
conversationFileSummary: {
|
||||||
|
hasImage: false,
|
||||||
|
hasAudio: false,
|
||||||
|
hasDocument: true,
|
||||||
|
hasRemoteDocumentUrl: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const order = await resolveUpstreamEndpointCandidates(
|
||||||
|
{
|
||||||
|
...baseContext,
|
||||||
|
site: { ...baseContext.site, platform: 'new-api' },
|
||||||
|
},
|
||||||
|
'gpt-5.3',
|
||||||
|
'openai',
|
||||||
|
undefined,
|
||||||
|
{
|
||||||
|
hasNonImageFileInput: true,
|
||||||
|
conversationFileSummary: {
|
||||||
|
hasImage: false,
|
||||||
|
hasAudio: false,
|
||||||
|
hasDocument: true,
|
||||||
|
hasRemoteDocumentUrl: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(order).toEqual(['responses', 'messages', 'chat']);
|
||||||
|
});
|
||||||
|
|
||||||
it('remembers the last successful endpoint per site capability profile', async () => {
|
it('remembers the last successful endpoint per site capability profile', async () => {
|
||||||
recordUpstreamEndpointSuccess({
|
recordUpstreamEndpointSuccess({
|
||||||
siteId: baseContext.site.id,
|
siteId: baseContext.site.id,
|
||||||
|
|||||||
@@ -41,6 +41,8 @@ export type EndpointPreference = DownstreamFormat | 'responses';
|
|||||||
|
|
||||||
type EndpointCapabilityProfile = {
|
type EndpointCapabilityProfile = {
|
||||||
preferMessagesForClaudeModel: boolean;
|
preferMessagesForClaudeModel: boolean;
|
||||||
|
hasImageInput: boolean;
|
||||||
|
hasAudioInput: boolean;
|
||||||
hasNonImageFileInput: boolean;
|
hasNonImageFileInput: boolean;
|
||||||
hasRemoteDocumentUrl: boolean;
|
hasRemoteDocumentUrl: boolean;
|
||||||
wantsNativeResponsesReasoning: boolean;
|
wantsNativeResponsesReasoning: boolean;
|
||||||
@@ -711,22 +713,35 @@ function buildEndpointCapabilityProfile(input?: {
|
|||||||
wantsNativeResponsesReasoning?: boolean;
|
wantsNativeResponsesReasoning?: boolean;
|
||||||
};
|
};
|
||||||
}): EndpointCapabilityProfile {
|
}): EndpointCapabilityProfile {
|
||||||
|
const conversationFileSummary = input?.requestCapabilities?.conversationFileSummary;
|
||||||
return {
|
return {
|
||||||
preferMessagesForClaudeModel: (
|
preferMessagesForClaudeModel: (
|
||||||
isClaudeFamilyModel(asTrimmedString(input?.modelName))
|
isClaudeFamilyModel(asTrimmedString(input?.modelName))
|
||||||
|| isClaudeFamilyModel(asTrimmedString(input?.requestedModelHint))
|
|| isClaudeFamilyModel(asTrimmedString(input?.requestedModelHint))
|
||||||
),
|
),
|
||||||
|
hasImageInput: conversationFileSummary?.hasImage === true,
|
||||||
|
hasAudioInput: conversationFileSummary?.hasAudio === true,
|
||||||
hasNonImageFileInput: (
|
hasNonImageFileInput: (
|
||||||
input?.requestCapabilities?.conversationFileSummary?.hasDocument === true
|
conversationFileSummary?.hasDocument === true
|
||||||
|| input?.requestCapabilities?.hasNonImageFileInput === true
|
|| input?.requestCapabilities?.hasNonImageFileInput === true
|
||||||
),
|
),
|
||||||
hasRemoteDocumentUrl: (
|
hasRemoteDocumentUrl: (
|
||||||
input?.requestCapabilities?.conversationFileSummary?.hasRemoteDocumentUrl === true
|
conversationFileSummary?.hasRemoteDocumentUrl === true
|
||||||
),
|
),
|
||||||
wantsNativeResponsesReasoning: input?.requestCapabilities?.wantsNativeResponsesReasoning === true,
|
wantsNativeResponsesReasoning: input?.requestCapabilities?.wantsNativeResponsesReasoning === true,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function shouldUseEndpointRuntimeMemory(capabilityProfile: EndpointCapabilityProfile): boolean {
|
||||||
|
// Attachment-capable requests are not protocol-equivalent across chat/messages/responses.
|
||||||
|
// A transient 200 on one endpoint should not bias later multimodal requests onto a lossy path.
|
||||||
|
return (
|
||||||
|
!capabilityProfile.hasImageInput
|
||||||
|
&& !capabilityProfile.hasAudioInput
|
||||||
|
&& !capabilityProfile.hasNonImageFileInput
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function buildEndpointRuntimeStateKey(input: {
|
function buildEndpointRuntimeStateKey(input: {
|
||||||
siteId: number;
|
siteId: number;
|
||||||
downstreamFormat: EndpointPreference;
|
downstreamFormat: EndpointPreference;
|
||||||
@@ -866,17 +881,19 @@ export function recordUpstreamEndpointSuccess(input: {
|
|||||||
wantsNativeResponsesReasoning?: boolean;
|
wantsNativeResponsesReasoning?: boolean;
|
||||||
};
|
};
|
||||||
}): void {
|
}): void {
|
||||||
|
const capabilityProfile = buildEndpointCapabilityProfile({
|
||||||
|
modelName: input.modelName,
|
||||||
|
requestedModelHint: input.requestedModelHint,
|
||||||
|
requestCapabilities: input.requestCapabilities,
|
||||||
|
});
|
||||||
|
if (!shouldUseEndpointRuntimeMemory(capabilityProfile)) return;
|
||||||
if (!shouldRememberSuccessfulEndpoint(input)) return;
|
if (!shouldRememberSuccessfulEndpoint(input)) return;
|
||||||
|
|
||||||
const nowMs = Date.now();
|
const nowMs = Date.now();
|
||||||
const key = buildEndpointRuntimeStateKey({
|
const key = buildEndpointRuntimeStateKey({
|
||||||
siteId: input.siteId,
|
siteId: input.siteId,
|
||||||
downstreamFormat: input.downstreamFormat,
|
downstreamFormat: input.downstreamFormat,
|
||||||
capabilityProfile: buildEndpointCapabilityProfile({
|
capabilityProfile,
|
||||||
modelName: input.modelName,
|
|
||||||
requestedModelHint: input.requestedModelHint,
|
|
||||||
requestCapabilities: input.requestCapabilities,
|
|
||||||
}),
|
|
||||||
});
|
});
|
||||||
const state = getOrCreateEndpointRuntimeState(key, nowMs);
|
const state = getOrCreateEndpointRuntimeState(key, nowMs);
|
||||||
state.preferredEndpoint = input.endpoint;
|
state.preferredEndpoint = input.endpoint;
|
||||||
@@ -898,17 +915,19 @@ export function recordUpstreamEndpointFailure(input: {
|
|||||||
wantsNativeResponsesReasoning?: boolean;
|
wantsNativeResponsesReasoning?: boolean;
|
||||||
};
|
};
|
||||||
}): void {
|
}): void {
|
||||||
|
const capabilityProfile = buildEndpointCapabilityProfile({
|
||||||
|
modelName: input.modelName,
|
||||||
|
requestedModelHint: input.requestedModelHint,
|
||||||
|
requestCapabilities: input.requestCapabilities,
|
||||||
|
});
|
||||||
|
if (!shouldUseEndpointRuntimeMemory(capabilityProfile)) return;
|
||||||
if (!shouldBlockEndpointByError(input.status, input.errorText)) return;
|
if (!shouldBlockEndpointByError(input.status, input.errorText)) return;
|
||||||
|
|
||||||
const nowMs = Date.now();
|
const nowMs = Date.now();
|
||||||
const key = buildEndpointRuntimeStateKey({
|
const key = buildEndpointRuntimeStateKey({
|
||||||
siteId: input.siteId,
|
siteId: input.siteId,
|
||||||
downstreamFormat: input.downstreamFormat,
|
downstreamFormat: input.downstreamFormat,
|
||||||
capabilityProfile: buildEndpointCapabilityProfile({
|
capabilityProfile,
|
||||||
modelName: input.modelName,
|
|
||||||
requestedModelHint: input.requestedModelHint,
|
|
||||||
requestCapabilities: input.requestCapabilities,
|
|
||||||
}),
|
|
||||||
});
|
});
|
||||||
const state = getOrCreateEndpointRuntimeState(key, nowMs);
|
const state = getOrCreateEndpointRuntimeState(key, nowMs);
|
||||||
state.blockedUntilMsByEndpoint[input.endpoint] = nowMs + ENDPOINT_RUNTIME_BLOCK_TTL_MS;
|
state.blockedUntilMsByEndpoint[input.endpoint] = nowMs + ENDPOINT_RUNTIME_BLOCK_TTL_MS;
|
||||||
@@ -1009,7 +1028,9 @@ export async function resolveUpstreamEndpointCandidates(
|
|||||||
capabilityProfile,
|
capabilityProfile,
|
||||||
});
|
});
|
||||||
const applyRuntimePreference = (candidates: UpstreamEndpoint[]) => (
|
const applyRuntimePreference = (candidates: UpstreamEndpoint[]) => (
|
||||||
applyEndpointRuntimePreference(candidates, runtimeStateKey)
|
shouldUseEndpointRuntimeMemory(capabilityProfile)
|
||||||
|
? applyEndpointRuntimePreference(candidates, runtimeStateKey)
|
||||||
|
: candidates
|
||||||
);
|
);
|
||||||
const conversationFileSummary = requestCapabilities?.conversationFileSummary ?? {
|
const conversationFileSummary = requestCapabilities?.conversationFileSummary ?? {
|
||||||
hasImage: false,
|
hasImage: false,
|
||||||
|
|||||||
Reference in New Issue
Block a user