Skip to content

Commit 7f67a14

Browse files
authored
✨ Multi-segment text merging, ultra-long message splitting, xml prompt, tag, session, and token calculation optimization. (#16)
* ✨ Enhance text input handling by appending multiple fragments * 💡 Add guidance for wrapping XML blocks in responses * 💡 Skip tagging for the first message in a new session. * ✨ Implement message splitting for Gemini requests exceeding the character limit * ✨ try to reuse session as much as possible * 💡 fix response token calculation to include the whole history * 💡 Enhance streaming response to include token usage calculation
1 parent 1090efb commit 7f67a14

4 files changed

Lines changed: 156 additions & 39 deletions

File tree

app/server/chat.py

Lines changed: 130 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
GeminiClientWrapper,
2121
LMDBConversationStore,
2222
)
23+
from ..utils import g_config
2324
from ..utils.helper import estimate_tokens
2425
from .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

160237
def _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

203294
def _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

app/services/client.py

Lines changed: 20 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,12 @@ async def process_message(
4343
# TODO: Use Pydantic to enforce the value checking
4444
for item in message.content:
4545
if item.type == "text":
46-
model_input = item.text or ""
46+
# Append multiple text fragments
47+
if item.text:
48+
if model_input:
49+
model_input += "\n" + item.text
50+
else:
51+
model_input = item.text
4752

4853
elif item.type == "image_url":
4954
if not item.image_url:
@@ -66,6 +71,8 @@ async def process_message(
6671
if model_input and tagged:
6772
model_input = add_tag(message.role, model_input)
6873

74+
if "<" in model_input and ">" in model_input:
75+
model_input += "\nFor any xml block, e.g. tool call, always wrap it by: \n`````xml\n...\n`````\n"
6976
return model_input, files
7077

7178
@staticmethod
@@ -76,16 +83,25 @@ async def process_conversation(
7683
Process the entire conversation and return a formatted string and list of
7784
files. The last message is assumed to be the assistant's response.
7885
"""
86+
# Determine once whether we need to wrap messages with role tags: only required
87+
# if the history already contains assistant/system messages. When every message
88+
# so far is from the user, we can skip tagging entirely.
89+
need_tag = any(m.role not in ("user", "system") for m in messages)
90+
7991
conversation: list[str] = []
8092
files: list[Path | str] = []
8193

8294
for msg in messages:
83-
input_part, files_part = await GeminiClientWrapper.process_message(msg, tempdir)
95+
input_part, files_part = await GeminiClientWrapper.process_message(
96+
msg, tempdir, tagged=need_tag
97+
)
8498
conversation.append(input_part)
8599
files.extend(files_part)
86100

87-
# Left with the last message as the assistant's response
88-
conversation.append(add_tag("assistant", "", unclose=True))
101+
# Append an opening assistant tag only when we used tags above so that Gemini
102+
# knows where to start its reply.
103+
if need_tag:
104+
conversation.append(add_tag("assistant", "", unclose=True))
89105

90106
return "\n".join(conversation), files
91107

app/utils/config.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,11 @@ class GeminiConfig(BaseModel):
4444
default=540, ge=1, description="Interval in seconds to refresh Gemini cookies"
4545
)
4646
verbose: bool = Field(False, description="Enable verbose logging for Gemini API requests")
47+
max_chars_per_request: int = Field(
48+
default=1_000_000,
49+
ge=1,
50+
description="Maximum characters Gemini Web can accept per request",
51+
)
4752

4853

4954
class CORSConfig(BaseModel):

config/config.yaml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ gemini:
2121
auto_refresh: true # Auto-refresh session cookies
2222
refresh_interval: 540 # Refresh interval in seconds
2323
verbose: false # Enable verbose logging for Gemini requests
24+
max_chars_per_request: 1000000 # Maximum characters Gemini Web accepts per request. Non-pro users might have a lower limit
2425

2526
storage:
2627
path: "data/lmdb" # Database storage path

0 commit comments

Comments
 (0)