Spaces:
Paused
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 +2 -0
- app/models/models.py +8 -0
- app/server/chat.py +105 -82
- app/services/client.py +5 -5
- app/services/lmdb.py +128 -21
- app/utils/config.py +7 -7
- app/utils/helper.py +12 -6
- pyproject.toml +11 -10
- uv.lock +60 -58
|
@@ -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)
|
|
@@ -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"""
|
|
@@ -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 =
|
| 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 =
|
| 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
|
| 275 |
-
|
| 276 |
-
|
| 277 |
-
|
| 278 |
-
|
| 279 |
-
|
| 280 |
-
|
| 281 |
-
|
| 282 |
-
|
| 283 |
-
|
|
|
|
| 284 |
|
| 285 |
-
|
| 286 |
-
|
| 287 |
-
|
| 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,
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 =
|
| 628 |
-
except
|
| 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 =
|
| 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 |
-
|
| 648 |
role="assistant",
|
| 649 |
content=storage_output or None,
|
| 650 |
tool_calls=tool_calls or None,
|
| 651 |
)
|
| 652 |
-
|
|
|
|
|
|
|
|
|
|
| 653 |
conv = ConversationInStore(
|
| 654 |
model=model.model_name,
|
| 655 |
client_id=client.id,
|
| 656 |
metadata=session.metadata,
|
| 657 |
-
messages=
|
| 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=
|
| 786 |
-
tool_choice=
|
| 787 |
-
extra_instructions=
|
|
|
|
| 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 =
|
| 868 |
-
except
|
| 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 =
|
| 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 |
-
|
| 1000 |
role="assistant",
|
| 1001 |
content=storage_output or None,
|
| 1002 |
tool_calls=detected_tool_calls or None,
|
| 1003 |
)
|
| 1004 |
-
|
|
|
|
|
|
|
| 1005 |
conv = ConversationInStore(
|
| 1006 |
model=model.model_name,
|
| 1007 |
client_id=client.id,
|
| 1008 |
metadata=session.metadata,
|
| 1009 |
-
messages=
|
| 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 |
-
|
| 1061 |
-
|
| 1062 |
-
|
| 1063 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1064 |
except Exception as e:
|
| 1065 |
-
logger.warning(
|
|
|
|
|
|
|
| 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 |
-
"""
|
| 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 |
-
|
| 1091 |
-
|
| 1092 |
-
|
| 1093 |
-
|
| 1094 |
-
|
| 1095 |
-
|
| 1096 |
-
|
| 1097 |
-
|
| 1098 |
-
|
| 1099 |
-
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1110 |
except Exception as e:
|
| 1111 |
-
logger.exception(f"Error sending
|
| 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,
|
|
@@ -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 =
|
| 126 |
-
args_text =
|
| 127 |
-
except
|
| 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" + "
|
| 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)
|
|
@@ -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 |
-
|
| 20 |
-
|
| 21 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 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,
|
|
|
|
|
|
|
| 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
|
| 426 |
"""
|
| 427 |
-
|
|
|
|
| 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
|
|
|
|
|
|
|
|
|
|
|
|
|
| 435 |
"""
|
| 436 |
cleaned_messages = []
|
| 437 |
for msg in messages:
|
| 438 |
-
if msg.role == "assistant"
|
| 439 |
-
|
| 440 |
-
|
| 441 |
-
|
| 442 |
-
|
| 443 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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:
|
|
@@ -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
|
| 69 |
-
except
|
| 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
|
| 104 |
-
except
|
| 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 =
|
| 286 |
parsed_successfully = True
|
| 287 |
-
except
|
| 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
|
|
@@ -1,15 +1,15 @@
|
|
| 1 |
import base64
|
| 2 |
-
import
|
| 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 =
|
| 225 |
-
arguments =
|
| 226 |
-
except
|
| 227 |
logger.warning(f"Failed to parse tool call arguments for '{name}'. Passing raw string.")
|
| 228 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 229 |
tool_calls.append(
|
| 230 |
ToolCall(
|
| 231 |
-
id=
|
| 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 |
)
|
|
@@ -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.
|
| 9 |
-
"gemini-webapi>=1.17.
|
| 10 |
-
"lmdb>=1.
|
| 11 |
-
"loguru>=0.7.
|
| 12 |
-
"
|
| 13 |
-
"
|
| 14 |
-
"
|
|
|
|
| 15 |
]
|
| 16 |
|
| 17 |
[project.optional-dependencies]
|
| 18 |
dev = [
|
| 19 |
-
"ruff>=0.
|
| 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.
|
| 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 |
]
|
|
@@ -22,24 +22,24 @@ wheels = [
|
|
| 22 |
|
| 23 |
[[package]]
|
| 24 |
name = "anyio"
|
| 25 |
-
version = "4.12.
|
| 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/
|
| 32 |
wheels = [
|
| 33 |
-
{ url = "https://files.pythonhosted.org/packages/
|
| 34 |
]
|
| 35 |
|
| 36 |
[[package]]
|
| 37 |
name = "certifi"
|
| 38 |
-
version = "
|
| 39 |
source = { registry = "https://pypi.org/simple" }
|
| 40 |
-
sdist = { url = "https://files.pythonhosted.org/packages/
|
| 41 |
wheels = [
|
| 42 |
-
{ url = "https://files.pythonhosted.org/packages/
|
| 43 |
]
|
| 44 |
|
| 45 |
[[package]]
|
|
@@ -65,7 +65,7 @@ wheels = [
|
|
| 65 |
|
| 66 |
[[package]]
|
| 67 |
name = "fastapi"
|
| 68 |
-
version = "0.
|
| 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/
|
| 77 |
wheels = [
|
| 78 |
-
{ url = "https://files.pythonhosted.org/packages/
|
| 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.
|
| 108 |
-
{ name = "gemini-webapi", specifier = ">=1.17.
|
| 109 |
-
{ name = "lmdb", specifier = ">=1.
|
| 110 |
-
{ name = "loguru", specifier = ">=0.7.
|
| 111 |
-
{ name = "
|
| 112 |
-
{ name = "
|
| 113 |
-
{ name = "
|
| 114 |
-
{ name = "
|
|
|
|
| 115 |
]
|
| 116 |
provides-extras = ["dev"]
|
| 117 |
|
| 118 |
[package.metadata.requires-dev]
|
| 119 |
-
dev = [{ name = "ruff", specifier = ">=0.
|
| 120 |
|
| 121 |
[[package]]
|
| 122 |
name = "gemini-webapi"
|
|
@@ -209,25 +211,25 @@ wheels = [
|
|
| 209 |
|
| 210 |
[[package]]
|
| 211 |
name = "orjson"
|
| 212 |
-
version = "3.11.
|
| 213 |
source = { registry = "https://pypi.org/simple" }
|
| 214 |
-
sdist = { url = "https://files.pythonhosted.org/packages/
|
| 215 |
wheels = [
|
| 216 |
-
{ url = "https://files.pythonhosted.org/packages/
|
| 217 |
-
{ url = "https://files.pythonhosted.org/packages/
|
| 218 |
-
{ url = "https://files.pythonhosted.org/packages/
|
| 219 |
-
{ url = "https://files.pythonhosted.org/packages/
|
| 220 |
-
{ url = "https://files.pythonhosted.org/packages/
|
| 221 |
-
{ url = "https://files.pythonhosted.org/packages/
|
| 222 |
-
{ url = "https://files.pythonhosted.org/packages/
|
| 223 |
-
{ url = "https://files.pythonhosted.org/packages/
|
| 224 |
-
{ url = "https://files.pythonhosted.org/packages/
|
| 225 |
-
{ url = "https://files.pythonhosted.org/packages/
|
| 226 |
-
{ url = "https://files.pythonhosted.org/packages/
|
| 227 |
-
{ url = "https://files.pythonhosted.org/packages/
|
| 228 |
-
{ url = "https://files.pythonhosted.org/packages/
|
| 229 |
-
{ url = "https://files.pythonhosted.org/packages/
|
| 230 |
-
{ url = "https://files.pythonhosted.org/packages/
|
| 231 |
]
|
| 232 |
|
| 233 |
[[package]]
|
|
@@ -322,28 +324,28 @@ wheels = [
|
|
| 322 |
|
| 323 |
[[package]]
|
| 324 |
name = "ruff"
|
| 325 |
-
version = "0.14.
|
| 326 |
source = { registry = "https://pypi.org/simple" }
|
| 327 |
-
sdist = { url = "https://files.pythonhosted.org/packages/
|
| 328 |
wheels = [
|
| 329 |
-
{ url = "https://files.pythonhosted.org/packages/
|
| 330 |
-
{ url = "https://files.pythonhosted.org/packages/
|
| 331 |
-
{ url = "https://files.pythonhosted.org/packages/
|
| 332 |
-
{ url = "https://files.pythonhosted.org/packages/
|
| 333 |
-
{ url = "https://files.pythonhosted.org/packages/
|
| 334 |
-
{ url = "https://files.pythonhosted.org/packages/
|
| 335 |
-
{ url = "https://files.pythonhosted.org/packages/
|
| 336 |
-
{ url = "https://files.pythonhosted.org/packages/
|
| 337 |
-
{ url = "https://files.pythonhosted.org/packages/
|
| 338 |
-
{ url = "https://files.pythonhosted.org/packages/
|
| 339 |
-
{ url = "https://files.pythonhosted.org/packages/
|
| 340 |
-
{ url = "https://files.pythonhosted.org/packages/
|
| 341 |
-
{ url = "https://files.pythonhosted.org/packages/
|
| 342 |
-
{ url = "https://files.pythonhosted.org/packages/
|
| 343 |
-
{ url = "https://files.pythonhosted.org/packages/
|
| 344 |
-
{ url = "https://files.pythonhosted.org/packages/
|
| 345 |
-
{ url = "https://files.pythonhosted.org/packages/
|
| 346 |
-
{ url = "https://files.pythonhosted.org/packages/
|
| 347 |
]
|
| 348 |
|
| 349 |
[[package]]
|
|
@@ -382,15 +384,15 @@ wheels = [
|
|
| 382 |
|
| 383 |
[[package]]
|
| 384 |
name = "uvicorn"
|
| 385 |
-
version = "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/
|
| 392 |
wheels = [
|
| 393 |
-
{ url = "https://files.pythonhosted.org/packages/
|
| 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]]
|