Repository navigation
Expand file tree
/
Copy pathgem.js
More file actions
379 lines (324 loc) · 13.8 KB
/
Copy pathgem.js
File metadata and controls
379 lines (324 loc) · 13.8 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
// Configuration: upstream Google API host and maximum retry attempts
const upstream_url_base = "https://generativelanguage.googleapis.com";
const max_consecutive_failures = 5;
const debug_mode = true;
// Handle CORS preflight requests
const handleOPTIONS = async () =>
new Response(null, {
headers: {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "*",
"Access-Control-Allow-Headers": "*",
},
});
function logDebug(...args) {
if (debug_mode) {
console.log('DEBUG - ', ...args);
}
}
// Relay non-POST requests as-is to upstream
async function relayNonStream(request) {
const originalUrl = new URL(request.url);
const upstreamUrl = `${upstream_url_base}${originalUrl.pathname}${originalUrl.search}`;
logDebug(`Relaying ${request.method} request to: ${upstreamUrl}`);
const upstreamResponse = await fetch(upstreamUrl, {
method: request.method,
headers: request.headers,
body: request.body
});
logDebug(`Upstream ${request.method} response status: ${upstreamResponse.status}`);
// Return response with same headers and body
return new Response(upstreamResponse.body, {
status: upstreamResponse.status,
statusText: upstreamResponse.statusText,
headers: upstreamResponse.headers
});
}
// Parse Server-Sent Events (SSE) stream line by line
async function* sseLineIterator(reader) {
const decoder = new TextDecoder("utf-8");
let buffer = "";
while (true) {
const { value, done } = await reader.read();
if (value) {
buffer += decoder.decode(value, { stream: true });
}
// Split buffer by newlines (support both \n and \r\n)
const lines = buffer.split(/\r?\n/);
buffer = lines.pop();
// Yield each complete line
for (const line of lines) {
if (line.trim()) {
yield line;
}
}
// If stream is done, yield any remaining content
if (done) {
if (buffer.trim()) {
yield buffer;
}
break;
}
}
}
// Extract text content from SSE data line
function extractTextFromSSELine(line) {
if (!line.startsWith('data: ')) return null;
try {
const jsonStr = line.slice(6); // Remove 'data: ' prefix
const data = JSON.parse(jsonStr);
// Navigate through the nested structure to get text
const text = data?.candidates?.[0]?.content?.parts?.[0]?.text;
return text || null;
} catch (e) {
logDebug(`Failed to extract text from SSE line: ${line}, error: ${e.message}`);
return null;
}
}
// Check if the line contains a finishReason and extract it
function checkFinishReason(line) {
if (!line.includes('finishReason')) return null;
// Find the start of JSON object
const firstBraceIndex = line.indexOf('{');
if (firstBraceIndex === -1) return null;
try {
const jsonStr = line.slice(firstBraceIndex);
const data = JSON.parse(jsonStr);
const finishReason = data?.candidates?.[0]?.finishReason || null;
logDebug(`Extracted finishReason: ${finishReason}`);
return finishReason;
} catch (e) {
logDebug(`Failed to parse finishReason from line: ${line}, error: ${e.message}`);
return null;
}
}
// Build a new request body for retry with accumulated text
function buildRetryRequestBody(originalRequestBody, aggText, isReasoningModel) {
// Minimize reasoning token budget for fast retry
// https://ai.google.dev/gemini-api/docs/thinking
if (isReasoningModel) {
originalRequestBody.generationConfig.thinkingConfig = {
...originalRequestBody.generationConfig.thinkingConfig,
thinkingBudget: 128,
includeThoughts: false
};
}
// Deep copy the original request body
const retryRequestBody = JSON.parse(JSON.stringify(originalRequestBody));
logDebug(`Building retry request body with accumulated text length: ${aggText.length}`);
// Ensure contents array exists
if (!retryRequestBody.contents) {
retryRequestBody.contents = [];
}
// If last message is not from user, add model response and user continue prompt
if (retryRequestBody.contents.length > 0 &&
retryRequestBody.contents[retryRequestBody.contents.length - 1].role !== "user") {
retryRequestBody.contents.push({ role: "model", parts: [{ text: "" }]});
retryRequestBody.contents.push({ role: "user", parts: [{ text: "continue" }]});
logDebug(`Added empty model response and continue prompt`);
}
// Add accumulated text as model's previous response
retryRequestBody.contents.push({
role: "model",
parts: [{ text: aggText }]
});
logDebug(`Added accumulated text as model response. Total conversation length: ${retryRequestBody.contents.length}`);
return retryRequestBody;
}
// Main function to relay requests and handle retries on truncation
async function relayAndRetry(originalRequest, writer) {
const encoder = new TextEncoder();
let retryCount = 0;
let AGG_TEXT = ""; // Accumulated text from all attempts
let originalRequestBody;
const geminiVersionMatch = new URL(originalRequest.url).pathname.match(/gemini-(\d+(?:\.\d+)?)/);
const isReasoningModel = geminiVersionMatch && parseFloat(geminiVersionMatch[1]) >= 2.5;
if (isReasoningModel) {
logDebug(`Detected Gemini version >= 2.5, will force includeThoughts to true`);
}
try {
originalRequestBody = await originalRequest.clone().json();
logDebug(`Original request body parsed successfully`);
if (isReasoningModel) {
originalRequestBody.generationConfig.thinkingConfig = {
...originalRequestBody.generationConfig.thinkingConfig,
includeThoughts: true
};
logDebug(`Overrode thinkingConfig.includeThoughts to true`);
}
} catch (e) {
logDebug(`Failed to parse original request body: ${e.message}`);
throw new Error(`Invalid JSON in request body: ${e.message}`);
}
let currentRequestBody = JSON.parse(JSON.stringify(originalRequestBody));
while (retryCount <= max_consecutive_failures) {
let needsRetry = false;
let reader = null;
logDebug(`Starting attempt ${retryCount + 1}/${max_consecutive_failures + 1}`);
try {
// Construct upstream URL with Google API host
const originalUrl = new URL(originalRequest.url);
const upstreamUrl = `${upstream_url_base}${originalUrl.pathname}${originalUrl.search}`;
logDebug(`Making request to upstream: ${upstreamUrl}`);
const reqBody = JSON.stringify(currentRequestBody);
// Make request to upstream Google API - preserve all original request details except body
const upstreamResponse = await fetch(upstreamUrl, {
method: originalRequest.method,
headers: originalRequest.headers,
body: reqBody
});
logDebug(`Request Body: ${reqBody}`);
logDebug(`Upstream response status: ${upstreamResponse.status} ${upstreamResponse.statusText}`);
if (!upstreamResponse.ok) {
throw new Error(`Upstream error: ${upstreamResponse.status} ${upstreamResponse.statusText}`);
}
reader = upstreamResponse.body.getReader();
let textReceivedInThisAttempt = false;
let isBlocked = false;
// Process SSE stream line by line
for await (const line of sseLineIterator(reader)) {
logDebug(`Processing SSE line: ${line.substring(0, 100)}${line.length > 100 ? '...' : ''}`);
const finishReason = checkFinishReason(line);
isBlocked = line.includes('blockReason');
if (finishReason !== null) {
logDebug(`Received finishReason: ${finishReason}`);
// Check if response was truncated (not a normal completion)
if (finishReason && finishReason !== "STOP" && finishReason !== "MAX_TOKENS") {
logDebug(`[Attempt ${retryCount + 1}] Detected abnormal finish reason: ${finishReason}, preparing to retry...`);
needsRetry = true;
break; // Exit loop to retry
} else {
// Normal completion - forward line and close stream
await writer.write(encoder.encode(line + '\n\n'));
await writer.close();
logDebug(`[Attempt ${retryCount + 1}] Request finished successfully. Finish reason: ${finishReason || "STOP"}`);
return; // Success - exit function
}
} else {
// Regular content line - extract text and forward
const text = extractTextFromSSELine(line);
if (text) {
AGG_TEXT += text; // Accumulate text
textReceivedInThisAttempt = true;
logDebug(`Extracted text chunk of length: ${text.length}, total accumulated: ${AGG_TEXT.length}`);
}
if (!isBlocked) {
await writer.write(encoder.encode(line + '\n\n'));
logDebug(`Forwarded SSE line to client`);
} else {
logDebug(`Blocked line detected, not forwarding: ${line}`);
}
}
}
// Reset retry count if we received text in this attempt
if (textReceivedInThisAttempt) {
retryCount = 0;
logDebug(`Reset retry count due to successful text reception`);
}
// If stream ended without finishReason, treat as truncation
if (!needsRetry && isBlocked) {
logDebug(`[Attempt ${retryCount + 1}] Stream blocked, preparing to retry...`);
needsRetry = true;
}
} catch (error) {
logDebug(`[Attempt ${retryCount + 1}] Fetch or processing error: ${error.message}`);
console.error(`[Attempt ${retryCount + 1}] Fetch or processing error:`, error);
needsRetry = true;
} finally {
// Clean up reader to prevent resource leaks
if (reader) {
try {
await reader.cancel();
logDebug(`Reader cancelled successfully`);
} catch (e) {
logDebug(`Failed to cancel reader: ${e.message}`);
}
}
}
if (needsRetry) {
retryCount++;
if (retryCount > max_consecutive_failures) {
// Max retries exceeded - send error and close
logDebug(`Max retries (${max_consecutive_failures}) exceeded. Closing stream.`);
console.error("Max retries exceeded. Closing stream.");
const errorPayload = { error: { message: "Max retries exceeded after multiple upstream failures." } };
await writer.write(encoder.encode('data: ' + JSON.stringify(errorPayload) + '\n\n'));
await writer.close();
return;
}
// Build new request body with accumulated text for retry
currentRequestBody = buildRetryRequestBody(originalRequestBody, AGG_TEXT, isReasoningModel);
logDebug(`[Attempt ${retryCount + 1}] Prepared retry with accumulated text length: ${AGG_TEXT.length}`);
} else {
// Success - exit loop
logDebug(`Request completed successfully without retry`);
break;
}
}
}
// Main request handler
async function handleRequest(request, env) {
logDebug(`Received ${request.method} request to ${request.url}`);
// Handle CORS preflight
if (request.method === "OPTIONS") {
logDebug(`Handling OPTIONS request`);
return handleOPTIONS();
}
const { pathname, searchParams } = new URL(request.url, 'http://_');
const isStream = /stream/i.test(pathname) || searchParams.get('alt')?.toLowerCase() === 'sse';
// For non-POST requests, relay as-is
if (request.method !== "POST" || !isStream) {
logDebug(`Relaying non-stream request: ${request.method}`);
try {
return await relayNonStream(request);
} catch (error) {
logDebug(`Failed to relay non-stream request: ${error.message}`);
console.error('Non-POST relay error:', error);
return new Response(JSON.stringify({ error: "Failed to relay request", details: error.message }),
{ status: 502, headers: { "Content-Type": "application/json" } });
}
}
// Handle POST requests with retry logic
try {
logDebug(`Processing POST request for streaming response`);
// Create a streaming response
const { readable, writable } = new TransformStream();
const writer = writable.getWriter();
// Start relay and retry process asynchronously
// Don't await - let it run in background while we return the stream
relayAndRetry(request, writer).catch(error => {
logDebug(`relayAndRetry failed: ${error.message}`);
console.error('Relay and retry error:', error);
// Try to send error to client if stream is still writable
const encoder = new TextEncoder();
const errorPayload = { error: { message: "Request processing failed", details: error.message } };
writer.write(encoder.encode('data: ' + JSON.stringify(errorPayload) + '\n\n')).catch(() => {});
writer.close().catch(() => {});
});
// Return SSE stream response
logDebug(`Returning streaming response`);
return new Response(readable, {
headers: {
'Content-Type': 'text/event-stream; charset=utf-8',
'Cache-Control': 'no-cache',
'Connection': 'keep-alive',
'Access-Control-Allow-Origin': '*',
}
});
} catch (error) {
logDebug(`Request handling error: ${error.message}`);
console.error('Request handling error:', error);
return new Response(JSON.stringify({ error: "Request processing failed", details: error.message }),
{ status: 400, headers: { "Content-Type": "application/json" } });
}
}
// Export for Cloudflare Workers
export default { fetch: handleRequest };
// Export for Cloudflare Pages Functions
export const onRequest = (context) => { return handleRequest(context.request, context.env); };
// Deno runtime support for local development
if (typeof Deno !== "undefined") {
const port = 8000;
logDebug(`Gemini Elastic Middleware listening on http://localhost:${port}`);
Deno.serve({ port }, handleRequest);
}