2020 GeminiClientWrapper ,
2121 LMDBConversationStore ,
2222)
23+ from ..utils import g_config
2324from ..utils .helper import estimate_tokens
2425from .middleware import get_temp_dir , verify_api_key
2526
@@ -64,24 +65,23 @@ async def create_chat_completion(
6465 )
6566
6667 # Check if conversation is reusable
67- session = None
68- client = None
69- if _check_reusable (request .messages ):
70- try :
71- # Exclude the last message from user
72- if old_conv := db .find (model .model_name , request .messages [:- 1 ]):
73- client = pool .acquire (old_conv .client_id )
74- session = client .start_chat (metadata = old_conv .metadata , model = model )
75- except Exception as e :
76- session = None
77- logger .warning (f"Error checking LMDB for reusable session: { e } " )
68+ session , client , remaining_messages = _find_reusable_session (
69+ db , pool , model , request .messages
70+ )
7871
7972 if session :
80- # Just send the last message to the existing session
81- model_input , files = await GeminiClientWrapper .process_message (
82- request .messages [- 1 ], tmp_dir , tagged = False
73+ # Prepare the model input depending on how many turns are missing.
74+ if len (remaining_messages ) == 1 :
75+ model_input , files = await GeminiClientWrapper .process_message (
76+ remaining_messages [0 ], tmp_dir , tagged = False
77+ )
78+ else :
79+ model_input , files = await GeminiClientWrapper .process_conversation (
80+ remaining_messages , tmp_dir
81+ )
82+ logger .debug (
83+ f"Reused session { session .metadata } – sending { len (remaining_messages )} new messages."
8384 )
84- logger .debug (f"Found reusable session: { session .metadata } " )
8585 else :
8686 # Start a new session and concat messages into a single string
8787 try :
@@ -97,13 +97,53 @@ async def create_chat_completion(
9797 raise
9898 logger .debug ("New session started." )
9999
100+ # Maximum characters Gemini Web can accept in a single request (configurable)
101+ MAX_CHARS_PER_REQUEST = int (g_config .gemini .max_chars_per_request * 0.9 )
102+
103+ async def _send_with_split (session , text : str , files : list [Path | str ] | None = None ):
104+ """Send text to Gemini, automatically splitting into multiple batches if it is
105+ longer than ``MAX_CHARS_PER_REQUEST``.
106+
107+ Every intermediate batch (that is **not** the last one) is suffixed with a hint
108+ telling Gemini that more content will come, and it should simply reply with
109+ "ok". The final batch carries any file uploads and the real user prompt so
110+ that Gemini can produce the actual answer.
111+ """
112+ if len (text ) <= MAX_CHARS_PER_REQUEST :
113+ # No need to split – a single request is fine.
114+ return await session .send_message (text , files = files )
115+
116+ chunks : list [str ] = []
117+ pos = 0
118+ total = len (text )
119+ while pos < total :
120+ end = min (pos + MAX_CHARS_PER_REQUEST , total )
121+ chunk = text [pos :end ]
122+ pos = end
123+
124+ # If this is NOT the last chunk, add the continuation hint.
125+ if end < total :
126+ chunk += "\n (More messages to come, please reply with just 'ok'.)"
127+ chunks .append (chunk )
128+
129+ # Fire off all but the last chunk, discarding the interim "ok" replies.
130+ for chk in chunks [:- 1 ]:
131+ try :
132+ await session .send_message (chk )
133+ except Exception as e :
134+ logger .exception (f"Error sending chunk to Gemini: { e } " )
135+ raise
136+
137+ # The last chunk carries the files (if any) and we return its response.
138+ return await session .send_message (chunks [- 1 ], files = files )
139+
100140 # Generate response
101141 try :
102142 assert session and client , "Session and client not available"
103143 logger .debug (
104144 f"Client ID: { client .id } , Input length: { len (model_input )} , files count: { len (files )} "
105145 )
106- response = await session . send_message ( model_input , files = files )
146+ response = await _send_with_split ( session , model_input , files = files )
107147 except Exception as e :
108148 logger .exception (f"Error generating content from Gemini API: { e } " )
109149 raise
@@ -132,35 +172,81 @@ async def create_chat_completion(
132172 completion_id = f"chatcmpl-{ uuid .uuid4 ()} "
133173 timestamp = int (datetime .now (tz = timezone .utc ).timestamp ())
134174 if request .stream :
135- return _create_streaming_response (model_output , completion_id , timestamp , request .model )
175+ return _create_streaming_response (
176+ model_output ,
177+ completion_id ,
178+ timestamp ,
179+ request .model ,
180+ request .messages ,
181+ )
136182 else :
137183 return _create_standard_response (
138- model_output , completion_id , timestamp , request .model , model_input
184+ model_output , completion_id , timestamp , request .model , request . messages
139185 )
140186
141-
142- def _check_reusable (messages : list [Message ]) -> bool :
143- """
144- Check if the conversation is reusable based on the message history.
187+ # --- Helper to find reusable session with partial history ---
188+ def _find_reusable_session (
189+ db : LMDBConversationStore ,
190+ pool : GeminiClientPool ,
191+ model : Model ,
192+ messages : list [Message ],
193+ ):
194+ """Find an existing chat session that matches the *longest* prefix of
195+ ``messages`` **whose last element is an assistant/system reply**.
196+
197+ Rationale
198+ ---------
199+ When a reply was generated by *another* server instance, the local LMDB may
200+ only contain an older part of the conversation. However, as long as we can
201+ line-up **any** earlier assistant/system response, we can restore the
202+ corresponding Gemini session and replay the *remaining* turns locally
203+ (including that missing assistant reply and the subsequent user prompts).
204+
205+ The algorithm therefore walks backwards through the history **one message at
206+ a time**, each time requiring the current tail to be assistant/system before
207+ querying LMDB. As soon as a match is found we recreate the session and
208+ return the untouched suffix as ``remaining_messages``.
145209 """
146- if not messages or len (messages ) < 2 :
147- return False
148210
149- # Last message must from the user
150- if messages [- 1 ].role != "user" or not messages [- 1 ].content :
151- return False
211+ if len (messages ) < 2 :
212+ return None , None , messages
213+
214+ # Start with the full history and iteratively trim from the end.
215+ search_end = len (messages )
216+ while search_end >= 2 :
217+ search_history = messages [:search_end ]
218+
219+ # Only try to match if the last stored message would be assistant/system.
220+ if search_history [- 1 ].role in {"assistant" , "system" }:
221+ try :
222+ if conv := db .find (model .model_name , search_history ):
223+ client = pool .acquire (conv .client_id )
224+ session = client .start_chat (metadata = conv .metadata , model = model )
225+ remain = messages [search_end :]
226+ return session , client , remain
227+ except Exception as e :
228+ logger .warning (f"Error checking LMDB for reusable session: { e } " )
229+ break
152230
153- # The second last message must be from the assistant or system
154- if messages [- 2 ].role not in ["assistant" , "system" ]:
155- return False
231+ # Trim one message and try again.
232+ search_end -= 1
156233
157- return True
234+ return None , None , messages
158235
159236
160237def _create_streaming_response (
161- model_output : str , completion_id : str , created_time : int , model : str
238+ model_output : str ,
239+ completion_id : str ,
240+ created_time : int ,
241+ model : str ,
242+ messages : list [Message ],
162243) -> StreamingResponse :
163- """Create streaming response"""
244+ """Create streaming response with `usage` calculation included in the final chunk."""
245+
246+ # Calculate token usage
247+ prompt_tokens = sum (estimate_tokens (msg .content ) for msg in messages )
248+ completion_tokens = estimate_tokens (model_output )
249+ total_tokens = prompt_tokens + completion_tokens
164250
165251 async def generate_stream ():
166252 # Send start event
@@ -193,6 +279,11 @@ async def generate_stream():
193279 "created" : created_time ,
194280 "model" : model ,
195281 "choices" : [{"index" : 0 , "delta" : {}, "finish_reason" : "stop" }],
282+ "usage" : {
283+ "prompt_tokens" : prompt_tokens ,
284+ "completion_tokens" : completion_tokens ,
285+ "total_tokens" : total_tokens ,
286+ },
196287 }
197288 yield f"data: { orjson .dumps (data ).decode ('utf-8' )} \n \n "
198289 yield "data: [DONE]\n \n "
@@ -201,11 +292,15 @@ async def generate_stream():
201292
202293
203294def _create_standard_response (
204- model_output : str , completion_id : str , created_time : int , model : str , model_input : str
295+ model_output : str ,
296+ completion_id : str ,
297+ created_time : int ,
298+ model : str ,
299+ messages : list [Message ],
205300) -> dict :
206301 """Create standard response"""
207302 # Calculate token usage
208- prompt_tokens = estimate_tokens (model_input )
303+ prompt_tokens = sum ( estimate_tokens (msg . content ) for msg in messages )
209304 completion_tokens = estimate_tokens (model_output )
210305 total_tokens = prompt_tokens + completion_tokens
211306
0 commit comments