diff --git a/app/main.py b/app/main.py
index c215e2a..307eb36 100644
--- a/app/main.py
+++ b/app/main.py
@@ -2,6 +2,7 @@
from contextlib import asynccontextmanager
from fastapi import FastAPI
+from fastapi.responses import ORJSONResponse
from loguru import logger
from .server.chat import router as chat_router
@@ -92,6 +93,7 @@ def create_app() -> FastAPI:
description="OpenAI-compatible API for Gemini Web",
version="1.0.0",
lifespan=lifespan,
+ default_response_class=ORJSONResponse,
)
add_cors_middleware(app)
diff --git a/app/models/models.py b/app/models/models.py
index c27e024..4072b29 100644
--- a/app/models/models.py
+++ b/app/models/models.py
@@ -24,11 +24,19 @@ class Message(BaseModel):
content: Union[str, List[ContentItem], None] = None
name: Optional[str] = None
tool_calls: Optional[List["ToolCall"]] = None
+ tool_call_id: Optional[str] = None
refusal: Optional[str] = None
reasoning_content: Optional[str] = None
audio: Optional[Dict[str, Any]] = None
annotations: List[Dict[str, Any]] = Field(default_factory=list)
+ @model_validator(mode="after")
+ def normalize_role(self) -> "Message":
+ """Normalize 'developer' role to 'system' for Gemini compatibility."""
+ if self.role == "developer":
+ self.role = "system"
+ return self
+
class Choice(BaseModel):
"""Choice model"""
diff --git a/app/server/chat.py b/app/server/chat.py
index 6e517ea..063d4d4 100644
--- a/app/server/chat.py
+++ b/app/server/chat.py
@@ -1,6 +1,6 @@
import base64
-import json
import re
+import tempfile
import uuid
from dataclasses import dataclass
from datetime import datetime, timezone
@@ -57,6 +57,7 @@
# Maximum characters Gemini Web can accept in a single request (configurable)
MAX_CHARS_PER_REQUEST = int(g_config.gemini.max_chars_per_request * 0.9)
CONTINUATION_HINT = "\n(More messages to come, please reply with just 'ok.')"
+METADATA_TTL_MINUTES = 15
router = APIRouter()
@@ -95,7 +96,7 @@ def _build_structured_requirement(
schema_name = json_schema.get("name") or "response"
strict = json_schema.get("strict", True)
- pretty_schema = json.dumps(schema, ensure_ascii=False, indent=2, sort_keys=True)
+ pretty_schema = orjson.dumps(schema, option=orjson.OPT_SORT_KEYS).decode("utf-8")
instruction_parts = [
"You must respond with a single valid JSON document that conforms to the schema shown below.",
"Do not include explanations, comments, or any text before or after the JSON.",
@@ -135,7 +136,7 @@ def _build_tool_prompt(
description = function.description or "No description provided."
lines.append(f"Tool `{function.name}`: {description}")
if function.parameters:
- schema_text = json.dumps(function.parameters, ensure_ascii=False, indent=2)
+ schema_text = orjson.dumps(function.parameters).decode("utf-8")
lines.append("Arguments JSON schema:")
lines.append(schema_text)
else:
@@ -266,31 +267,35 @@ def _prepare_messages_for_model(
tools: list[Tool] | None,
tool_choice: str | ToolChoiceFunction | None,
extra_instructions: list[str] | None = None,
+ inject_system_defaults: bool = True,
) -> list[Message]:
"""Return a copy of messages enriched with tool instructions when needed."""
prepared = [msg.model_copy(deep=True) for msg in source_messages]
instructions: list[str] = []
- if tools:
- tool_prompt = _build_tool_prompt(tools, tool_choice)
- if tool_prompt:
- instructions.append(tool_prompt)
-
- if extra_instructions:
- instructions.extend(instr for instr in extra_instructions if instr)
- logger.debug(
- f"Applied {len(extra_instructions)} extra instructions for tool/structured output."
- )
+ if inject_system_defaults:
+ if tools:
+ tool_prompt = _build_tool_prompt(tools, tool_choice)
+ if tool_prompt:
+ instructions.append(tool_prompt)
+
+ if extra_instructions:
+ instructions.extend(instr for instr in extra_instructions if instr)
+ logger.debug(
+ f"Applied {len(extra_instructions)} extra instructions for tool/structured output."
+ )
- if not _conversation_has_code_hint(prepared):
- instructions.append(CODE_BLOCK_HINT)
- logger.debug("Injected default code block hint for Gemini conversation.")
+ if not _conversation_has_code_hint(prepared):
+ instructions.append(CODE_BLOCK_HINT)
+ logger.debug("Injected default code block hint for Gemini conversation.")
if not instructions:
+ # Still need to ensure XML hint for the last user message if tools are present
+ if tools and tool_choice != "none":
+ _append_xml_hint_to_last_user_message(prepared)
return prepared
combined_instructions = "\n\n".join(instructions)
-
if prepared and prepared[0].role == "system" and isinstance(prepared[0].content, str):
existing = prepared[0].content or ""
separator = "\n\n" if existing else ""
@@ -318,8 +323,6 @@ def _response_items_to_messages(
normalized_input: list[ResponseInputItem] = []
for item in items:
role = item.role
- if role == "developer":
- role = "system"
content = item.content
normalized_contents: list[ResponseInputContent] = []
@@ -371,9 +374,7 @@ def _response_items_to_messages(
ResponseInputItem(type="message", role=item.role, content=normalized_contents or [])
)
- logger.debug(
- f"Normalized Responses input: {len(normalized_input)} message items (developer roles mapped to system)."
- )
+ logger.debug(f"Normalized Responses input: {len(normalized_input)} message items.")
return messages, normalized_input
@@ -393,8 +394,6 @@ def _instructions_to_messages(
continue
role = item.role
- if role == "developer":
- role = "system"
content = item.content
if isinstance(content, str):
@@ -532,8 +531,14 @@ async def create_chat_completion(
)
if session:
+ # Optimization: When reusing a session, we don't need to resend the heavy tool definitions
+ # or structured output instructions as they are already in the Gemini session history.
messages_to_send = _prepare_messages_for_model(
- remaining_messages, request.tools, request.tool_choice, extra_instructions
+ remaining_messages,
+ request.tools,
+ request.tool_choice,
+ extra_instructions,
+ inject_system_defaults=False,
)
if not messages_to_send:
raise HTTPException(
@@ -624,8 +629,8 @@ async def create_chat_completion(
detail="LLM returned an empty response while JSON schema output was requested.",
)
try:
- structured_payload = json.loads(cleaned_visible)
- except json.JSONDecodeError as exc:
+ structured_payload = orjson.loads(cleaned_visible)
+ except orjson.JSONDecodeError as exc:
logger.warning(
f"Failed to decode JSON for structured response (schema={structured_requirement.schema_name}): "
f"{cleaned_visible}"
@@ -635,7 +640,7 @@ async def create_chat_completion(
detail="LLM returned invalid JSON for the requested response_format.",
) from exc
- canonical_output = json.dumps(structured_payload, ensure_ascii=False)
+ canonical_output = orjson.dumps(structured_payload).decode("utf-8")
visible_output = canonical_output
storage_output = canonical_output
@@ -644,17 +649,20 @@ async def create_chat_completion(
# After formatting, persist the conversation to LMDB
try:
- last_message = Message(
+ current_assistant_message = Message(
role="assistant",
content=storage_output or None,
tool_calls=tool_calls or None,
)
- cleaned_history = db.sanitize_assistant_messages(request.messages)
+ # Sanitize the entire history including the new message to ensure consistency
+ full_history = [*request.messages, current_assistant_message]
+ cleaned_history = db.sanitize_assistant_messages(full_history)
+
conv = ConversationInStore(
model=model.model_name,
client_id=client.id,
metadata=session.metadata,
- messages=[*cleaned_history, last_message],
+ messages=cleaned_history,
)
key = db.store(conv)
logger.debug(f"Conversation saved to LMDB with key: {key}")
@@ -782,9 +790,10 @@ async def _build_payload(
if reuse_session:
messages_to_send = _prepare_messages_for_model(
remaining_messages,
- tools=None,
- tool_choice=None,
- extra_instructions=extra_instructions or None,
+ tools=request_data.tools, # Keep for XML hint logic
+ tool_choice=request_data.tool_choice,
+ extra_instructions=None, # Already in session history
+ inject_system_defaults=False,
)
if not messages_to_send:
raise HTTPException(
@@ -864,8 +873,8 @@ async def _build_payload(
detail="LLM returned an empty response while JSON schema output was requested.",
)
try:
- structured_payload = json.loads(cleaned_visible)
- except json.JSONDecodeError as exc:
+ structured_payload = orjson.loads(cleaned_visible)
+ except orjson.JSONDecodeError as exc:
logger.warning(
f"Failed to decode JSON for structured response (schema={structured_requirement.schema_name}): "
f"{cleaned_visible}"
@@ -875,7 +884,7 @@ async def _build_payload(
detail="LLM returned invalid JSON for the requested response_format.",
) from exc
- canonical_output = json.dumps(structured_payload, ensure_ascii=False)
+ canonical_output = orjson.dumps(structured_payload).decode("utf-8")
assistant_text = canonical_output
storage_output = canonical_output
logger.debug(
@@ -996,17 +1005,19 @@ async def _build_payload(
)
try:
- last_message = Message(
+ current_assistant_message = Message(
role="assistant",
content=storage_output or None,
tool_calls=detected_tool_calls or None,
)
- cleaned_history = db.sanitize_assistant_messages(messages)
+ full_history = [*messages, current_assistant_message]
+ cleaned_history = db.sanitize_assistant_messages(full_history)
+
conv = ConversationInStore(
model=model.model_name,
client_id=client.id,
metadata=session.metadata,
- messages=[*cleaned_history, last_message],
+ messages=cleaned_history,
)
key = db.store(conv)
logger.debug(f"Conversation saved to LMDB with key: {key}")
@@ -1050,19 +1061,35 @@ async def _find_reusable_session(
# Start with the full history and iteratively trim from the end.
search_end = len(messages)
+
while search_end >= 2:
search_history = messages[:search_end]
- # Only try to match if the last stored message would be assistant/system.
- if search_history[-1].role in {"assistant", "system"}:
+ # Only try to match if the last stored message would be assistant/system/tool before querying LMDB.
+ if search_history[-1].role in {"assistant", "system", "tool"}:
try:
if conv := db.find(model.model_name, search_history):
- client = await pool.acquire(conv.client_id)
- session = client.start_chat(metadata=conv.metadata, model=model)
- remain = messages[search_end:]
- return session, client, remain
+ # Check if metadata is too old
+ now = datetime.now()
+ updated_at = conv.updated_at or conv.created_at or now
+ age_minutes = (now - updated_at).total_seconds() / 60
+
+ if age_minutes <= METADATA_TTL_MINUTES:
+ client = await pool.acquire(conv.client_id)
+ session = client.start_chat(metadata=conv.metadata, model=model)
+ remain = messages[search_end:]
+ logger.debug(
+ f"Match found at prefix length {search_end}. Client: {conv.client_id}"
+ )
+ return session, client, remain
+ else:
+ logger.debug(
+ f"Matched conversation is too old ({age_minutes:.1f}m), skipping reuse."
+ )
except Exception as e:
- logger.warning(f"Error checking LMDB for reusable session: {e}")
+ logger.warning(
+ f"Error checking LMDB for reusable session at length {search_end}: {e}"
+ )
break
# Trim one message and try again.
@@ -1072,52 +1099,48 @@ async def _find_reusable_session(
async def _send_with_split(session: ChatSession, text: str, files: list[Path | str] | None = None):
- """Send text to Gemini, automatically splitting into multiple batches if it is
- longer than ``MAX_CHARS_PER_REQUEST``.
-
- Every intermediate batch (that is **not** the last one) is suffixed with a hint
- telling Gemini that more content will come, and it should simply reply with
- "ok". The final batch carries any file uploads and the real user prompt so
- that Gemini can produce the actual answer.
+ """
+ Send text to Gemini. If text is longer than ``MAX_CHARS_PER_REQUEST``,
+ it is converted into a temporary text file attachment to avoid splitting issues.
"""
if len(text) <= MAX_CHARS_PER_REQUEST:
- # No need to split - a single request is fine.
try:
return await session.send_message(text, files=files)
except Exception as e:
logger.exception(f"Error sending message to Gemini: {e}")
raise
- hint_len = len(CONTINUATION_HINT)
- chunk_size = MAX_CHARS_PER_REQUEST - hint_len
-
- chunks: list[str] = []
- pos = 0
- total = len(text)
- while pos < total:
- end = min(pos + chunk_size, total)
- chunk = text[pos:end]
- pos = end
-
- # If this is NOT the last chunk, add the continuation hint.
- if end < total:
- chunk += CONTINUATION_HINT
- chunks.append(chunk)
-
- # Fire off all but the last chunk, discarding the interim "ok" replies.
- for chk in chunks[:-1]:
+
+ logger.info(
+ f"Message length ({len(text)}) exceeds limit ({MAX_CHARS_PER_REQUEST}). Converting text to file attachment."
+ )
+
+ # Create a temporary directory to hold the message.txt file
+ # This ensures the filename is exactly 'message.txt' as expected by the instruction.
+ with tempfile.TemporaryDirectory() as tmpdirname:
+ temp_file_path = Path(tmpdirname) / "message.txt"
+ temp_file_path.write_text(text, encoding="utf-8")
+
try:
- await session.send_message(chk)
+ # Prepare the files list
+ final_files = list(files) if files else []
+ final_files.append(temp_file_path)
+
+ instruction = (
+ "The user's input exceeds the character limit and is provided in the attached file `message.txt`.\n\n"
+ "**System Instruction:**\n"
+ "1. Read the content of `message.txt`.\n"
+ "2. Treat that content as the **primary** user prompt for this turn.\n"
+ "3. Execute the instructions or answer the questions found *inside* that file immediately.\n"
+ )
+
+ logger.debug(f"Sending prompt as temporary file: {temp_file_path}")
+
+ return await session.send_message(instruction, files=final_files)
+
except Exception as e:
- logger.exception(f"Error sending chunk to Gemini: {e}")
+ logger.exception(f"Error sending large text as file to Gemini: {e}")
raise
- # The last chunk carries the files (if any) and we return its response.
- try:
- return await session.send_message(chunks[-1], files=files)
- except Exception as e:
- logger.exception(f"Error sending final chunk to Gemini: {e}")
- raise
-
def _create_streaming_response(
model_output: str,
diff --git a/app/services/client.py b/app/services/client.py
index 87c0ca7..55be11a 100644
--- a/app/services/client.py
+++ b/app/services/client.py
@@ -1,9 +1,9 @@
import html
-import json
import re
from pathlib import Path
from typing import Any, cast
+import orjson
from gemini_webapi import GeminiClient, ModelOutput
from loguru import logger
@@ -122,9 +122,9 @@ async def process_message(
for call in message.tool_calls:
args_text = call.function.arguments.strip()
try:
- parsed_args = json.loads(args_text)
- args_text = json.dumps(parsed_args, ensure_ascii=False)
- except (json.JSONDecodeError, TypeError):
+ parsed_args = orjson.loads(args_text)
+ args_text = orjson.dumps(parsed_args).decode("utf-8")
+ except orjson.JSONDecodeError:
# Leave args_text as is if it is not valid JSON
pass
tool_blocks.append(
@@ -132,7 +132,7 @@ async def process_message(
)
if tool_blocks:
- tool_section = "```xml\n" + "\n".join(tool_blocks) + "\n```"
+ tool_section = "```xml\n" + "".join(tool_blocks) + "\n```"
text_fragments.append(tool_section)
model_input = "\n".join(fragment for fragment in text_fragments if fragment)
diff --git a/app/services/lmdb.py b/app/services/lmdb.py
index 8ccb0d4..c9d42cd 100644
--- a/app/services/lmdb.py
+++ b/app/services/lmdb.py
@@ -9,25 +9,78 @@
import orjson
from loguru import logger
-from ..models import ConversationInStore, Message
+from ..models import ContentItem, ConversationInStore, Message
from ..utils import g_config
+from ..utils.helper import extract_tool_calls, remove_tool_call_blocks
from ..utils.singleton import Singleton
def _hash_message(message: Message) -> str:
- """Generate a hash for a single message."""
- # Convert message to dict and sort keys for consistent hashing
- message_dict = message.model_dump(mode="json")
- message_bytes = orjson.dumps(message_dict, option=orjson.OPT_SORT_KEYS)
+ """Generate a consistent hash for a single message focusing ONLY on logic/content, ignoring technical IDs."""
+ core_data = {
+ "role": message.role,
+ "name": message.name,
+ }
+
+ # Normalize content: strip, handle empty/None, and list-of-text items
+ content = message.content
+ if not content:
+ core_data["content"] = None
+ elif isinstance(content, str):
+ # Normalize line endings and strip whitespace
+ normalized = content.replace("\r\n", "\n").strip()
+ core_data["content"] = normalized if normalized else None
+ elif isinstance(content, list):
+ text_parts = []
+ for item in content:
+ if isinstance(item, ContentItem) and item.type == "text":
+ text_parts.append(item.text or "")
+ elif isinstance(item, dict) and item.get("type") == "text":
+ text_parts.append(item.get("text") or "")
+ else:
+ # If it contains non-text (images/files), keep the full list for hashing
+ text_parts = None
+ break
+
+ if text_parts is not None:
+ # Normalize each part but keep them as a list to preserve boundaries and avoid collisions
+ normalized_parts = [p.replace("\r\n", "\n") for p in text_parts]
+ core_data["content"] = normalized_parts if normalized_parts else None
+ else:
+ core_data["content"] = message.model_dump(mode="json")["content"]
+
+ # Normalize tool_calls: Focus ONLY on function name and arguments
+ if message.tool_calls:
+ calls_data = []
+ for tc in message.tool_calls:
+ args = tc.function.arguments or "{}"
+ try:
+ parsed = orjson.loads(args)
+ canon_args = orjson.dumps(parsed, option=orjson.OPT_SORT_KEYS).decode("utf-8")
+ except orjson.JSONDecodeError:
+ canon_args = args
+
+ calls_data.append(
+ {
+ "name": tc.function.name,
+ "arguments": canon_args,
+ }
+ )
+ # Sort calls to be order-independent
+ calls_data.sort(key=lambda x: (x["name"], x["arguments"]))
+ core_data["tool_calls"] = calls_data
+ else:
+ core_data["tool_calls"] = None
+
+ message_bytes = orjson.dumps(core_data, option=orjson.OPT_SORT_KEYS)
return hashlib.sha256(message_bytes).hexdigest()
def _hash_conversation(client_id: str, model: str, messages: List[Message]) -> str:
- """Generate a hash for a list of messages and client id."""
- # Create a combined hash from all individual message hashes
+ """Generate a hash for a list of messages and model name, tied to a specific client_id."""
combined_hash = hashlib.sha256()
- combined_hash.update(client_id.encode("utf-8"))
- combined_hash.update(model.encode("utf-8"))
+ combined_hash.update((client_id or "").encode("utf-8"))
+ combined_hash.update((model or "").encode("utf-8"))
for message in messages:
message_hash = _hash_message(message)
combined_hash.update(message_hash.encode("utf-8"))
@@ -210,12 +263,13 @@ def find(self, model: str, messages: List[Message]) -> Optional[ConversationInSt
return None
def _find_by_message_list(
- self, model: str, messages: List[Message]
+ self,
+ model: str,
+ messages: List[Message],
) -> Optional[ConversationInStore]:
"""Internal find implementation based on a message list."""
for c in g_config.gemini.clients:
message_hash = _hash_conversation(c.id, model, messages)
-
key = f"{self.HASH_LOOKUP_PREFIX}{message_hash}"
try:
with self._get_transaction(write=False) as txn:
@@ -422,25 +476,78 @@ def __del__(self):
@staticmethod
def remove_think_tags(text: str) -> str:
"""
- Remove ... tags at the start of text and strip whitespace.
+ Remove all ... tags and strip whitespace.
"""
- cleaned_content = re.sub(r"^(\s*.*?\n?)", "", text, flags=re.DOTALL)
+ # Remove all think blocks anywhere in the text
+ cleaned_content = re.sub(r".*?", "", text, flags=re.DOTALL)
return cleaned_content.strip()
@staticmethod
def sanitize_assistant_messages(messages: list[Message]) -> list[Message]:
"""
- Create a new list of messages with assistant content cleaned of tags.
- This is useful for store the chat history.
+ Create a new list of messages with assistant content cleaned of tags
+ and system hints/tool call blocks. This is used for both storing and
+ searching chat history to ensure consistency.
+
+ If a message has no tool_calls but contains tool call XML blocks in its
+ content, they will be extracted and moved to the tool_calls field.
"""
cleaned_messages = []
for msg in messages:
- if msg.role == "assistant" and isinstance(msg.content, str):
- normalized_content = LMDBConversationStore.remove_think_tags(msg.content)
- # Only create a new object if content actually changed
- if normalized_content != msg.content:
- cleaned_msg = Message(role=msg.role, content=normalized_content, name=msg.name)
- cleaned_messages.append(cleaned_msg)
+ if msg.role == "assistant":
+ if isinstance(msg.content, str):
+ text = LMDBConversationStore.remove_think_tags(msg.content)
+ tool_calls = msg.tool_calls
+ if not tool_calls:
+ text, tool_calls = extract_tool_calls(text)
+ else:
+ text = remove_tool_call_blocks(text).strip()
+
+ normalized_content = text.strip()
+
+ if normalized_content != msg.content or tool_calls != msg.tool_calls:
+ cleaned_msg = msg.model_copy(
+ update={
+ "content": normalized_content or None,
+ "tool_calls": tool_calls or None,
+ }
+ )
+ cleaned_messages.append(cleaned_msg)
+ else:
+ cleaned_messages.append(msg)
+ elif isinstance(msg.content, list):
+ new_content = []
+ all_extracted_calls = list(msg.tool_calls or [])
+ changed = False
+
+ for item in msg.content:
+ if isinstance(item, ContentItem) and item.type == "text" and item.text:
+ text = LMDBConversationStore.remove_think_tags(item.text)
+
+ if not msg.tool_calls:
+ text, extracted = extract_tool_calls(text)
+ if extracted:
+ all_extracted_calls.extend(extracted)
+ changed = True
+ else:
+ text = remove_tool_call_blocks(text).strip()
+
+ if text != item.text:
+ changed = True
+ item = item.model_copy(update={"text": text.strip() or None})
+ new_content.append(item)
+
+ if changed:
+ cleaned_messages.append(
+ msg.model_copy(
+ update={
+ "content": new_content,
+ "tool_calls": all_extracted_calls or None,
+ }
+ )
+ )
+ else:
+ cleaned_messages.append(msg)
else:
cleaned_messages.append(msg)
else:
diff --git a/app/utils/config.py b/app/utils/config.py
index a9c5d44..708462d 100644
--- a/app/utils/config.py
+++ b/app/utils/config.py
@@ -1,9 +1,9 @@
import ast
-import json
import os
import sys
from typing import Any, Literal, Optional
+import orjson
from loguru import logger
from pydantic import BaseModel, Field, ValidationError, field_validator
from pydantic_settings import (
@@ -65,8 +65,8 @@ class GeminiModelConfig(BaseModel):
def _parse_json_string(cls, v: Any) -> Any:
if isinstance(v, str) and v.strip().startswith("{"):
try:
- return json.loads(v)
- except json.JSONDecodeError:
+ return orjson.loads(v)
+ except orjson.JSONDecodeError:
# Return the original value to let Pydantic handle the error or type mismatch
return v
return v
@@ -100,8 +100,8 @@ class GeminiConfig(BaseModel):
def _parse_models_json(cls, v: Any) -> Any:
if isinstance(v, str) and v.strip().startswith("["):
try:
- return json.loads(v)
- except json.JSONDecodeError as e:
+ return orjson.loads(v)
+ except orjson.JSONDecodeError as e:
logger.warning(f"Failed to parse models JSON string: {e}")
return v
return v
@@ -282,9 +282,9 @@ def extract_gemini_models_env() -> dict[int, dict[str, Any]]:
parsed_successfully = False
try:
- models_list = json.loads(val)
+ models_list = orjson.loads(val)
parsed_successfully = True
- except json.JSONDecodeError:
+ except orjson.JSONDecodeError:
try:
models_list = ast.literal_eval(val)
parsed_successfully = True
diff --git a/app/utils/helper.py b/app/utils/helper.py
index 51a6ccf..190b5ce 100644
--- a/app/utils/helper.py
+++ b/app/utils/helper.py
@@ -1,15 +1,15 @@
import base64
-import json
+import hashlib
import mimetypes
import re
import struct
import tempfile
-import uuid
from pathlib import Path
from typing import Iterator
from urllib.parse import urlparse
import httpx
+import orjson
from loguru import logger
from ..models import FunctionCall, Message, ToolCall
@@ -221,14 +221,20 @@ def _create_tool_call(name: str, raw_args: str) -> None:
arguments = raw_args
try:
- parsed_args = json.loads(raw_args)
- arguments = json.dumps(parsed_args, ensure_ascii=False)
- except json.JSONDecodeError:
+ parsed_args = orjson.loads(raw_args)
+ arguments = orjson.dumps(parsed_args, option=orjson.OPT_SORT_KEYS).decode("utf-8")
+ except orjson.JSONDecodeError:
logger.warning(f"Failed to parse tool call arguments for '{name}'. Passing raw string.")
+ # Generate a deterministic ID based on name, arguments, and its global sequence index
+ # to ensure uniqueness across multiple fenced blocks while remaining stable for storage.
+ index = len(tool_calls)
+ seed = f"{name}:{arguments}:{index}".encode("utf-8")
+ call_id = f"call_{hashlib.sha256(seed).hexdigest()[:24]}"
+
tool_calls.append(
ToolCall(
- id=f"call_{uuid.uuid4().hex}",
+ id=call_id,
type="function",
function=FunctionCall(name=name, arguments=arguments),
)
diff --git a/pyproject.toml b/pyproject.toml
index 32a42b4..1c30f8e 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -5,24 +5,25 @@ description = "FastAPI Server built on Gemini Web API"
readme = "README.md"
requires-python = "==3.12.*"
dependencies = [
- "fastapi>=0.115.12",
- "gemini-webapi>=1.17.0",
- "lmdb>=1.6.2",
- "loguru>=0.7.0",
- "pydantic-settings[yaml]>=2.9.1",
- "uvicorn>=0.34.1",
- "uvloop>=0.21.0; sys_platform != 'win32'",
+ "fastapi>=0.128.0",
+ "gemini-webapi>=1.17.3",
+ "lmdb>=1.7.5",
+ "loguru>=0.7.3",
+ "orjson>=3.11.5",
+ "pydantic-settings[yaml]>=2.12.0",
+ "uvicorn>=0.40.0",
+ "uvloop>=0.22.1; sys_platform != 'win32'",
]
[project.optional-dependencies]
dev = [
- "ruff>=0.11.7",
+ "ruff>=0.14.14",
]
[tool.ruff]
line-length = 100
lint.select = ["E", "F", "W", "I", "RUF"]
-lint.ignore = ["E501"]
+lint.ignore = ["E501"]
[tool.ruff.format]
quote-style = "double"
@@ -30,5 +31,5 @@ indent-style = "space"
[dependency-groups]
dev = [
- "ruff>=0.11.13",
+ "ruff>=0.14.14",
]
diff --git a/uv.lock b/uv.lock
index 923e6d3..50a73be 100644
--- a/uv.lock
+++ b/uv.lock
@@ -22,24 +22,24 @@ wheels = [
[[package]]
name = "anyio"
-version = "4.12.0"
+version = "4.12.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "idna" },
{ name = "typing-extensions" },
]
-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" }
+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" }
wheels = [
- { 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" },
+ { 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" },
]
[[package]]
name = "certifi"
-version = "2025.11.12"
+version = "2026.1.4"
source = { registry = "https://pypi.org/simple" }
-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" }
+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" }
wheels = [
- { 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" },
+ { 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" },
]
[[package]]
@@ -65,7 +65,7 @@ wheels = [
[[package]]
name = "fastapi"
-version = "0.123.10"
+version = "0.128.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "annotated-doc" },
@@ -73,9 +73,9 @@ dependencies = [
{ name = "starlette" },
{ name = "typing-extensions" },
]
-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" }
+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" }
wheels = [
- { 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" },
+ { 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" },
]
[[package]]
@@ -87,6 +87,7 @@ dependencies = [
{ name = "gemini-webapi" },
{ name = "lmdb" },
{ name = "loguru" },
+ { name = "orjson" },
{ name = "pydantic-settings", extra = ["yaml"] },
{ name = "uvicorn" },
{ name = "uvloop", marker = "sys_platform != 'win32'" },
@@ -104,19 +105,20 @@ dev = [
[package.metadata]
requires-dist = [
- { name = "fastapi", specifier = ">=0.115.12" },
- { name = "gemini-webapi", specifier = ">=1.17.0" },
- { name = "lmdb", specifier = ">=1.6.2" },
- { name = "loguru", specifier = ">=0.7.0" },
- { name = "pydantic-settings", extras = ["yaml"], specifier = ">=2.9.1" },
- { name = "ruff", marker = "extra == 'dev'", specifier = ">=0.11.7" },
- { name = "uvicorn", specifier = ">=0.34.1" },
- { name = "uvloop", marker = "sys_platform != 'win32'", specifier = ">=0.21.0" },
+ { name = "fastapi", specifier = ">=0.128.0" },
+ { name = "gemini-webapi", specifier = ">=1.17.3" },
+ { name = "lmdb", specifier = ">=1.7.5" },
+ { name = "loguru", specifier = ">=0.7.3" },
+ { name = "orjson", specifier = ">=3.11.5" },
+ { name = "pydantic-settings", extras = ["yaml"], specifier = ">=2.12.0" },
+ { name = "ruff", marker = "extra == 'dev'", specifier = ">=0.14.14" },
+ { name = "uvicorn", specifier = ">=0.40.0" },
+ { name = "uvloop", marker = "sys_platform != 'win32'", specifier = ">=0.22.1" },
]
provides-extras = ["dev"]
[package.metadata.requires-dev]
-dev = [{ name = "ruff", specifier = ">=0.11.13" }]
+dev = [{ name = "ruff", specifier = ">=0.14.14" }]
[[package]]
name = "gemini-webapi"
@@ -209,25 +211,25 @@ wheels = [
[[package]]
name = "orjson"
-version = "3.11.4"
+version = "3.11.5"
source = { registry = "https://pypi.org/simple" }
-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" }
+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" }
wheels = [
- { 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" },
- { 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" },
- { 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" },
- { 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" },
- { 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" },
- { 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" },
- { 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" },
- { 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" },
- { 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" },
- { 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" },
- { 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" },
- { 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" },
- { 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" },
- { 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" },
- { 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" },
+ { 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" },
+ { 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" },
+ { 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" },
+ { 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" },
+ { 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" },
+ { 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" },
+ { 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" },
+ { 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" },
+ { 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" },
+ { 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" },
+ { 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" },
+ { 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" },
+ { 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" },
+ { 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" },
+ { 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" },
]
[[package]]
@@ -322,28 +324,28 @@ wheels = [
[[package]]
name = "ruff"
-version = "0.14.8"
+version = "0.14.14"
source = { registry = "https://pypi.org/simple" }
-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" }
+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" }
wheels = [
- { 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" },
- { 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" },
- { 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" },
- { 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" },
- { 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" },
- { 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" },
- { 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" },
- { 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" },
- { 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" },
- { 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" },
- { 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" },
- { 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" },
- { 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" },
- { 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" },
- { 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" },
- { 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" },
- { 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" },
- { 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" },
+ { 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" },
+ { 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" },
+ { 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" },
+ { 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" },
+ { 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" },
+ { 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" },
+ { 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" },
+ { 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" },
+ { 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" },
+ { 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" },
+ { 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" },
+ { 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" },
+ { 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" },
+ { 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" },
+ { 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" },
+ { 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" },
+ { 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" },
+ { 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" },
]
[[package]]
@@ -382,15 +384,15 @@ wheels = [
[[package]]
name = "uvicorn"
-version = "0.38.0"
+version = "0.40.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "click" },
{ name = "h11" },
]
-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" }
+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" }
wheels = [
- { 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" },
+ { 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" },
]
[[package]]