L瓢u Quang V农 commited on
Commit
f8272eb
unverified
1 Parent(s): a583ded

refactor: Optimize performance by reducing token usage and speed up model response time. (#89)

Browse files

* Remove the unused auto-refresh functionality and related imports.

They are no longer needed since the underlying library issue has been resolved.

* Enhance error handling in client initialization and message sending

* Refactor link handling to extract file paths and simplify Google search links

* Fix regex pattern for Google search link matching

* Fix regex patterns for Markdown escaping, code fence and Google search link matching

* Increase timeout value in configuration files from 60 to 120 seconds to better handle heavy tasks

* Fix Image generation

* Refactor tool handling to support standard and image generation tools separately

* Fix: use "ascii" decoding for base64-encoded image data consistency

* Fix: replace `running` with `_running` for internal client status checks

* Refactor: replace direct `_running` access with `running()` method in client status checks

* Extend models with new fields for annotations, reasoning, audio, log probabilities, and token details; adjust response handling accordingly.

* Extend models with new fields (annotations, error), add `normalize_output_text` validator, rename `created` to `created_at`, and update response handling accordingly.

* Extend response models to support tool choices, image output, and improved streaming of response items. Refactor image generation handling for consistency and add compatibility with output content.

* Set default `text` value to an empty string for `ResponseOutputContent` and ensure consistent initialization in image output handling.

* feat: Add /images endpoint with dedicated router and improved image management

Add dedicated router for /images endpoint and refactor image handling logic for better modularity. Enhance temporary image management with secure naming, token verification, and cleanup functionality.

* feat: Add token-based verification for image access

* Refactor: rename image store directory to `ai_generated_images` for clarity

* fix: Update create_response to use FastAPI Request object for base_url and refactor variable handling

* fix: Correct attribute access in request_data handling within `chat.py` for tools, tool_choice, and streaming settings

* fix: Save generated images to persistent storage

* fix: Remove unused `output_image` type from `ResponseOutputContent` and update response handling for consistency

* fix: Update image URL generation in chat response to use Markdown format for compatibility

* fix: Enhance error handling for full-size image saving and add fallback to default size

* fix: Use filename as image ID to ensure consistency in generated image handling

* fix: Enhance tempfile saving by adding custom headers, content-type handling, and improved extension determination

* feat: Add support for custom Gemini models and model loading strategies

- Introduced `model_strategy` configuration for "append" (default + custom models) or "overwrite" (custom models only).
- Enhanced `/v1/models` endpoint to return models based on the configured strategy.
- Improved model loading with environment variable overrides and validation.
- Refactored model handling logic for improved modularity and error handling.

* feat: Improve Gemini model environment variable parsing and nested field support

- Enhanced `extract_gemini_models_env` to handle nested fields within environment variables.
- Updated type hints for more flexibility in model overrides.
- Improved `_merge_models_with_env` to better support field-level updates and appending new models.

* refactor: Consolidate utility functions and clean up unused code

- Moved utility functions like `strip_code_fence`, `extract_tool_calls`, and `iter_stream_segments` to a centralized helper module.
- Removed unused and redundant private methods from `chat.py`, including `_strip_code_fence`, `_strip_tagged_blocks`, and `_strip_system_hints`.
- Updated imports and references across modules for consistency.
- Simplified tool call and streaming logic by replacing inline implementations with shared helper functions.

* fix: Handle None input in `estimate_tokens` and return 0 for empty text

* refactor: Simplify model configuration and add JSON parsing validators

- Replaced unused model placeholder in `config.yaml` with an empty list.
- Added JSON parsing validators for `model_header` and `models` to enhance flexibility and error handling.
- Improved validation to filter out incomplete model configurations.

* refactor: Simplify Gemini model environment variable parsing with JSON support

- Replaced prefix-based parsing with a root key approach.
- Added JSON parsing to handle list-based model configurations.
- Improved handling of errors and cleanup of environment variables.

* fix: Enhance Gemini model environment variable parsing with fallback to Python literals

- Added `ast.literal_eval` as a fallback for parsing environment variables when JSON decoding fails.
- Improved error handling and logging for invalid configurations.
- Ensured proper cleanup of environment variables post-parsing.

* fix: Improve regex patterns in helper module

- Adjusted `TOOL_CALL_RE` regex pattern for better accuracy.

* docs: Update README files to include custom model configuration and environment variable setup

* fix: Remove unused headers from HTTP client in helper module

* fix: Update README and README.zh to clarify model configuration via environment variables; enhance error logging in config validation

* Update README and README.zh to clarify model configuration via JSON string or list structure for enhanced flexibility in automated environments

* Refactor: compress JSON content to save tokens and streamline sending multiple chunks

* Refactor: Modify the LMDB store to fix issues where no conversation is found in either the raw or cleaned history.

* Refactor: Modify the LMDB store to fix issues where no conversation is found.

* Refactor: Update all functions to use orjson for better performance

* Update project dependencies

* Fix IDE warnings

* Incorrect IDE warnings

* Refactor: Modify the LMDB store to fix issues where no conversation is found.

* Refactor: Centralized the mapping of the 'developer' role to 'system' for better Gemini compatibility.

* Refactor: Modify the LMDB store to fix issues where no conversation is found.

* Refactor: Modify the LMDB store to fix issues where no conversation is found.

* Refactor: Modify the LMDB store to fix issues where no conversation is found.

* Refactor: Avoid reusing an existing chat session if its idle time exceeds METADATA_TTL_MINUTES.

* Refactor: Update the LMDB store to resolve issues preventing conversation from being saved

* Refactor: Update the _prepare_messages_for_model helper to omit the system instruction when reusing a session to save tokens.

* Refactor: Modify the logic to convert a large prompt into a temporary text file attachment

- When multiple chunks are sent simultaneously, Google will immediately invalidate the access token and reject the request
- When a prompt contains a structured format like JSON, splitting it can break the format and may cause the model to misunderstand the context
- Another minor tweak as Copilot suggested

app/main.py CHANGED
@@ -2,6 +2,7 @@ import asyncio
2
  from contextlib import asynccontextmanager
3
 
4
  from fastapi import FastAPI
 
5
  from loguru import logger
6
 
7
  from .server.chat import router as chat_router
@@ -92,6 +93,7 @@ def create_app() -> FastAPI:
92
  description="OpenAI-compatible API for Gemini Web",
93
  version="1.0.0",
94
  lifespan=lifespan,
 
95
  )
96
 
97
  add_cors_middleware(app)
 
2
  from contextlib import asynccontextmanager
3
 
4
  from fastapi import FastAPI
5
+ from fastapi.responses import ORJSONResponse
6
  from loguru import logger
7
 
8
  from .server.chat import router as chat_router
 
93
  description="OpenAI-compatible API for Gemini Web",
94
  version="1.0.0",
95
  lifespan=lifespan,
96
+ default_response_class=ORJSONResponse,
97
  )
98
 
99
  add_cors_middleware(app)
app/models/models.py CHANGED
@@ -24,11 +24,19 @@ class Message(BaseModel):
24
  content: Union[str, List[ContentItem], None] = None
25
  name: Optional[str] = None
26
  tool_calls: Optional[List["ToolCall"]] = None
 
27
  refusal: Optional[str] = None
28
  reasoning_content: Optional[str] = None
29
  audio: Optional[Dict[str, Any]] = None
30
  annotations: List[Dict[str, Any]] = Field(default_factory=list)
31
 
 
 
 
 
 
 
 
32
 
33
  class Choice(BaseModel):
34
  """Choice model"""
 
24
  content: Union[str, List[ContentItem], None] = None
25
  name: Optional[str] = None
26
  tool_calls: Optional[List["ToolCall"]] = None
27
+ tool_call_id: Optional[str] = None
28
  refusal: Optional[str] = None
29
  reasoning_content: Optional[str] = None
30
  audio: Optional[Dict[str, Any]] = None
31
  annotations: List[Dict[str, Any]] = Field(default_factory=list)
32
 
33
+ @model_validator(mode="after")
34
+ def normalize_role(self) -> "Message":
35
+ """Normalize 'developer' role to 'system' for Gemini compatibility."""
36
+ if self.role == "developer":
37
+ self.role = "system"
38
+ return self
39
+
40
 
41
  class Choice(BaseModel):
42
  """Choice model"""
app/server/chat.py CHANGED
@@ -1,6 +1,6 @@
1
  import base64
2
- import json
3
  import re
 
4
  import uuid
5
  from dataclasses import dataclass
6
  from datetime import datetime, timezone
@@ -57,6 +57,7 @@ from .middleware import get_image_store_dir, get_image_token, get_temp_dir, veri
57
  # Maximum characters Gemini Web can accept in a single request (configurable)
58
  MAX_CHARS_PER_REQUEST = int(g_config.gemini.max_chars_per_request * 0.9)
59
  CONTINUATION_HINT = "\n(More messages to come, please reply with just 'ok.')"
 
60
 
61
  router = APIRouter()
62
 
@@ -95,7 +96,7 @@ def _build_structured_requirement(
95
  schema_name = json_schema.get("name") or "response"
96
  strict = json_schema.get("strict", True)
97
 
98
- pretty_schema = json.dumps(schema, ensure_ascii=False, indent=2, sort_keys=True)
99
  instruction_parts = [
100
  "You must respond with a single valid JSON document that conforms to the schema shown below.",
101
  "Do not include explanations, comments, or any text before or after the JSON.",
@@ -135,7 +136,7 @@ def _build_tool_prompt(
135
  description = function.description or "No description provided."
136
  lines.append(f"Tool `{function.name}`: {description}")
137
  if function.parameters:
138
- schema_text = json.dumps(function.parameters, ensure_ascii=False, indent=2)
139
  lines.append("Arguments JSON schema:")
140
  lines.append(schema_text)
141
  else:
@@ -266,31 +267,35 @@ def _prepare_messages_for_model(
266
  tools: list[Tool] | None,
267
  tool_choice: str | ToolChoiceFunction | None,
268
  extra_instructions: list[str] | None = None,
 
269
  ) -> list[Message]:
270
  """Return a copy of messages enriched with tool instructions when needed."""
271
  prepared = [msg.model_copy(deep=True) for msg in source_messages]
272
 
273
  instructions: list[str] = []
274
- if tools:
275
- tool_prompt = _build_tool_prompt(tools, tool_choice)
276
- if tool_prompt:
277
- instructions.append(tool_prompt)
278
-
279
- if extra_instructions:
280
- instructions.extend(instr for instr in extra_instructions if instr)
281
- logger.debug(
282
- f"Applied {len(extra_instructions)} extra instructions for tool/structured output."
283
- )
 
284
 
285
- if not _conversation_has_code_hint(prepared):
286
- instructions.append(CODE_BLOCK_HINT)
287
- logger.debug("Injected default code block hint for Gemini conversation.")
288
 
289
  if not instructions:
 
 
 
290
  return prepared
291
 
292
  combined_instructions = "\n\n".join(instructions)
293
-
294
  if prepared and prepared[0].role == "system" and isinstance(prepared[0].content, str):
295
  existing = prepared[0].content or ""
296
  separator = "\n\n" if existing else ""
@@ -318,8 +323,6 @@ def _response_items_to_messages(
318
  normalized_input: list[ResponseInputItem] = []
319
  for item in items:
320
  role = item.role
321
- if role == "developer":
322
- role = "system"
323
 
324
  content = item.content
325
  normalized_contents: list[ResponseInputContent] = []
@@ -371,9 +374,7 @@ def _response_items_to_messages(
371
  ResponseInputItem(type="message", role=item.role, content=normalized_contents or [])
372
  )
373
 
374
- logger.debug(
375
- f"Normalized Responses input: {len(normalized_input)} message items (developer roles mapped to system)."
376
- )
377
  return messages, normalized_input
378
 
379
 
@@ -393,8 +394,6 @@ def _instructions_to_messages(
393
  continue
394
 
395
  role = item.role
396
- if role == "developer":
397
- role = "system"
398
 
399
  content = item.content
400
  if isinstance(content, str):
@@ -532,8 +531,14 @@ async def create_chat_completion(
532
  )
533
 
534
  if session:
 
 
535
  messages_to_send = _prepare_messages_for_model(
536
- remaining_messages, request.tools, request.tool_choice, extra_instructions
 
 
 
 
537
  )
538
  if not messages_to_send:
539
  raise HTTPException(
@@ -624,8 +629,8 @@ async def create_chat_completion(
624
  detail="LLM returned an empty response while JSON schema output was requested.",
625
  )
626
  try:
627
- structured_payload = json.loads(cleaned_visible)
628
- except json.JSONDecodeError as exc:
629
  logger.warning(
630
  f"Failed to decode JSON for structured response (schema={structured_requirement.schema_name}): "
631
  f"{cleaned_visible}"
@@ -635,7 +640,7 @@ async def create_chat_completion(
635
  detail="LLM returned invalid JSON for the requested response_format.",
636
  ) from exc
637
 
638
- canonical_output = json.dumps(structured_payload, ensure_ascii=False)
639
  visible_output = canonical_output
640
  storage_output = canonical_output
641
 
@@ -644,17 +649,20 @@ async def create_chat_completion(
644
 
645
  # After formatting, persist the conversation to LMDB
646
  try:
647
- last_message = Message(
648
  role="assistant",
649
  content=storage_output or None,
650
  tool_calls=tool_calls or None,
651
  )
652
- cleaned_history = db.sanitize_assistant_messages(request.messages)
 
 
 
653
  conv = ConversationInStore(
654
  model=model.model_name,
655
  client_id=client.id,
656
  metadata=session.metadata,
657
- messages=[*cleaned_history, last_message],
658
  )
659
  key = db.store(conv)
660
  logger.debug(f"Conversation saved to LMDB with key: {key}")
@@ -782,9 +790,10 @@ async def create_response(
782
  if reuse_session:
783
  messages_to_send = _prepare_messages_for_model(
784
  remaining_messages,
785
- tools=None,
786
- tool_choice=None,
787
- extra_instructions=extra_instructions or None,
 
788
  )
789
  if not messages_to_send:
790
  raise HTTPException(
@@ -864,8 +873,8 @@ async def create_response(
864
  detail="LLM returned an empty response while JSON schema output was requested.",
865
  )
866
  try:
867
- structured_payload = json.loads(cleaned_visible)
868
- except json.JSONDecodeError as exc:
869
  logger.warning(
870
  f"Failed to decode JSON for structured response (schema={structured_requirement.schema_name}): "
871
  f"{cleaned_visible}"
@@ -875,7 +884,7 @@ async def create_response(
875
  detail="LLM returned invalid JSON for the requested response_format.",
876
  ) from exc
877
 
878
- canonical_output = json.dumps(structured_payload, ensure_ascii=False)
879
  assistant_text = canonical_output
880
  storage_output = canonical_output
881
  logger.debug(
@@ -996,17 +1005,19 @@ async def create_response(
996
  )
997
 
998
  try:
999
- last_message = Message(
1000
  role="assistant",
1001
  content=storage_output or None,
1002
  tool_calls=detected_tool_calls or None,
1003
  )
1004
- cleaned_history = db.sanitize_assistant_messages(messages)
 
 
1005
  conv = ConversationInStore(
1006
  model=model.model_name,
1007
  client_id=client.id,
1008
  metadata=session.metadata,
1009
- messages=[*cleaned_history, last_message],
1010
  )
1011
  key = db.store(conv)
1012
  logger.debug(f"Conversation saved to LMDB with key: {key}")
@@ -1050,19 +1061,35 @@ async def _find_reusable_session(
1050
 
1051
  # Start with the full history and iteratively trim from the end.
1052
  search_end = len(messages)
 
1053
  while search_end >= 2:
1054
  search_history = messages[:search_end]
1055
 
1056
- # Only try to match if the last stored message would be assistant/system.
1057
- if search_history[-1].role in {"assistant", "system"}:
1058
  try:
1059
  if conv := db.find(model.model_name, search_history):
1060
- client = await pool.acquire(conv.client_id)
1061
- session = client.start_chat(metadata=conv.metadata, model=model)
1062
- remain = messages[search_end:]
1063
- return session, client, remain
 
 
 
 
 
 
 
 
 
 
 
 
 
1064
  except Exception as e:
1065
- logger.warning(f"Error checking LMDB for reusable session: {e}")
 
 
1066
  break
1067
 
1068
  # Trim one message and try again.
@@ -1072,52 +1099,48 @@ async def _find_reusable_session(
1072
 
1073
 
1074
  async def _send_with_split(session: ChatSession, text: str, files: list[Path | str] | None = None):
1075
- """Send text to Gemini, automatically splitting into multiple batches if it is
1076
- longer than ``MAX_CHARS_PER_REQUEST``.
1077
-
1078
- Every intermediate batch (that is **not** the last one) is suffixed with a hint
1079
- telling Gemini that more content will come, and it should simply reply with
1080
- "ok". The final batch carries any file uploads and the real user prompt so
1081
- that Gemini can produce the actual answer.
1082
  """
1083
  if len(text) <= MAX_CHARS_PER_REQUEST:
1084
- # No need to split - a single request is fine.
1085
  try:
1086
  return await session.send_message(text, files=files)
1087
  except Exception as e:
1088
  logger.exception(f"Error sending message to Gemini: {e}")
1089
  raise
1090
- hint_len = len(CONTINUATION_HINT)
1091
- chunk_size = MAX_CHARS_PER_REQUEST - hint_len
1092
-
1093
- chunks: list[str] = []
1094
- pos = 0
1095
- total = len(text)
1096
- while pos < total:
1097
- end = min(pos + chunk_size, total)
1098
- chunk = text[pos:end]
1099
- pos = end
1100
-
1101
- # If this is NOT the last chunk, add the continuation hint.
1102
- if end < total:
1103
- chunk += CONTINUATION_HINT
1104
- chunks.append(chunk)
1105
-
1106
- # Fire off all but the last chunk, discarding the interim "ok" replies.
1107
- for chk in chunks[:-1]:
1108
  try:
1109
- await session.send_message(chk)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1110
  except Exception as e:
1111
- logger.exception(f"Error sending chunk to Gemini: {e}")
1112
  raise
1113
 
1114
- # The last chunk carries the files (if any) and we return its response.
1115
- try:
1116
- return await session.send_message(chunks[-1], files=files)
1117
- except Exception as e:
1118
- logger.exception(f"Error sending final chunk to Gemini: {e}")
1119
- raise
1120
-
1121
 
1122
  def _create_streaming_response(
1123
  model_output: str,
 
1
  import base64
 
2
  import re
3
+ import tempfile
4
  import uuid
5
  from dataclasses import dataclass
6
  from datetime import datetime, timezone
 
57
  # Maximum characters Gemini Web can accept in a single request (configurable)
58
  MAX_CHARS_PER_REQUEST = int(g_config.gemini.max_chars_per_request * 0.9)
59
  CONTINUATION_HINT = "\n(More messages to come, please reply with just 'ok.')"
60
+ METADATA_TTL_MINUTES = 15
61
 
62
  router = APIRouter()
63
 
 
96
  schema_name = json_schema.get("name") or "response"
97
  strict = json_schema.get("strict", True)
98
 
99
+ pretty_schema = orjson.dumps(schema, option=orjson.OPT_SORT_KEYS).decode("utf-8")
100
  instruction_parts = [
101
  "You must respond with a single valid JSON document that conforms to the schema shown below.",
102
  "Do not include explanations, comments, or any text before or after the JSON.",
 
136
  description = function.description or "No description provided."
137
  lines.append(f"Tool `{function.name}`: {description}")
138
  if function.parameters:
139
+ schema_text = orjson.dumps(function.parameters).decode("utf-8")
140
  lines.append("Arguments JSON schema:")
141
  lines.append(schema_text)
142
  else:
 
267
  tools: list[Tool] | None,
268
  tool_choice: str | ToolChoiceFunction | None,
269
  extra_instructions: list[str] | None = None,
270
+ inject_system_defaults: bool = True,
271
  ) -> list[Message]:
272
  """Return a copy of messages enriched with tool instructions when needed."""
273
  prepared = [msg.model_copy(deep=True) for msg in source_messages]
274
 
275
  instructions: list[str] = []
276
+ if inject_system_defaults:
277
+ if tools:
278
+ tool_prompt = _build_tool_prompt(tools, tool_choice)
279
+ if tool_prompt:
280
+ instructions.append(tool_prompt)
281
+
282
+ if extra_instructions:
283
+ instructions.extend(instr for instr in extra_instructions if instr)
284
+ logger.debug(
285
+ f"Applied {len(extra_instructions)} extra instructions for tool/structured output."
286
+ )
287
 
288
+ if not _conversation_has_code_hint(prepared):
289
+ instructions.append(CODE_BLOCK_HINT)
290
+ logger.debug("Injected default code block hint for Gemini conversation.")
291
 
292
  if not instructions:
293
+ # Still need to ensure XML hint for the last user message if tools are present
294
+ if tools and tool_choice != "none":
295
+ _append_xml_hint_to_last_user_message(prepared)
296
  return prepared
297
 
298
  combined_instructions = "\n\n".join(instructions)
 
299
  if prepared and prepared[0].role == "system" and isinstance(prepared[0].content, str):
300
  existing = prepared[0].content or ""
301
  separator = "\n\n" if existing else ""
 
323
  normalized_input: list[ResponseInputItem] = []
324
  for item in items:
325
  role = item.role
 
 
326
 
327
  content = item.content
328
  normalized_contents: list[ResponseInputContent] = []
 
374
  ResponseInputItem(type="message", role=item.role, content=normalized_contents or [])
375
  )
376
 
377
+ logger.debug(f"Normalized Responses input: {len(normalized_input)} message items.")
 
 
378
  return messages, normalized_input
379
 
380
 
 
394
  continue
395
 
396
  role = item.role
 
 
397
 
398
  content = item.content
399
  if isinstance(content, str):
 
531
  )
532
 
533
  if session:
534
+ # Optimization: When reusing a session, we don't need to resend the heavy tool definitions
535
+ # or structured output instructions as they are already in the Gemini session history.
536
  messages_to_send = _prepare_messages_for_model(
537
+ remaining_messages,
538
+ request.tools,
539
+ request.tool_choice,
540
+ extra_instructions,
541
+ inject_system_defaults=False,
542
  )
543
  if not messages_to_send:
544
  raise HTTPException(
 
629
  detail="LLM returned an empty response while JSON schema output was requested.",
630
  )
631
  try:
632
+ structured_payload = orjson.loads(cleaned_visible)
633
+ except orjson.JSONDecodeError as exc:
634
  logger.warning(
635
  f"Failed to decode JSON for structured response (schema={structured_requirement.schema_name}): "
636
  f"{cleaned_visible}"
 
640
  detail="LLM returned invalid JSON for the requested response_format.",
641
  ) from exc
642
 
643
+ canonical_output = orjson.dumps(structured_payload).decode("utf-8")
644
  visible_output = canonical_output
645
  storage_output = canonical_output
646
 
 
649
 
650
  # After formatting, persist the conversation to LMDB
651
  try:
652
+ current_assistant_message = Message(
653
  role="assistant",
654
  content=storage_output or None,
655
  tool_calls=tool_calls or None,
656
  )
657
+ # Sanitize the entire history including the new message to ensure consistency
658
+ full_history = [*request.messages, current_assistant_message]
659
+ cleaned_history = db.sanitize_assistant_messages(full_history)
660
+
661
  conv = ConversationInStore(
662
  model=model.model_name,
663
  client_id=client.id,
664
  metadata=session.metadata,
665
+ messages=cleaned_history,
666
  )
667
  key = db.store(conv)
668
  logger.debug(f"Conversation saved to LMDB with key: {key}")
 
790
  if reuse_session:
791
  messages_to_send = _prepare_messages_for_model(
792
  remaining_messages,
793
+ tools=request_data.tools, # Keep for XML hint logic
794
+ tool_choice=request_data.tool_choice,
795
+ extra_instructions=None, # Already in session history
796
+ inject_system_defaults=False,
797
  )
798
  if not messages_to_send:
799
  raise HTTPException(
 
873
  detail="LLM returned an empty response while JSON schema output was requested.",
874
  )
875
  try:
876
+ structured_payload = orjson.loads(cleaned_visible)
877
+ except orjson.JSONDecodeError as exc:
878
  logger.warning(
879
  f"Failed to decode JSON for structured response (schema={structured_requirement.schema_name}): "
880
  f"{cleaned_visible}"
 
884
  detail="LLM returned invalid JSON for the requested response_format.",
885
  ) from exc
886
 
887
+ canonical_output = orjson.dumps(structured_payload).decode("utf-8")
888
  assistant_text = canonical_output
889
  storage_output = canonical_output
890
  logger.debug(
 
1005
  )
1006
 
1007
  try:
1008
+ current_assistant_message = Message(
1009
  role="assistant",
1010
  content=storage_output or None,
1011
  tool_calls=detected_tool_calls or None,
1012
  )
1013
+ full_history = [*messages, current_assistant_message]
1014
+ cleaned_history = db.sanitize_assistant_messages(full_history)
1015
+
1016
  conv = ConversationInStore(
1017
  model=model.model_name,
1018
  client_id=client.id,
1019
  metadata=session.metadata,
1020
+ messages=cleaned_history,
1021
  )
1022
  key = db.store(conv)
1023
  logger.debug(f"Conversation saved to LMDB with key: {key}")
 
1061
 
1062
  # Start with the full history and iteratively trim from the end.
1063
  search_end = len(messages)
1064
+
1065
  while search_end >= 2:
1066
  search_history = messages[:search_end]
1067
 
1068
+ # Only try to match if the last stored message would be assistant/system/tool before querying LMDB.
1069
+ if search_history[-1].role in {"assistant", "system", "tool"}:
1070
  try:
1071
  if conv := db.find(model.model_name, search_history):
1072
+ # Check if metadata is too old
1073
+ now = datetime.now()
1074
+ updated_at = conv.updated_at or conv.created_at or now
1075
+ age_minutes = (now - updated_at).total_seconds() / 60
1076
+
1077
+ if age_minutes <= METADATA_TTL_MINUTES:
1078
+ client = await pool.acquire(conv.client_id)
1079
+ session = client.start_chat(metadata=conv.metadata, model=model)
1080
+ remain = messages[search_end:]
1081
+ logger.debug(
1082
+ f"Match found at prefix length {search_end}. Client: {conv.client_id}"
1083
+ )
1084
+ return session, client, remain
1085
+ else:
1086
+ logger.debug(
1087
+ f"Matched conversation is too old ({age_minutes:.1f}m), skipping reuse."
1088
+ )
1089
  except Exception as e:
1090
+ logger.warning(
1091
+ f"Error checking LMDB for reusable session at length {search_end}: {e}"
1092
+ )
1093
  break
1094
 
1095
  # Trim one message and try again.
 
1099
 
1100
 
1101
  async def _send_with_split(session: ChatSession, text: str, files: list[Path | str] | None = None):
1102
+ """
1103
+ Send text to Gemini. If text is longer than ``MAX_CHARS_PER_REQUEST``,
1104
+ it is converted into a temporary text file attachment to avoid splitting issues.
 
 
 
 
1105
  """
1106
  if len(text) <= MAX_CHARS_PER_REQUEST:
 
1107
  try:
1108
  return await session.send_message(text, files=files)
1109
  except Exception as e:
1110
  logger.exception(f"Error sending message to Gemini: {e}")
1111
  raise
1112
+
1113
+ logger.info(
1114
+ f"Message length ({len(text)}) exceeds limit ({MAX_CHARS_PER_REQUEST}). Converting text to file attachment."
1115
+ )
1116
+
1117
+ # Create a temporary directory to hold the message.txt file
1118
+ # This ensures the filename is exactly 'message.txt' as expected by the instruction.
1119
+ with tempfile.TemporaryDirectory() as tmpdirname:
1120
+ temp_file_path = Path(tmpdirname) / "message.txt"
1121
+ temp_file_path.write_text(text, encoding="utf-8")
1122
+
 
 
 
 
 
 
 
1123
  try:
1124
+ # Prepare the files list
1125
+ final_files = list(files) if files else []
1126
+ final_files.append(temp_file_path)
1127
+
1128
+ instruction = (
1129
+ "The user's input exceeds the character limit and is provided in the attached file `message.txt`.\n\n"
1130
+ "**System Instruction:**\n"
1131
+ "1. Read the content of `message.txt`.\n"
1132
+ "2. Treat that content as the **primary** user prompt for this turn.\n"
1133
+ "3. Execute the instructions or answer the questions found *inside* that file immediately.\n"
1134
+ )
1135
+
1136
+ logger.debug(f"Sending prompt as temporary file: {temp_file_path}")
1137
+
1138
+ return await session.send_message(instruction, files=final_files)
1139
+
1140
  except Exception as e:
1141
+ logger.exception(f"Error sending large text as file to Gemini: {e}")
1142
  raise
1143
 
 
 
 
 
 
 
 
1144
 
1145
  def _create_streaming_response(
1146
  model_output: str,
app/services/client.py CHANGED
@@ -1,9 +1,9 @@
1
  import html
2
- import json
3
  import re
4
  from pathlib import Path
5
  from typing import Any, cast
6
 
 
7
  from gemini_webapi import GeminiClient, ModelOutput
8
  from loguru import logger
9
 
@@ -122,9 +122,9 @@ class GeminiClientWrapper(GeminiClient):
122
  for call in message.tool_calls:
123
  args_text = call.function.arguments.strip()
124
  try:
125
- parsed_args = json.loads(args_text)
126
- args_text = json.dumps(parsed_args, ensure_ascii=False)
127
- except (json.JSONDecodeError, TypeError):
128
  # Leave args_text as is if it is not valid JSON
129
  pass
130
  tool_blocks.append(
@@ -132,7 +132,7 @@ class GeminiClientWrapper(GeminiClient):
132
  )
133
 
134
  if tool_blocks:
135
- tool_section = "```xml\n" + "\n".join(tool_blocks) + "\n```"
136
  text_fragments.append(tool_section)
137
 
138
  model_input = "\n".join(fragment for fragment in text_fragments if fragment)
 
1
  import html
 
2
  import re
3
  from pathlib import Path
4
  from typing import Any, cast
5
 
6
+ import orjson
7
  from gemini_webapi import GeminiClient, ModelOutput
8
  from loguru import logger
9
 
 
122
  for call in message.tool_calls:
123
  args_text = call.function.arguments.strip()
124
  try:
125
+ parsed_args = orjson.loads(args_text)
126
+ args_text = orjson.dumps(parsed_args).decode("utf-8")
127
+ except orjson.JSONDecodeError:
128
  # Leave args_text as is if it is not valid JSON
129
  pass
130
  tool_blocks.append(
 
132
  )
133
 
134
  if tool_blocks:
135
+ tool_section = "```xml\n" + "".join(tool_blocks) + "\n```"
136
  text_fragments.append(tool_section)
137
 
138
  model_input = "\n".join(fragment for fragment in text_fragments if fragment)
app/services/lmdb.py CHANGED
@@ -9,25 +9,78 @@ import lmdb
9
  import orjson
10
  from loguru import logger
11
 
12
- from ..models import ConversationInStore, Message
13
  from ..utils import g_config
 
14
  from ..utils.singleton import Singleton
15
 
16
 
17
  def _hash_message(message: Message) -> str:
18
- """Generate a hash for a single message."""
19
- # Convert message to dict and sort keys for consistent hashing
20
- message_dict = message.model_dump(mode="json")
21
- message_bytes = orjson.dumps(message_dict, option=orjson.OPT_SORT_KEYS)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
22
  return hashlib.sha256(message_bytes).hexdigest()
23
 
24
 
25
  def _hash_conversation(client_id: str, model: str, messages: List[Message]) -> str:
26
- """Generate a hash for a list of messages and client id."""
27
- # Create a combined hash from all individual message hashes
28
  combined_hash = hashlib.sha256()
29
- combined_hash.update(client_id.encode("utf-8"))
30
- combined_hash.update(model.encode("utf-8"))
31
  for message in messages:
32
  message_hash = _hash_message(message)
33
  combined_hash.update(message_hash.encode("utf-8"))
@@ -210,12 +263,13 @@ class LMDBConversationStore(metaclass=Singleton):
210
  return None
211
 
212
  def _find_by_message_list(
213
- self, model: str, messages: List[Message]
 
 
214
  ) -> Optional[ConversationInStore]:
215
  """Internal find implementation based on a message list."""
216
  for c in g_config.gemini.clients:
217
  message_hash = _hash_conversation(c.id, model, messages)
218
-
219
  key = f"{self.HASH_LOOKUP_PREFIX}{message_hash}"
220
  try:
221
  with self._get_transaction(write=False) as txn:
@@ -422,25 +476,78 @@ class LMDBConversationStore(metaclass=Singleton):
422
  @staticmethod
423
  def remove_think_tags(text: str) -> str:
424
  """
425
- Remove <think>...</think> tags at the start of text and strip whitespace.
426
  """
427
- cleaned_content = re.sub(r"^(\s*<think>.*?</think>\n?)", "", text, flags=re.DOTALL)
 
428
  return cleaned_content.strip()
429
 
430
  @staticmethod
431
  def sanitize_assistant_messages(messages: list[Message]) -> list[Message]:
432
  """
433
- Create a new list of messages with assistant content cleaned of <think> tags.
434
- This is useful for store the chat history.
 
 
 
 
435
  """
436
  cleaned_messages = []
437
  for msg in messages:
438
- if msg.role == "assistant" and isinstance(msg.content, str):
439
- normalized_content = LMDBConversationStore.remove_think_tags(msg.content)
440
- # Only create a new object if content actually changed
441
- if normalized_content != msg.content:
442
- cleaned_msg = Message(role=msg.role, content=normalized_content, name=msg.name)
443
- cleaned_messages.append(cleaned_msg)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
444
  else:
445
  cleaned_messages.append(msg)
446
  else:
 
9
  import orjson
10
  from loguru import logger
11
 
12
+ from ..models import ContentItem, ConversationInStore, Message
13
  from ..utils import g_config
14
+ from ..utils.helper import extract_tool_calls, remove_tool_call_blocks
15
  from ..utils.singleton import Singleton
16
 
17
 
18
  def _hash_message(message: Message) -> str:
19
+ """Generate a consistent hash for a single message focusing ONLY on logic/content, ignoring technical IDs."""
20
+ core_data = {
21
+ "role": message.role,
22
+ "name": message.name,
23
+ }
24
+
25
+ # Normalize content: strip, handle empty/None, and list-of-text items
26
+ content = message.content
27
+ if not content:
28
+ core_data["content"] = None
29
+ elif isinstance(content, str):
30
+ # Normalize line endings and strip whitespace
31
+ normalized = content.replace("\r\n", "\n").strip()
32
+ core_data["content"] = normalized if normalized else None
33
+ elif isinstance(content, list):
34
+ text_parts = []
35
+ for item in content:
36
+ if isinstance(item, ContentItem) and item.type == "text":
37
+ text_parts.append(item.text or "")
38
+ elif isinstance(item, dict) and item.get("type") == "text":
39
+ text_parts.append(item.get("text") or "")
40
+ else:
41
+ # If it contains non-text (images/files), keep the full list for hashing
42
+ text_parts = None
43
+ break
44
+
45
+ if text_parts is not None:
46
+ # Normalize each part but keep them as a list to preserve boundaries and avoid collisions
47
+ normalized_parts = [p.replace("\r\n", "\n") for p in text_parts]
48
+ core_data["content"] = normalized_parts if normalized_parts else None
49
+ else:
50
+ core_data["content"] = message.model_dump(mode="json")["content"]
51
+
52
+ # Normalize tool_calls: Focus ONLY on function name and arguments
53
+ if message.tool_calls:
54
+ calls_data = []
55
+ for tc in message.tool_calls:
56
+ args = tc.function.arguments or "{}"
57
+ try:
58
+ parsed = orjson.loads(args)
59
+ canon_args = orjson.dumps(parsed, option=orjson.OPT_SORT_KEYS).decode("utf-8")
60
+ except orjson.JSONDecodeError:
61
+ canon_args = args
62
+
63
+ calls_data.append(
64
+ {
65
+ "name": tc.function.name,
66
+ "arguments": canon_args,
67
+ }
68
+ )
69
+ # Sort calls to be order-independent
70
+ calls_data.sort(key=lambda x: (x["name"], x["arguments"]))
71
+ core_data["tool_calls"] = calls_data
72
+ else:
73
+ core_data["tool_calls"] = None
74
+
75
+ message_bytes = orjson.dumps(core_data, option=orjson.OPT_SORT_KEYS)
76
  return hashlib.sha256(message_bytes).hexdigest()
77
 
78
 
79
  def _hash_conversation(client_id: str, model: str, messages: List[Message]) -> str:
80
+ """Generate a hash for a list of messages and model name, tied to a specific client_id."""
 
81
  combined_hash = hashlib.sha256()
82
+ combined_hash.update((client_id or "").encode("utf-8"))
83
+ combined_hash.update((model or "").encode("utf-8"))
84
  for message in messages:
85
  message_hash = _hash_message(message)
86
  combined_hash.update(message_hash.encode("utf-8"))
 
263
  return None
264
 
265
  def _find_by_message_list(
266
+ self,
267
+ model: str,
268
+ messages: List[Message],
269
  ) -> Optional[ConversationInStore]:
270
  """Internal find implementation based on a message list."""
271
  for c in g_config.gemini.clients:
272
  message_hash = _hash_conversation(c.id, model, messages)
 
273
  key = f"{self.HASH_LOOKUP_PREFIX}{message_hash}"
274
  try:
275
  with self._get_transaction(write=False) as txn:
 
476
  @staticmethod
477
  def remove_think_tags(text: str) -> str:
478
  """
479
+ Remove all <think>...</think> tags and strip whitespace.
480
  """
481
+ # Remove all think blocks anywhere in the text
482
+ cleaned_content = re.sub(r"<think>.*?</think>", "", text, flags=re.DOTALL)
483
  return cleaned_content.strip()
484
 
485
  @staticmethod
486
  def sanitize_assistant_messages(messages: list[Message]) -> list[Message]:
487
  """
488
+ Create a new list of messages with assistant content cleaned of <think> tags
489
+ and system hints/tool call blocks. This is used for both storing and
490
+ searching chat history to ensure consistency.
491
+
492
+ If a message has no tool_calls but contains tool call XML blocks in its
493
+ content, they will be extracted and moved to the tool_calls field.
494
  """
495
  cleaned_messages = []
496
  for msg in messages:
497
+ if msg.role == "assistant":
498
+ if isinstance(msg.content, str):
499
+ text = LMDBConversationStore.remove_think_tags(msg.content)
500
+ tool_calls = msg.tool_calls
501
+ if not tool_calls:
502
+ text, tool_calls = extract_tool_calls(text)
503
+ else:
504
+ text = remove_tool_call_blocks(text).strip()
505
+
506
+ normalized_content = text.strip()
507
+
508
+ if normalized_content != msg.content or tool_calls != msg.tool_calls:
509
+ cleaned_msg = msg.model_copy(
510
+ update={
511
+ "content": normalized_content or None,
512
+ "tool_calls": tool_calls or None,
513
+ }
514
+ )
515
+ cleaned_messages.append(cleaned_msg)
516
+ else:
517
+ cleaned_messages.append(msg)
518
+ elif isinstance(msg.content, list):
519
+ new_content = []
520
+ all_extracted_calls = list(msg.tool_calls or [])
521
+ changed = False
522
+
523
+ for item in msg.content:
524
+ if isinstance(item, ContentItem) and item.type == "text" and item.text:
525
+ text = LMDBConversationStore.remove_think_tags(item.text)
526
+
527
+ if not msg.tool_calls:
528
+ text, extracted = extract_tool_calls(text)
529
+ if extracted:
530
+ all_extracted_calls.extend(extracted)
531
+ changed = True
532
+ else:
533
+ text = remove_tool_call_blocks(text).strip()
534
+
535
+ if text != item.text:
536
+ changed = True
537
+ item = item.model_copy(update={"text": text.strip() or None})
538
+ new_content.append(item)
539
+
540
+ if changed:
541
+ cleaned_messages.append(
542
+ msg.model_copy(
543
+ update={
544
+ "content": new_content,
545
+ "tool_calls": all_extracted_calls or None,
546
+ }
547
+ )
548
+ )
549
+ else:
550
+ cleaned_messages.append(msg)
551
  else:
552
  cleaned_messages.append(msg)
553
  else:
app/utils/config.py CHANGED
@@ -1,9 +1,9 @@
1
  import ast
2
- import json
3
  import os
4
  import sys
5
  from typing import Any, Literal, Optional
6
 
 
7
  from loguru import logger
8
  from pydantic import BaseModel, Field, ValidationError, field_validator
9
  from pydantic_settings import (
@@ -65,8 +65,8 @@ class GeminiModelConfig(BaseModel):
65
  def _parse_json_string(cls, v: Any) -> Any:
66
  if isinstance(v, str) and v.strip().startswith("{"):
67
  try:
68
- return json.loads(v)
69
- except json.JSONDecodeError:
70
  # Return the original value to let Pydantic handle the error or type mismatch
71
  return v
72
  return v
@@ -100,8 +100,8 @@ class GeminiConfig(BaseModel):
100
  def _parse_models_json(cls, v: Any) -> Any:
101
  if isinstance(v, str) and v.strip().startswith("["):
102
  try:
103
- return json.loads(v)
104
- except json.JSONDecodeError as e:
105
  logger.warning(f"Failed to parse models JSON string: {e}")
106
  return v
107
  return v
@@ -282,9 +282,9 @@ def extract_gemini_models_env() -> dict[int, dict[str, Any]]:
282
  parsed_successfully = False
283
 
284
  try:
285
- models_list = json.loads(val)
286
  parsed_successfully = True
287
- except json.JSONDecodeError:
288
  try:
289
  models_list = ast.literal_eval(val)
290
  parsed_successfully = True
 
1
  import ast
 
2
  import os
3
  import sys
4
  from typing import Any, Literal, Optional
5
 
6
+ import orjson
7
  from loguru import logger
8
  from pydantic import BaseModel, Field, ValidationError, field_validator
9
  from pydantic_settings import (
 
65
  def _parse_json_string(cls, v: Any) -> Any:
66
  if isinstance(v, str) and v.strip().startswith("{"):
67
  try:
68
+ return orjson.loads(v)
69
+ except orjson.JSONDecodeError:
70
  # Return the original value to let Pydantic handle the error or type mismatch
71
  return v
72
  return v
 
100
  def _parse_models_json(cls, v: Any) -> Any:
101
  if isinstance(v, str) and v.strip().startswith("["):
102
  try:
103
+ return orjson.loads(v)
104
+ except orjson.JSONDecodeError as e:
105
  logger.warning(f"Failed to parse models JSON string: {e}")
106
  return v
107
  return v
 
282
  parsed_successfully = False
283
 
284
  try:
285
+ models_list = orjson.loads(val)
286
  parsed_successfully = True
287
+ except orjson.JSONDecodeError:
288
  try:
289
  models_list = ast.literal_eval(val)
290
  parsed_successfully = True
app/utils/helper.py CHANGED
@@ -1,15 +1,15 @@
1
  import base64
2
- import json
3
  import mimetypes
4
  import re
5
  import struct
6
  import tempfile
7
- import uuid
8
  from pathlib import Path
9
  from typing import Iterator
10
  from urllib.parse import urlparse
11
 
12
  import httpx
 
13
  from loguru import logger
14
 
15
  from ..models import FunctionCall, Message, ToolCall
@@ -221,14 +221,20 @@ def extract_tool_calls(text: str) -> tuple[str, list[ToolCall]]:
221
 
222
  arguments = raw_args
223
  try:
224
- parsed_args = json.loads(raw_args)
225
- arguments = json.dumps(parsed_args, ensure_ascii=False)
226
- except json.JSONDecodeError:
227
  logger.warning(f"Failed to parse tool call arguments for '{name}'. Passing raw string.")
228
 
 
 
 
 
 
 
229
  tool_calls.append(
230
  ToolCall(
231
- id=f"call_{uuid.uuid4().hex}",
232
  type="function",
233
  function=FunctionCall(name=name, arguments=arguments),
234
  )
 
1
  import base64
2
+ import hashlib
3
  import mimetypes
4
  import re
5
  import struct
6
  import tempfile
 
7
  from pathlib import Path
8
  from typing import Iterator
9
  from urllib.parse import urlparse
10
 
11
  import httpx
12
+ import orjson
13
  from loguru import logger
14
 
15
  from ..models import FunctionCall, Message, ToolCall
 
221
 
222
  arguments = raw_args
223
  try:
224
+ parsed_args = orjson.loads(raw_args)
225
+ arguments = orjson.dumps(parsed_args, option=orjson.OPT_SORT_KEYS).decode("utf-8")
226
+ except orjson.JSONDecodeError:
227
  logger.warning(f"Failed to parse tool call arguments for '{name}'. Passing raw string.")
228
 
229
+ # Generate a deterministic ID based on name, arguments, and its global sequence index
230
+ # to ensure uniqueness across multiple fenced blocks while remaining stable for storage.
231
+ index = len(tool_calls)
232
+ seed = f"{name}:{arguments}:{index}".encode("utf-8")
233
+ call_id = f"call_{hashlib.sha256(seed).hexdigest()[:24]}"
234
+
235
  tool_calls.append(
236
  ToolCall(
237
+ id=call_id,
238
  type="function",
239
  function=FunctionCall(name=name, arguments=arguments),
240
  )
pyproject.toml CHANGED
@@ -5,24 +5,25 @@ description = "FastAPI Server built on Gemini Web API"
5
  readme = "README.md"
6
  requires-python = "==3.12.*"
7
  dependencies = [
8
- "fastapi>=0.115.12",
9
- "gemini-webapi>=1.17.0",
10
- "lmdb>=1.6.2",
11
- "loguru>=0.7.0",
12
- "pydantic-settings[yaml]>=2.9.1",
13
- "uvicorn>=0.34.1",
14
- "uvloop>=0.21.0; sys_platform != 'win32'",
 
15
  ]
16
 
17
  [project.optional-dependencies]
18
  dev = [
19
- "ruff>=0.11.7",
20
  ]
21
 
22
  [tool.ruff]
23
  line-length = 100
24
  lint.select = ["E", "F", "W", "I", "RUF"]
25
- lint.ignore = ["E501"]
26
 
27
  [tool.ruff.format]
28
  quote-style = "double"
@@ -30,5 +31,5 @@ indent-style = "space"
30
 
31
  [dependency-groups]
32
  dev = [
33
- "ruff>=0.11.13",
34
  ]
 
5
  readme = "README.md"
6
  requires-python = "==3.12.*"
7
  dependencies = [
8
+ "fastapi>=0.128.0",
9
+ "gemini-webapi>=1.17.3",
10
+ "lmdb>=1.7.5",
11
+ "loguru>=0.7.3",
12
+ "orjson>=3.11.5",
13
+ "pydantic-settings[yaml]>=2.12.0",
14
+ "uvicorn>=0.40.0",
15
+ "uvloop>=0.22.1; sys_platform != 'win32'",
16
  ]
17
 
18
  [project.optional-dependencies]
19
  dev = [
20
+ "ruff>=0.14.14",
21
  ]
22
 
23
  [tool.ruff]
24
  line-length = 100
25
  lint.select = ["E", "F", "W", "I", "RUF"]
26
+ lint.ignore = ["E501"]
27
 
28
  [tool.ruff.format]
29
  quote-style = "double"
 
31
 
32
  [dependency-groups]
33
  dev = [
34
+ "ruff>=0.14.14",
35
  ]
uv.lock CHANGED
@@ -22,24 +22,24 @@ wheels = [
22
 
23
  [[package]]
24
  name = "anyio"
25
- version = "4.12.0"
26
  source = { registry = "https://pypi.org/simple" }
27
  dependencies = [
28
  { name = "idna" },
29
  { name = "typing-extensions" },
30
  ]
31
- sdist = { url = "https://files.pythonhosted.org/packages/16/ce/8a777047513153587e5434fd752e89334ac33e379aa3497db860eeb60377/anyio-4.12.0.tar.gz", hash = "sha256:73c693b567b0c55130c104d0b43a9baf3aa6a31fc6110116509f27bf75e21ec0", size = 228266, upload-time = "2025-11-28T23:37:38.911Z" }
32
  wheels = [
33
- { url = "https://files.pythonhosted.org/packages/7f/9c/36c5c37947ebfb8c7f22e0eb6e4d188ee2d53aa3880f3f2744fb894f0cb1/anyio-4.12.0-py3-none-any.whl", hash = "sha256:dad2376a628f98eeca4881fc56cd06affd18f659b17a747d3ff0307ced94b1bb", size = 113362, upload-time = "2025-11-28T23:36:57.897Z" },
34
  ]
35
 
36
  [[package]]
37
  name = "certifi"
38
- version = "2025.11.12"
39
  source = { registry = "https://pypi.org/simple" }
40
- sdist = { url = "https://files.pythonhosted.org/packages/a2/8c/58f469717fa48465e4a50c014a0400602d3c437d7c0c468e17ada824da3a/certifi-2025.11.12.tar.gz", hash = "sha256:d8ab5478f2ecd78af242878415affce761ca6bc54a22a27e026d7c25357c3316", size = 160538, upload-time = "2025-11-12T02:54:51.517Z" }
41
  wheels = [
42
- { url = "https://files.pythonhosted.org/packages/70/7d/9bc192684cea499815ff478dfcdc13835ddf401365057044fb721ec6bddb/certifi-2025.11.12-py3-none-any.whl", hash = "sha256:97de8790030bbd5c2d96b7ec782fc2f7820ef8dba6db909ccf95449f2d062d4b", size = 159438, upload-time = "2025-11-12T02:54:49.735Z" },
43
  ]
44
 
45
  [[package]]
@@ -65,7 +65,7 @@ wheels = [
65
 
66
  [[package]]
67
  name = "fastapi"
68
- version = "0.123.10"
69
  source = { registry = "https://pypi.org/simple" }
70
  dependencies = [
71
  { name = "annotated-doc" },
@@ -73,9 +73,9 @@ dependencies = [
73
  { name = "starlette" },
74
  { name = "typing-extensions" },
75
  ]
76
- sdist = { url = "https://files.pythonhosted.org/packages/22/ff/e01087de891010089f1620c916c0c13130f3898177955c13e2b02d22ec4a/fastapi-0.123.10.tar.gz", hash = "sha256:624d384d7cda7c096449c889fc776a0571948ba14c3c929fa8e9a78cd0b0a6a8", size = 356360, upload-time = "2025-12-05T21:27:46.237Z" }
77
  wheels = [
78
- { url = "https://files.pythonhosted.org/packages/d7/f0/7cb92c4a720def85240fd63fbbcf147ce19e7a731c8e1032376bb5a486ac/fastapi-0.123.10-py3-none-any.whl", hash = "sha256:0503b7b7bc71bc98f7c90c9117d21fdf6147c0d74703011b87936becc86985c1", size = 111774, upload-time = "2025-12-05T21:27:44.78Z" },
79
  ]
80
 
81
  [[package]]
@@ -87,6 +87,7 @@ dependencies = [
87
  { name = "gemini-webapi" },
88
  { name = "lmdb" },
89
  { name = "loguru" },
 
90
  { name = "pydantic-settings", extra = ["yaml"] },
91
  { name = "uvicorn" },
92
  { name = "uvloop", marker = "sys_platform != 'win32'" },
@@ -104,19 +105,20 @@ dev = [
104
 
105
  [package.metadata]
106
  requires-dist = [
107
- { name = "fastapi", specifier = ">=0.115.12" },
108
- { name = "gemini-webapi", specifier = ">=1.17.0" },
109
- { name = "lmdb", specifier = ">=1.6.2" },
110
- { name = "loguru", specifier = ">=0.7.0" },
111
- { name = "pydantic-settings", extras = ["yaml"], specifier = ">=2.9.1" },
112
- { name = "ruff", marker = "extra == 'dev'", specifier = ">=0.11.7" },
113
- { name = "uvicorn", specifier = ">=0.34.1" },
114
- { name = "uvloop", marker = "sys_platform != 'win32'", specifier = ">=0.21.0" },
 
115
  ]
116
  provides-extras = ["dev"]
117
 
118
  [package.metadata.requires-dev]
119
- dev = [{ name = "ruff", specifier = ">=0.11.13" }]
120
 
121
  [[package]]
122
  name = "gemini-webapi"
@@ -209,25 +211,25 @@ wheels = [
209
 
210
  [[package]]
211
  name = "orjson"
212
- version = "3.11.4"
213
  source = { registry = "https://pypi.org/simple" }
214
- sdist = { url = "https://files.pythonhosted.org/packages/c6/fe/ed708782d6709cc60eb4c2d8a361a440661f74134675c72990f2c48c785f/orjson-3.11.4.tar.gz", hash = "sha256:39485f4ab4c9b30a3943cfe99e1a213c4776fb69e8abd68f66b83d5a0b0fdc6d", size = 5945188, upload-time = "2025-10-24T15:50:38.027Z" }
215
  wheels = [
216
- { url = "https://files.pythonhosted.org/packages/63/51/6b556192a04595b93e277a9ff71cd0cc06c21a7df98bcce5963fa0f5e36f/orjson-3.11.4-cp312-cp312-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:d4371de39319d05d3f482f372720b841c841b52f5385bd99c61ed69d55d9ab50", size = 243571, upload-time = "2025-10-24T15:49:10.008Z" },
217
- { url = "https://files.pythonhosted.org/packages/1c/2c/2602392ddf2601d538ff11848b98621cd465d1a1ceb9db9e8043181f2f7b/orjson-3.11.4-cp312-cp312-macosx_15_0_arm64.whl", hash = "sha256:e41fd3b3cac850eaae78232f37325ed7d7436e11c471246b87b2cd294ec94853", size = 128891, upload-time = "2025-10-24T15:49:11.297Z" },
218
- { url = "https://files.pythonhosted.org/packages/4e/47/bf85dcf95f7a3a12bf223394a4f849430acd82633848d52def09fa3f46ad/orjson-3.11.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:600e0e9ca042878c7fdf189cf1b028fe2c1418cc9195f6cb9824eb6ed99cb938", size = 130137, upload-time = "2025-10-24T15:49:12.544Z" },
219
- { url = "https://files.pythonhosted.org/packages/b4/4d/a0cb31007f3ab6f1fd2a1b17057c7c349bc2baf8921a85c0180cc7be8011/orjson-3.11.4-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7bbf9b333f1568ef5da42bc96e18bf30fd7f8d54e9ae066d711056add508e415", size = 129152, upload-time = "2025-10-24T15:49:13.754Z" },
220
- { url = "https://files.pythonhosted.org/packages/f7/ef/2811def7ce3d8576b19e3929fff8f8f0d44bc5eb2e0fdecb2e6e6cc6c720/orjson-3.11.4-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4806363144bb6e7297b8e95870e78d30a649fdc4e23fc84daa80c8ebd366ce44", size = 136834, upload-time = "2025-10-24T15:49:15.307Z" },
221
- { url = "https://files.pythonhosted.org/packages/00/d4/9aee9e54f1809cec8ed5abd9bc31e8a9631d19460e3b8470145d25140106/orjson-3.11.4-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ad355e8308493f527d41154e9053b86a5be892b3b359a5c6d5d95cda23601cb2", size = 137519, upload-time = "2025-10-24T15:49:16.557Z" },
222
- { url = "https://files.pythonhosted.org/packages/db/ea/67bfdb5465d5679e8ae8d68c11753aaf4f47e3e7264bad66dc2f2249e643/orjson-3.11.4-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c8a7517482667fb9f0ff1b2f16fe5829296ed7a655d04d68cd9711a4d8a4e708", size = 136749, upload-time = "2025-10-24T15:49:17.796Z" },
223
- { url = "https://files.pythonhosted.org/packages/01/7e/62517dddcfce6d53a39543cd74d0dccfcbdf53967017c58af68822100272/orjson-3.11.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:97eb5942c7395a171cbfecc4ef6701fc3c403e762194683772df4c54cfbb2210", size = 136325, upload-time = "2025-10-24T15:49:19.347Z" },
224
- { url = "https://files.pythonhosted.org/packages/18/ae/40516739f99ab4c7ec3aaa5cc242d341fcb03a45d89edeeaabc5f69cb2cf/orjson-3.11.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:149d95d5e018bdd822e3f38c103b1a7c91f88d38a88aada5c4e9b3a73a244241", size = 140204, upload-time = "2025-10-24T15:49:20.545Z" },
225
- { url = "https://files.pythonhosted.org/packages/82/18/ff5734365623a8916e3a4037fcef1cd1782bfc14cf0992afe7940c5320bf/orjson-3.11.4-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:624f3951181eb46fc47dea3d221554e98784c823e7069edb5dbd0dc826ac909b", size = 406242, upload-time = "2025-10-24T15:49:21.884Z" },
226
- { url = "https://files.pythonhosted.org/packages/e1/43/96436041f0a0c8c8deca6a05ebeaf529bf1de04839f93ac5e7c479807aec/orjson-3.11.4-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:03bfa548cf35e3f8b3a96c4e8e41f753c686ff3d8e182ce275b1751deddab58c", size = 150013, upload-time = "2025-10-24T15:49:23.185Z" },
227
- { url = "https://files.pythonhosted.org/packages/1b/48/78302d98423ed8780479a1e682b9aecb869e8404545d999d34fa486e573e/orjson-3.11.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:525021896afef44a68148f6ed8a8bf8375553d6066c7f48537657f64823565b9", size = 139951, upload-time = "2025-10-24T15:49:24.428Z" },
228
- { url = "https://files.pythonhosted.org/packages/4a/7b/ad613fdcdaa812f075ec0875143c3d37f8654457d2af17703905425981bf/orjson-3.11.4-cp312-cp312-win32.whl", hash = "sha256:b58430396687ce0f7d9eeb3dd47761ca7d8fda8e9eb92b3077a7a353a75efefa", size = 136049, upload-time = "2025-10-24T15:49:25.973Z" },
229
- { url = "https://files.pythonhosted.org/packages/b9/3c/9cf47c3ff5f39b8350fb21ba65d789b6a1129d4cbb3033ba36c8a9023520/orjson-3.11.4-cp312-cp312-win_amd64.whl", hash = "sha256:c6dbf422894e1e3c80a177133c0dda260f81428f9de16d61041949f6a2e5c140", size = 131461, upload-time = "2025-10-24T15:49:27.259Z" },
230
- { url = "https://files.pythonhosted.org/packages/c6/3b/e2425f61e5825dc5b08c2a5a2b3af387eaaca22a12b9c8c01504f8614c36/orjson-3.11.4-cp312-cp312-win_arm64.whl", hash = "sha256:d38d2bc06d6415852224fcc9c0bfa834c25431e466dc319f0edd56cca81aa96e", size = 126167, upload-time = "2025-10-24T15:49:28.511Z" },
231
  ]
232
 
233
  [[package]]
@@ -322,28 +324,28 @@ wheels = [
322
 
323
  [[package]]
324
  name = "ruff"
325
- version = "0.14.8"
326
  source = { registry = "https://pypi.org/simple" }
327
- sdist = { url = "https://files.pythonhosted.org/packages/ed/d9/f7a0c4b3a2bf2556cd5d99b05372c29980249ef71e8e32669ba77428c82c/ruff-0.14.8.tar.gz", hash = "sha256:774ed0dd87d6ce925e3b8496feb3a00ac564bea52b9feb551ecd17e0a23d1eed", size = 5765385, upload-time = "2025-12-04T15:06:17.669Z" }
328
  wheels = [
329
- { url = "https://files.pythonhosted.org/packages/48/b8/9537b52010134b1d2b72870cc3f92d5fb759394094741b09ceccae183fbe/ruff-0.14.8-py3-none-linux_armv6l.whl", hash = "sha256:ec071e9c82eca417f6111fd39f7043acb53cd3fde9b1f95bbed745962e345afb", size = 13441540, upload-time = "2025-12-04T15:06:14.896Z" },
330
- { url = "https://files.pythonhosted.org/packages/24/00/99031684efb025829713682012b6dd37279b1f695ed1b01725f85fd94b38/ruff-0.14.8-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:8cdb162a7159f4ca36ce980a18c43d8f036966e7f73f866ac8f493b75e0c27e9", size = 13669384, upload-time = "2025-12-04T15:06:51.809Z" },
331
- { url = "https://files.pythonhosted.org/packages/72/64/3eb5949169fc19c50c04f28ece2c189d3b6edd57e5b533649dae6ca484fe/ruff-0.14.8-py3-none-macosx_11_0_arm64.whl", hash = "sha256:2e2fcbefe91f9fad0916850edf0854530c15bd1926b6b779de47e9ab619ea38f", size = 12806917, upload-time = "2025-12-04T15:06:08.925Z" },
332
- { url = "https://files.pythonhosted.org/packages/c4/08/5250babb0b1b11910f470370ec0cbc67470231f7cdc033cee57d4976f941/ruff-0.14.8-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a9d70721066a296f45786ec31916dc287b44040f553da21564de0ab4d45a869b", size = 13256112, upload-time = "2025-12-04T15:06:23.498Z" },
333
- { url = "https://files.pythonhosted.org/packages/78/4c/6c588e97a8e8c2d4b522c31a579e1df2b4d003eddfbe23d1f262b1a431ff/ruff-0.14.8-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2c87e09b3cd9d126fc67a9ecd3b5b1d3ded2b9c7fce3f16e315346b9d05cfb52", size = 13227559, upload-time = "2025-12-04T15:06:33.432Z" },
334
- { url = "https://files.pythonhosted.org/packages/23/ce/5f78cea13eda8eceac71b5f6fa6e9223df9b87bb2c1891c166d1f0dce9f1/ruff-0.14.8-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1d62cb310c4fbcb9ee4ac023fe17f984ae1e12b8a4a02e3d21489f9a2a5f730c", size = 13896379, upload-time = "2025-12-04T15:06:02.687Z" },
335
- { url = "https://files.pythonhosted.org/packages/cf/79/13de4517c4dadce9218a20035b21212a4c180e009507731f0d3b3f5df85a/ruff-0.14.8-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:1af35c2d62633d4da0521178e8a2641c636d2a7153da0bac1b30cfd4ccd91344", size = 15372786, upload-time = "2025-12-04T15:06:29.828Z" },
336
- { url = "https://files.pythonhosted.org/packages/00/06/33df72b3bb42be8a1c3815fd4fae83fa2945fc725a25d87ba3e42d1cc108/ruff-0.14.8-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:25add4575ffecc53d60eed3f24b1e934493631b48ebbc6ebaf9d8517924aca4b", size = 14990029, upload-time = "2025-12-04T15:06:36.812Z" },
337
- { url = "https://files.pythonhosted.org/packages/64/61/0f34927bd90925880394de0e081ce1afab66d7b3525336f5771dcf0cb46c/ruff-0.14.8-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4c943d847b7f02f7db4201a0600ea7d244d8a404fbb639b439e987edcf2baf9a", size = 14407037, upload-time = "2025-12-04T15:06:39.979Z" },
338
- { url = "https://files.pythonhosted.org/packages/96/bc/058fe0aefc0fbf0d19614cb6d1a3e2c048f7dc77ca64957f33b12cfdc5ef/ruff-0.14.8-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cb6e8bf7b4f627548daa1b69283dac5a296bfe9ce856703b03130732e20ddfe2", size = 14102390, upload-time = "2025-12-04T15:06:46.372Z" },
339
- { url = "https://files.pythonhosted.org/packages/af/a4/e4f77b02b804546f4c17e8b37a524c27012dd6ff05855d2243b49a7d3cb9/ruff-0.14.8-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:7aaf2974f378e6b01d1e257c6948207aec6a9b5ba53fab23d0182efb887a0e4a", size = 14230793, upload-time = "2025-12-04T15:06:20.497Z" },
340
- { url = "https://files.pythonhosted.org/packages/3f/52/bb8c02373f79552e8d087cedaffad76b8892033d2876c2498a2582f09dcf/ruff-0.14.8-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:e5758ca513c43ad8a4ef13f0f081f80f08008f410790f3611a21a92421ab045b", size = 13160039, upload-time = "2025-12-04T15:06:49.06Z" },
341
- { url = "https://files.pythonhosted.org/packages/1f/ad/b69d6962e477842e25c0b11622548df746290cc6d76f9e0f4ed7456c2c31/ruff-0.14.8-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:f74f7ba163b6e85a8d81a590363bf71618847e5078d90827749bfda1d88c9cdf", size = 13205158, upload-time = "2025-12-04T15:06:54.574Z" },
342
- { url = "https://files.pythonhosted.org/packages/06/63/54f23da1315c0b3dfc1bc03fbc34e10378918a20c0b0f086418734e57e74/ruff-0.14.8-py3-none-musllinux_1_2_i686.whl", hash = "sha256:eed28f6fafcc9591994c42254f5a5c5ca40e69a30721d2ab18bb0bb3baac3ab6", size = 13469550, upload-time = "2025-12-04T15:05:59.209Z" },
343
- { url = "https://files.pythonhosted.org/packages/70/7d/a4d7b1961e4903bc37fffb7ddcfaa7beb250f67d97cfd1ee1d5cddb1ec90/ruff-0.14.8-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:21d48fa744c9d1cb8d71eb0a740c4dd02751a5de9db9a730a8ef75ca34cf138e", size = 14211332, upload-time = "2025-12-04T15:06:06.027Z" },
344
- { url = "https://files.pythonhosted.org/packages/5d/93/2a5063341fa17054e5c86582136e9895db773e3c2ffb770dde50a09f35f0/ruff-0.14.8-py3-none-win32.whl", hash = "sha256:15f04cb45c051159baebb0f0037f404f1dc2f15a927418f29730f411a79bc4e7", size = 13151890, upload-time = "2025-12-04T15:06:11.668Z" },
345
- { url = "https://files.pythonhosted.org/packages/02/1c/65c61a0859c0add13a3e1cbb6024b42de587456a43006ca2d4fd3d1618fe/ruff-0.14.8-py3-none-win_amd64.whl", hash = "sha256:9eeb0b24242b5bbff3011409a739929f497f3fb5fe3b5698aba5e77e8c833097", size = 14537826, upload-time = "2025-12-04T15:06:26.409Z" },
346
- { url = "https://files.pythonhosted.org/packages/6d/63/8b41cea3afd7f58eb64ac9251668ee0073789a3bc9ac6f816c8c6fef986d/ruff-0.14.8-py3-none-win_arm64.whl", hash = "sha256:965a582c93c63fe715fd3e3f8aa37c4b776777203d8e1d8aa3cc0c14424a4b99", size = 13634522, upload-time = "2025-12-04T15:06:43.212Z" },
347
  ]
348
 
349
  [[package]]
@@ -382,15 +384,15 @@ wheels = [
382
 
383
  [[package]]
384
  name = "uvicorn"
385
- version = "0.38.0"
386
  source = { registry = "https://pypi.org/simple" }
387
  dependencies = [
388
  { name = "click" },
389
  { name = "h11" },
390
  ]
391
- sdist = { url = "https://files.pythonhosted.org/packages/cb/ce/f06b84e2697fef4688ca63bdb2fdf113ca0a3be33f94488f2cadb690b0cf/uvicorn-0.38.0.tar.gz", hash = "sha256:fd97093bdd120a2609fc0d3afe931d4d4ad688b6e75f0f929fde1bc36fe0e91d", size = 80605, upload-time = "2025-10-18T13:46:44.63Z" }
392
  wheels = [
393
- { url = "https://files.pythonhosted.org/packages/ee/d9/d88e73ca598f4f6ff671fb5fde8a32925c2e08a637303a1d12883c7305fa/uvicorn-0.38.0-py3-none-any.whl", hash = "sha256:48c0afd214ceb59340075b4a052ea1ee91c16fbc2a9b1469cca0e54566977b02", size = 68109, upload-time = "2025-10-18T13:46:42.958Z" },
394
  ]
395
 
396
  [[package]]
 
22
 
23
  [[package]]
24
  name = "anyio"
25
+ version = "4.12.1"
26
  source = { registry = "https://pypi.org/simple" }
27
  dependencies = [
28
  { name = "idna" },
29
  { name = "typing-extensions" },
30
  ]
31
+ sdist = { url = "https://files.pythonhosted.org/packages/96/f0/5eb65b2bb0d09ac6776f2eb54adee6abe8228ea05b20a5ad0e4945de8aac/anyio-4.12.1.tar.gz", hash = "sha256:41cfcc3a4c85d3f05c932da7c26d0201ac36f72abd4435ba90d0464a3ffed703", size = 228685, upload-time = "2026-01-06T11:45:21.246Z" }
32
  wheels = [
33
+ { url = "https://files.pythonhosted.org/packages/38/0e/27be9fdef66e72d64c0cdc3cc2823101b80585f8119b5c112c2e8f5f7dab/anyio-4.12.1-py3-none-any.whl", hash = "sha256:d405828884fc140aa80a3c667b8beed277f1dfedec42ba031bd6ac3db606ab6c", size = 113592, upload-time = "2026-01-06T11:45:19.497Z" },
34
  ]
35
 
36
  [[package]]
37
  name = "certifi"
38
+ version = "2026.1.4"
39
  source = { registry = "https://pypi.org/simple" }
40
+ sdist = { url = "https://files.pythonhosted.org/packages/e0/2d/a891ca51311197f6ad14a7ef42e2399f36cf2f9bd44752b3dc4eab60fdc5/certifi-2026.1.4.tar.gz", hash = "sha256:ac726dd470482006e014ad384921ed6438c457018f4b3d204aea4281258b2120", size = 154268, upload-time = "2026-01-04T02:42:41.825Z" }
41
  wheels = [
42
+ { url = "https://files.pythonhosted.org/packages/e6/ad/3cc14f097111b4de0040c83a525973216457bbeeb63739ef1ed275c1c021/certifi-2026.1.4-py3-none-any.whl", hash = "sha256:9943707519e4add1115f44c2bc244f782c0249876bf51b6599fee1ffbedd685c", size = 152900, upload-time = "2026-01-04T02:42:40.15Z" },
43
  ]
44
 
45
  [[package]]
 
65
 
66
  [[package]]
67
  name = "fastapi"
68
+ version = "0.128.0"
69
  source = { registry = "https://pypi.org/simple" }
70
  dependencies = [
71
  { name = "annotated-doc" },
 
73
  { name = "starlette" },
74
  { name = "typing-extensions" },
75
  ]
76
+ sdist = { url = "https://files.pythonhosted.org/packages/52/08/8c8508db6c7b9aae8f7175046af41baad690771c9bcde676419965e338c7/fastapi-0.128.0.tar.gz", hash = "sha256:1cc179e1cef10a6be60ffe429f79b829dce99d8de32d7acb7e6c8dfdf7f2645a", size = 365682, upload-time = "2025-12-27T15:21:13.714Z" }
77
  wheels = [
78
+ { url = "https://files.pythonhosted.org/packages/5c/05/5cbb59154b093548acd0f4c7c474a118eda06da25aa75c616b72d8fcd92a/fastapi-0.128.0-py3-none-any.whl", hash = "sha256:aebd93f9716ee3b4f4fcfe13ffb7cf308d99c9f3ab5622d8877441072561582d", size = 103094, upload-time = "2025-12-27T15:21:12.154Z" },
79
  ]
80
 
81
  [[package]]
 
87
  { name = "gemini-webapi" },
88
  { name = "lmdb" },
89
  { name = "loguru" },
90
+ { name = "orjson" },
91
  { name = "pydantic-settings", extra = ["yaml"] },
92
  { name = "uvicorn" },
93
  { name = "uvloop", marker = "sys_platform != 'win32'" },
 
105
 
106
  [package.metadata]
107
  requires-dist = [
108
+ { name = "fastapi", specifier = ">=0.128.0" },
109
+ { name = "gemini-webapi", specifier = ">=1.17.3" },
110
+ { name = "lmdb", specifier = ">=1.7.5" },
111
+ { name = "loguru", specifier = ">=0.7.3" },
112
+ { name = "orjson", specifier = ">=3.11.5" },
113
+ { name = "pydantic-settings", extras = ["yaml"], specifier = ">=2.12.0" },
114
+ { name = "ruff", marker = "extra == 'dev'", specifier = ">=0.14.14" },
115
+ { name = "uvicorn", specifier = ">=0.40.0" },
116
+ { name = "uvloop", marker = "sys_platform != 'win32'", specifier = ">=0.22.1" },
117
  ]
118
  provides-extras = ["dev"]
119
 
120
  [package.metadata.requires-dev]
121
+ dev = [{ name = "ruff", specifier = ">=0.14.14" }]
122
 
123
  [[package]]
124
  name = "gemini-webapi"
 
211
 
212
  [[package]]
213
  name = "orjson"
214
+ version = "3.11.5"
215
  source = { registry = "https://pypi.org/simple" }
216
+ sdist = { url = "https://files.pythonhosted.org/packages/04/b8/333fdb27840f3bf04022d21b654a35f58e15407183aeb16f3b41aa053446/orjson-3.11.5.tar.gz", hash = "sha256:82393ab47b4fe44ffd0a7659fa9cfaacc717eb617c93cde83795f14af5c2e9d5", size = 5972347, upload-time = "2025-12-06T15:55:39.458Z" }
217
  wheels = [
218
+ { url = "https://files.pythonhosted.org/packages/ef/a4/8052a029029b096a78955eadd68ab594ce2197e24ec50e6b6d2ab3f4e33b/orjson-3.11.5-cp312-cp312-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:334e5b4bff9ad101237c2d799d9fd45737752929753bf4faf4b207335a416b7d", size = 245347, upload-time = "2025-12-06T15:54:22.061Z" },
219
+ { url = "https://files.pythonhosted.org/packages/64/67/574a7732bd9d9d79ac620c8790b4cfe0717a3d5a6eb2b539e6e8995e24a0/orjson-3.11.5-cp312-cp312-macosx_15_0_arm64.whl", hash = "sha256:ff770589960a86eae279f5d8aa536196ebda8273a2a07db2a54e82b93bc86626", size = 129435, upload-time = "2025-12-06T15:54:23.615Z" },
220
+ { url = "https://files.pythonhosted.org/packages/52/8d/544e77d7a29d90cf4d9eecd0ae801c688e7f3d1adfa2ebae5e1e94d38ab9/orjson-3.11.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ed24250e55efbcb0b35bed7caaec8cedf858ab2f9f2201f17b8938c618c8ca6f", size = 132074, upload-time = "2025-12-06T15:54:24.694Z" },
221
+ { url = "https://files.pythonhosted.org/packages/6e/57/b9f5b5b6fbff9c26f77e785baf56ae8460ef74acdb3eae4931c25b8f5ba9/orjson-3.11.5-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a66d7769e98a08a12a139049aac2f0ca3adae989817f8c43337455fbc7669b85", size = 130520, upload-time = "2025-12-06T15:54:26.185Z" },
222
+ { url = "https://files.pythonhosted.org/packages/f6/6d/d34970bf9eb33f9ec7c979a262cad86076814859e54eb9a059a52f6dc13d/orjson-3.11.5-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:86cfc555bfd5794d24c6a1903e558b50644e5e68e6471d66502ce5cb5fdef3f9", size = 136209, upload-time = "2025-12-06T15:54:27.264Z" },
223
+ { url = "https://files.pythonhosted.org/packages/e7/39/bc373b63cc0e117a105ea12e57280f83ae52fdee426890d57412432d63b3/orjson-3.11.5-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a230065027bc2a025e944f9d4714976a81e7ecfa940923283bca7bbc1f10f626", size = 139837, upload-time = "2025-12-06T15:54:28.75Z" },
224
+ { url = "https://files.pythonhosted.org/packages/cb/aa/7c4818c8d7d324da220f4f1af55c343956003aa4d1ce1857bdc1d396ba69/orjson-3.11.5-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b29d36b60e606df01959c4b982729c8845c69d1963f88686608be9ced96dbfaa", size = 137307, upload-time = "2025-12-06T15:54:29.856Z" },
225
+ { url = "https://files.pythonhosted.org/packages/46/bf/0993b5a056759ba65145effe3a79dd5a939d4a070eaa5da2ee3180fbb13f/orjson-3.11.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c74099c6b230d4261fdc3169d50efc09abf38ace1a42ea2f9994b1d79153d477", size = 139020, upload-time = "2025-12-06T15:54:31.024Z" },
226
+ { url = "https://files.pythonhosted.org/packages/65/e8/83a6c95db3039e504eda60fc388f9faedbb4f6472f5aba7084e06552d9aa/orjson-3.11.5-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e697d06ad57dd0c7a737771d470eedc18e68dfdefcdd3b7de7f33dfda5b6212e", size = 141099, upload-time = "2025-12-06T15:54:32.196Z" },
227
+ { url = "https://files.pythonhosted.org/packages/b9/b4/24fdc024abfce31c2f6812973b0a693688037ece5dc64b7a60c1ce69e2f2/orjson-3.11.5-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:e08ca8a6c851e95aaecc32bc44a5aa75d0ad26af8cdac7c77e4ed93acf3d5b69", size = 413540, upload-time = "2025-12-06T15:54:33.361Z" },
228
+ { url = "https://files.pythonhosted.org/packages/d9/37/01c0ec95d55ed0c11e4cae3e10427e479bba40c77312b63e1f9665e0737d/orjson-3.11.5-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:e8b5f96c05fce7d0218df3fdfeb962d6b8cfff7e3e20264306b46dd8b217c0f3", size = 151530, upload-time = "2025-12-06T15:54:34.6Z" },
229
+ { url = "https://files.pythonhosted.org/packages/f9/d4/f9ebc57182705bb4bbe63f5bbe14af43722a2533135e1d2fb7affa0c355d/orjson-3.11.5-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ddbfdb5099b3e6ba6d6ea818f61997bb66de14b411357d24c4612cf1ebad08ca", size = 141863, upload-time = "2025-12-06T15:54:35.801Z" },
230
+ { url = "https://files.pythonhosted.org/packages/0d/04/02102b8d19fdcb009d72d622bb5781e8f3fae1646bf3e18c53d1bc8115b5/orjson-3.11.5-cp312-cp312-win32.whl", hash = "sha256:9172578c4eb09dbfcf1657d43198de59b6cef4054de385365060ed50c458ac98", size = 135255, upload-time = "2025-12-06T15:54:37.209Z" },
231
+ { url = "https://files.pythonhosted.org/packages/d4/fb/f05646c43d5450492cb387de5549f6de90a71001682c17882d9f66476af5/orjson-3.11.5-cp312-cp312-win_amd64.whl", hash = "sha256:2b91126e7b470ff2e75746f6f6ee32b9ab67b7a93c8ba1d15d3a0caaf16ec875", size = 133252, upload-time = "2025-12-06T15:54:38.401Z" },
232
+ { url = "https://files.pythonhosted.org/packages/dc/a6/7b8c0b26ba18c793533ac1cd145e131e46fcf43952aa94c109b5b913c1f0/orjson-3.11.5-cp312-cp312-win_arm64.whl", hash = "sha256:acbc5fac7e06777555b0722b8ad5f574739e99ffe99467ed63da98f97f9ca0fe", size = 126777, upload-time = "2025-12-06T15:54:39.515Z" },
233
  ]
234
 
235
  [[package]]
 
324
 
325
  [[package]]
326
  name = "ruff"
327
+ version = "0.14.14"
328
  source = { registry = "https://pypi.org/simple" }
329
+ sdist = { url = "https://files.pythonhosted.org/packages/2e/06/f71e3a86b2df0dfa2d2f72195941cd09b44f87711cb7fa5193732cb9a5fc/ruff-0.14.14.tar.gz", hash = "sha256:2d0f819c9a90205f3a867dbbd0be083bee9912e170fd7d9704cc8ae45824896b", size = 4515732, upload-time = "2026-01-22T22:30:17.527Z" }
330
  wheels = [
331
+ { url = "https://files.pythonhosted.org/packages/d2/89/20a12e97bc6b9f9f68343952da08a8099c57237aef953a56b82711d55edd/ruff-0.14.14-py3-none-linux_armv6l.whl", hash = "sha256:7cfe36b56e8489dee8fbc777c61959f60ec0f1f11817e8f2415f429552846aed", size = 10467650, upload-time = "2026-01-22T22:30:08.578Z" },
332
+ { url = "https://files.pythonhosted.org/packages/a3/b1/c5de3fd2d5a831fcae21beda5e3589c0ba67eec8202e992388e4b17a6040/ruff-0.14.14-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:6006a0082336e7920b9573ef8a7f52eec837add1265cc74e04ea8a4368cd704c", size = 10883245, upload-time = "2026-01-22T22:30:04.155Z" },
333
+ { url = "https://files.pythonhosted.org/packages/b8/7c/3c1db59a10e7490f8f6f8559d1db8636cbb13dccebf18686f4e3c9d7c772/ruff-0.14.14-py3-none-macosx_11_0_arm64.whl", hash = "sha256:026c1d25996818f0bf498636686199d9bd0d9d6341c9c2c3b62e2a0198b758de", size = 10231273, upload-time = "2026-01-22T22:30:34.642Z" },
334
+ { url = "https://files.pythonhosted.org/packages/a1/6e/5e0e0d9674be0f8581d1f5e0f0a04761203affce3232c1a1189d0e3b4dad/ruff-0.14.14-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f666445819d31210b71e0a6d1c01e24447a20b85458eea25a25fe8142210ae0e", size = 10585753, upload-time = "2026-01-22T22:30:31.781Z" },
335
+ { url = "https://files.pythonhosted.org/packages/23/09/754ab09f46ff1884d422dc26d59ba18b4e5d355be147721bb2518aa2a014/ruff-0.14.14-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3c0f18b922c6d2ff9a5e6c3ee16259adc513ca775bcf82c67ebab7cbd9da5bc8", size = 10286052, upload-time = "2026-01-22T22:30:24.827Z" },
336
+ { url = "https://files.pythonhosted.org/packages/c8/cc/e71f88dd2a12afb5f50733851729d6b571a7c3a35bfdb16c3035132675a0/ruff-0.14.14-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1629e67489c2dea43e8658c3dba659edbfd87361624b4040d1df04c9740ae906", size = 11043637, upload-time = "2026-01-22T22:30:13.239Z" },
337
+ { url = "https://files.pythonhosted.org/packages/67/b2/397245026352494497dac935d7f00f1468c03a23a0c5db6ad8fc49ca3fb2/ruff-0.14.14-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:27493a2131ea0f899057d49d303e4292b2cae2bb57253c1ed1f256fbcd1da480", size = 12194761, upload-time = "2026-01-22T22:30:22.542Z" },
338
+ { url = "https://files.pythonhosted.org/packages/5b/06/06ef271459f778323112c51b7587ce85230785cd64e91772034ddb88f200/ruff-0.14.14-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:01ff589aab3f5b539e35db38425da31a57521efd1e4ad1ae08fc34dbe30bd7df", size = 12005701, upload-time = "2026-01-22T22:30:20.499Z" },
339
+ { url = "https://files.pythonhosted.org/packages/41/d6/99364514541cf811ccc5ac44362f88df66373e9fec1b9d1c4cc830593fe7/ruff-0.14.14-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1cc12d74eef0f29f51775f5b755913eb523546b88e2d733e1d701fe65144e89b", size = 11282455, upload-time = "2026-01-22T22:29:59.679Z" },
340
+ { url = "https://files.pythonhosted.org/packages/ca/71/37daa46f89475f8582b7762ecd2722492df26421714a33e72ccc9a84d7a5/ruff-0.14.14-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bb8481604b7a9e75eff53772496201690ce2687067e038b3cc31aaf16aa0b974", size = 11215882, upload-time = "2026-01-22T22:29:57.032Z" },
341
+ { url = "https://files.pythonhosted.org/packages/2c/10/a31f86169ec91c0705e618443ee74ede0bdd94da0a57b28e72db68b2dbac/ruff-0.14.14-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:14649acb1cf7b5d2d283ebd2f58d56b75836ed8c6f329664fa91cdea19e76e66", size = 11180549, upload-time = "2026-01-22T22:30:27.175Z" },
342
+ { url = "https://files.pythonhosted.org/packages/fd/1e/c723f20536b5163adf79bdd10c5f093414293cdf567eed9bdb7b83940f3f/ruff-0.14.14-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:e8058d2145566510790eab4e2fad186002e288dec5e0d343a92fe7b0bc1b3e13", size = 10543416, upload-time = "2026-01-22T22:30:01.964Z" },
343
+ { url = "https://files.pythonhosted.org/packages/3e/34/8a84cea7e42c2d94ba5bde1d7a4fae164d6318f13f933d92da6d7c2041ff/ruff-0.14.14-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:e651e977a79e4c758eb807f0481d673a67ffe53cfa92209781dfa3a996cf8412", size = 10285491, upload-time = "2026-01-22T22:30:29.51Z" },
344
+ { url = "https://files.pythonhosted.org/packages/55/ef/b7c5ea0be82518906c978e365e56a77f8de7678c8bb6651ccfbdc178c29f/ruff-0.14.14-py3-none-musllinux_1_2_i686.whl", hash = "sha256:cc8b22da8d9d6fdd844a68ae937e2a0adf9b16514e9a97cc60355e2d4b219fc3", size = 10733525, upload-time = "2026-01-22T22:30:06.499Z" },
345
+ { url = "https://files.pythonhosted.org/packages/6a/5b/aaf1dfbcc53a2811f6cc0a1759de24e4b03e02ba8762daabd9b6bd8c59e3/ruff-0.14.14-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:16bc890fb4cc9781bb05beb5ab4cd51be9e7cb376bf1dd3580512b24eb3fda2b", size = 11315626, upload-time = "2026-01-22T22:30:36.848Z" },
346
+ { url = "https://files.pythonhosted.org/packages/2c/aa/9f89c719c467dfaf8ad799b9bae0df494513fb21d31a6059cb5870e57e74/ruff-0.14.14-py3-none-win32.whl", hash = "sha256:b530c191970b143375b6a68e6f743800b2b786bbcf03a7965b06c4bf04568167", size = 10502442, upload-time = "2026-01-22T22:30:38.93Z" },
347
+ { url = "https://files.pythonhosted.org/packages/87/44/90fa543014c45560cae1fffc63ea059fb3575ee6e1cb654562197e5d16fb/ruff-0.14.14-py3-none-win_amd64.whl", hash = "sha256:3dde1435e6b6fe5b66506c1dff67a421d0b7f6488d466f651c07f4cab3bf20fd", size = 11630486, upload-time = "2026-01-22T22:30:10.852Z" },
348
+ { url = "https://files.pythonhosted.org/packages/9e/6a/40fee331a52339926a92e17ae748827270b288a35ef4a15c9c8f2ec54715/ruff-0.14.14-py3-none-win_arm64.whl", hash = "sha256:56e6981a98b13a32236a72a8da421d7839221fa308b223b9283312312e5ac76c", size = 10920448, upload-time = "2026-01-22T22:30:15.417Z" },
349
  ]
350
 
351
  [[package]]
 
384
 
385
  [[package]]
386
  name = "uvicorn"
387
+ version = "0.40.0"
388
  source = { registry = "https://pypi.org/simple" }
389
  dependencies = [
390
  { name = "click" },
391
  { name = "h11" },
392
  ]
393
+ sdist = { url = "https://files.pythonhosted.org/packages/c3/d1/8f3c683c9561a4e6689dd3b1d345c815f10f86acd044ee1fb9a4dcd0b8c5/uvicorn-0.40.0.tar.gz", hash = "sha256:839676675e87e73694518b5574fd0f24c9d97b46bea16df7b8c05ea1a51071ea", size = 81761, upload-time = "2025-12-21T14:16:22.45Z" }
394
  wheels = [
395
+ { url = "https://files.pythonhosted.org/packages/3d/d8/2083a1daa7439a66f3a48589a57d576aa117726762618f6bb09fe3798796/uvicorn-0.40.0-py3-none-any.whl", hash = "sha256:c6c8f55bc8bf13eb6fa9ff87ad62308bbbc33d0b67f84293151efe87e0d5f2ee", size = 68502, upload-time = "2025-12-21T14:16:21.041Z" },
396
  ]
397
 
398
  [[package]]