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]]