1
0
forked from boos/BoosAPI

fix: bypass multimodal endpoint memory for gemini

This commit is contained in:
Cita
2026-03-21 18:37:36 +08:00
parent 6a19d809f0
commit 500ba25787
4 changed files with 202 additions and 18 deletions
@@ -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';
+75
View File
@@ -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,
+34 -13
View File
@@ -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,