From a3dfe703507494e6082f142276fb91a743375250 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C6=B0u=20Quang=20V=C5=A9?= Date: Fri, 21 Nov 2025 12:44:51 +0700 Subject: [PATCH 001/291] Remove the unused auto-refresh functionality and related imports. They are no longer needed since the underlying library issue has been resolved. --- app/services/client.py | 46 +----------------------------------------- 1 file changed, 1 insertion(+), 45 deletions(-) diff --git a/app/services/client.py b/app/services/client.py index 825564b..1554bdd 100644 --- a/app/services/client.py +++ b/app/services/client.py @@ -8,11 +8,8 @@ from gemini_webapi import GeminiClient, ModelOutput from gemini_webapi.client import ChatSession from gemini_webapi.constants import Model -from gemini_webapi.exceptions import AuthError, ModelInvalid +from gemini_webapi.exceptions import ModelInvalid from gemini_webapi.types import Gem -from gemini_webapi.utils import rotate_tasks -from gemini_webapi.utils.rotate_1psidts import rotate_1psidts -from loguru import logger from ..models import Message from ..utils import g_config @@ -76,47 +73,6 @@ async def init( verbose=verbose, ) - async def start_auto_refresh(self) -> None: - """ - Refresh the __Secure-1PSIDTS cookie periodically and keep the HTTP client in sync. - """ - while True: - new_1psidts: str | None = None - try: - new_1psidts = await rotate_1psidts(self.cookies, self.proxy) - except AuthError: - if task := rotate_tasks.get(self.cookies.get("__Secure-1PSID", "")): - task.cancel() - logger.warning( - "Failed to refresh Gemini cookies (AuthError). Auto refresh task canceled." - ) - return - except Exception as exc: - logger.warning(f"Unexpected error while refreshing Gemini cookies: {exc}") - - if new_1psidts: - self.cookies["__Secure-1PSIDTS"] = new_1psidts - self._sync_httpx_cookie("__Secure-1PSIDTS", new_1psidts) - logger.debug("Gemini cookies refreshed. New __Secure-1PSIDTS applied.") - await asyncio.sleep(self.refresh_interval) - - def _sync_httpx_cookie(self, name: str, value: str) -> None: - """ - Ensure the underlying httpx client uses the refreshed cookie value. - """ - if not self.client: - return - - jar = self.client.cookies.jar - matched = False - for cookie in jar: - if cookie.name == name: - cookie.value = value - matched = True - if not matched: - # Fall back to setting the cookie with default scope if we did not find an existing entry. - self.client.cookies.set(name, value) - async def generate_content( self, prompt: str, From 3a692ab014bf6d0cb98f38d499dc2760eb92c096 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C6=B0u=20Quang=20V=C5=A9?= Date: Sat, 22 Nov 2025 14:54:53 +0700 Subject: [PATCH 002/291] Enhance error handling in client initialization and message sending --- app/server/chat.py | 12 ++++++++-- app/services/client.py | 52 +++++++++++------------------------------- app/services/pool.py | 26 ++++++++++++++------- 3 files changed, 41 insertions(+), 49 deletions(-) diff --git a/app/server/chat.py b/app/server/chat.py index 66fa6ce..e8752cf 100644 --- a/app/server/chat.py +++ b/app/server/chat.py @@ -1129,7 +1129,11 @@ async def _send_with_split(session: ChatSession, text: str, files: list[Path | s """ if len(text) <= MAX_CHARS_PER_REQUEST: # No need to split - a single request is fine. - return await session.send_message(text, files=files) + 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 @@ -1155,7 +1159,11 @@ async def _send_with_split(session: ChatSession, text: str, files: list[Path | s raise # The last chunk carries the files (if any) and we return its response. - return await session.send_message(chunks[-1], files=files) + 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 _iter_stream_segments(model_output: str, chunk_size: int = 64): diff --git a/app/services/client.py b/app/services/client.py index 1554bdd..26be26f 100644 --- a/app/services/client.py +++ b/app/services/client.py @@ -1,4 +1,3 @@ -import asyncio import html import json import re @@ -6,10 +5,7 @@ from typing import Any, cast from gemini_webapi import GeminiClient, ModelOutput -from gemini_webapi.client import ChatSession -from gemini_webapi.constants import Model -from gemini_webapi.exceptions import ModelInvalid -from gemini_webapi.types import Gem +from loguru import logger from ..models import Message from ..utils import g_config @@ -64,40 +60,18 @@ async def init( refresh_interval = cast(float, _resolve(refresh_interval, config.refresh_interval)) verbose = cast(bool, _resolve(verbose, config.verbose)) - await super().init( - timeout=timeout, - auto_close=auto_close, - close_delay=close_delay, - auto_refresh=auto_refresh, - refresh_interval=refresh_interval, - verbose=verbose, - ) - - async def generate_content( - self, - prompt: str, - files: list[str | Path] | None = None, - model: Model | str = Model.UNSPECIFIED, - gem: Gem | str | None = None, - chat: ChatSession | None = None, - **kwargs, - ) -> ModelOutput: - cnt = 2 # Try 2 times before giving up - last_exception: ModelInvalid | None = None - while cnt: - cnt -= 1 - try: - return await super().generate_content(prompt, files, model, gem, chat, **kwargs) - except ModelInvalid as e: - # This is not always caused by model selection. Instead, it can be solved by retrying. - # So we catch it and retry as a workaround. - await asyncio.sleep(1) - last_exception = e - - # If retrying failed, re-raise ModelInvalid - if last_exception is not None: - raise last_exception - raise RuntimeError("generate_content failed without receiving a ModelInvalid error.") + try: + await super().init( + timeout=timeout, + auto_close=auto_close, + close_delay=close_delay, + auto_refresh=auto_refresh, + refresh_interval=refresh_interval, + verbose=verbose, + ) + except Exception: + logger.exception(f"Failed to initialize GeminiClient {self.id}") + raise @staticmethod async def process_message( diff --git a/app/services/pool.py b/app/services/pool.py index abf1fa0..24a21dc 100644 --- a/app/services/pool.py +++ b/app/services/pool.py @@ -35,14 +35,24 @@ def __init__(self) -> None: async def init(self) -> None: """Initialize all clients in the pool.""" + success_count = 0 for client in self._clients: if not client.running: - await client.init( - timeout=g_config.gemini.timeout, - auto_refresh=g_config.gemini.auto_refresh, - verbose=g_config.gemini.verbose, - refresh_interval=g_config.gemini.refresh_interval, - ) + try: + await client.init( + timeout=g_config.gemini.timeout, + auto_refresh=g_config.gemini.auto_refresh, + verbose=g_config.gemini.verbose, + refresh_interval=g_config.gemini.refresh_interval, + ) + except Exception: + logger.exception(f"Failed to initialize client {client.id}") + + if client.running: + success_count += 1 + + if success_count == 0: + raise RuntimeError("Failed to initialize any Gemini clients") async def acquire(self, client_id: Optional[str] = None) -> GeminiClientWrapper: """Return a healthy client by id or using round-robin.""" @@ -89,8 +99,8 @@ async def _ensure_client_ready(self, client: GeminiClientWrapper) -> bool: ) logger.info(f"Restarted Gemini client {client.id} after it stopped.") return True - except Exception as exc: - logger.warning(f"Failed to restart Gemini client {client.id}: {exc}") + except Exception: + logger.exception(f"Failed to restart Gemini client {client.id}") return False @property From d57e3676fed9fa03e1f51a5aed80d4b7f88e6a88 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C6=B0u=20Quang=20V=C5=A9?= Date: Sat, 22 Nov 2025 17:49:41 +0700 Subject: [PATCH 003/291] Refactor link handling to extract file paths and simplify Google search links --- app/services/client.py | 46 +++++++++++++++++++++++++----------------- 1 file changed, 27 insertions(+), 19 deletions(-) diff --git a/app/services/client.py b/app/services/client.py index 26be26f..f5a39dd 100644 --- a/app/services/client.py +++ b/app/services/client.py @@ -24,9 +24,20 @@ ) HTML_ESCAPE_RE = re.compile(r"&(?:lt|gt|amp|quot|apos|#[0-9]+|#x[0-9a-fA-F]+);") + MARKDOWN_ESCAPE_RE = re.compile(r"\\(?=\s*[-\\`*_{}\[\]()#+.!<>])") + CODE_FENCE_RE = re.compile(r"(```.*?```|`[^`]*`)", re.DOTALL) +FILE_PATH_PATTERN = re.compile( + r"^(?=.*[./\\]|.*:\d+|^(?:Dockerfile|Makefile|Jenkinsfile|Procfile|Rakefile|Vagrantfile|Caddyfile|Justfile|LICENSE|README|CONTRIBUTING|CODEOWNERS|AUTHORS|NOTICE|Gemfile|CHANGELOG)$)([a-zA-Z0-9_./\\-]+(?::\d+)?)$", + re.IGNORECASE, +) + +GOOGLE_SEARCH_LINK_PATTERN = re.compile( + r"(?:`\s*)?`?\[`?([^`\]]+)`?`?]\((https://www\.google\.com/search\?q=)(.*?)(? str: text = _unescape_html(text) text = _unescape_markdown(text) - def simplify_link_target(text_content: str) -> str: - match_colon_num = re.match(r"([^:]+:\d+)", text_content) - if match_colon_num: - return match_colon_num.group(1) - return text_content + def extract_file_path_from_display_text(text_content: str) -> str | None: + match = re.match(FILE_PATH_PATTERN, text_content) + if match: + return match.group(1) + return None def replacer(match: re.Match) -> str: - outer_open_paren = match.group(1) - display_text = match.group(2) + display_text = str(match.group(1)).strip() + google_search_prefix = match.group(2) + query_part = match.group(3) - new_target_url = simplify_link_target(display_text) - new_link_segment = f"[`{display_text}`]({new_target_url})" + file_path = extract_file_path_from_display_text(display_text) - if outer_open_paren: - return f"{outer_open_paren}{new_link_segment})" + if file_path: + # If it's a file path, transform it into a self-referencing Markdown link + return f"[`{file_path}`]({file_path})" else: - return new_link_segment - - # Replace Google search links with simplified Markdown links - pattern = r"(\()?\[`([^`]+?)`\]\((https://www.google.com/search\?q=)(.*?)(? Date: Sat, 22 Nov 2025 18:29:41 +0700 Subject: [PATCH 004/291] Fix regex pattern for Google search link matching --- app/services/client.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/services/client.py b/app/services/client.py index f5a39dd..ffc559e 100644 --- a/app/services/client.py +++ b/app/services/client.py @@ -35,7 +35,7 @@ ) GOOGLE_SEARCH_LINK_PATTERN = re.compile( - r"(?:`\s*)?`?\[`?([^`\]]+)`?`?]\((https://www\.google\.com/search\?q=)(.*?)(? Date: Sat, 22 Nov 2025 21:44:09 +0700 Subject: [PATCH 005/291] Fix regex patterns for Markdown escaping, code fence and Google search link matching --- app/services/client.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/app/services/client.py b/app/services/client.py index ffc559e..0088c74 100644 --- a/app/services/client.py +++ b/app/services/client.py @@ -25,17 +25,17 @@ HTML_ESCAPE_RE = re.compile(r"&(?:lt|gt|amp|quot|apos|#[0-9]+|#x[0-9a-fA-F]+);") -MARKDOWN_ESCAPE_RE = re.compile(r"\\(?=\s*[-\\`*_{}\[\]()#+.!<>])") +MARKDOWN_ESCAPE_RE = re.compile(r"\\(?=[-\\`*_{}\[\]()#+.!<>])") -CODE_FENCE_RE = re.compile(r"(```.*?```|`[^`]*`)", re.DOTALL) +CODE_FENCE_RE = re.compile(r"(```.*?```|`[^`\n]+?`)", re.DOTALL) FILE_PATH_PATTERN = re.compile( - r"^(?=.*[./\\]|.*:\d+|^(?:Dockerfile|Makefile|Jenkinsfile|Procfile|Rakefile|Vagrantfile|Caddyfile|Justfile|LICENSE|README|CONTRIBUTING|CODEOWNERS|AUTHORS|NOTICE|Gemfile|CHANGELOG)$)([a-zA-Z0-9_./\\-]+(?::\d+)?)$", + r"^(?=.*[./\\]|.*:\d+|^(?:Dockerfile|Makefile|Jenkinsfile|Procfile|Rakefile|Gemfile|Vagrantfile|Caddyfile|Justfile|LICENSE|README|CONTRIBUTING|CODEOWNERS|AUTHORS|NOTICE|CHANGELOG)$)([a-zA-Z0-9_./\\-]+(?::\d+)?)$", re.IGNORECASE, ) GOOGLE_SEARCH_LINK_PATTERN = re.compile( - r"(?:`\s*)?`?\[`?(.+?)`?`?]\((https://www\.google\.com/search\?q=)(.*?)(? Date: Sat, 22 Nov 2025 22:52:27 +0700 Subject: [PATCH 006/291] Increase timeout value in configuration files from 60 to 120 seconds to better handle heavy tasks --- app/server/chat.py | 2 -- app/services/client.py | 8 -------- app/utils/config.py | 2 +- config/config.yaml | 6 +++--- 4 files changed, 4 insertions(+), 14 deletions(-) diff --git a/app/server/chat.py b/app/server/chat.py index e8752cf..b4e88da 100644 --- a/app/server/chat.py +++ b/app/server/chat.py @@ -48,9 +48,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.')" - TOOL_BLOCK_RE = re.compile(r"```xml\s*(.*?)```", re.DOTALL | re.IGNORECASE) TOOL_CALL_RE = re.compile( r"(.*?)", re.DOTALL | re.IGNORECASE diff --git a/app/services/client.py b/app/services/client.py index 0088c74..166eb70 100644 --- a/app/services/client.py +++ b/app/services/client.py @@ -16,29 +16,21 @@ '```xml\n{"arg": "value"}\n```\n' "Do not surround the fence with any other text or whitespace; otherwise the call will be ignored.\n" ) - CODE_BLOCK_HINT = ( "\nWhenever you include code, markup, or shell snippets, wrap each snippet in a Markdown fenced " "block and supply the correct language label (for example, ```python ... ``` or ```html ... ```).\n" "Fence ONLY the actual code/markup; keep all narrative or explanatory text outside the fences.\n" ) - HTML_ESCAPE_RE = re.compile(r"&(?:lt|gt|amp|quot|apos|#[0-9]+|#x[0-9a-fA-F]+);") - MARKDOWN_ESCAPE_RE = re.compile(r"\\(?=[-\\`*_{}\[\]()#+.!<>])") - CODE_FENCE_RE = re.compile(r"(```.*?```|`[^`\n]+?`)", re.DOTALL) - FILE_PATH_PATTERN = re.compile( r"^(?=.*[./\\]|.*:\d+|^(?:Dockerfile|Makefile|Jenkinsfile|Procfile|Rakefile|Gemfile|Vagrantfile|Caddyfile|Justfile|LICENSE|README|CONTRIBUTING|CODEOWNERS|AUTHORS|NOTICE|CHANGELOG)$)([a-zA-Z0-9_./\\-]+(?::\d+)?)$", re.IGNORECASE, ) - GOOGLE_SEARCH_LINK_PATTERN = re.compile( r"`?\[`?(.+?)`?`?]\((https://www\.google\.com/search\?q=)([^)]*)\)`?" ) - - _UNSET = object() diff --git a/app/utils/config.py b/app/utils/config.py index 48f0792..796ca75 100644 --- a/app/utils/config.py +++ b/app/utils/config.py @@ -56,7 +56,7 @@ class GeminiConfig(BaseModel): clients: list[GeminiClientSettings] = Field( ..., description="List of Gemini client credential pairs" ) - timeout: int = Field(default=60, ge=1, description="Init timeout") + timeout: int = Field(default=120, ge=1, description="Init timeout") auto_refresh: bool = Field(True, description="Enable auto-refresh for Gemini cookies") refresh_interval: int = Field( default=540, ge=1, description="Interval in seconds to refresh Gemini cookies" diff --git a/config/config.yaml b/config/config.yaml index b0f8fbf..89c88b7 100644 --- a/config/config.yaml +++ b/config/config.yaml @@ -21,8 +21,8 @@ gemini: - id: "example-id-1" # Arbitrary client ID secure_1psid: "YOUR_SECURE_1PSID_HERE" secure_1psidts: "YOUR_SECURE_1PSIDTS_HERE" - proxy: null # Optional proxy URL (null/empty means direct connection) - timeout: 60 # Init timeout in seconds + proxy: null # Optional proxy URL (null/empty means direct connection) + timeout: 120 # Init timeout in seconds auto_refresh: true # Auto-refresh session cookies refresh_interval: 540 # Refresh interval in seconds verbose: false # Enable verbose logging for Gemini requests @@ -34,4 +34,4 @@ storage: retention_days: 14 # Number of days to retain conversations before cleanup logging: - level: "INFO" # Log level: DEBUG, INFO, WARNING, ERROR + level: "INFO" # Log level: DEBUG, INFO, WARNING, ERROR From f00ebfcbd0424c7ab06d680f308349a04aff3be0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C6=B0u=20Quang=20V=C5=A9?= Date: Tue, 2 Dec 2025 13:15:27 +0700 Subject: [PATCH 007/291] Fix Image generation --- .github/workflows/docker.yaml | 10 ++--- .github/workflows/track.yml | 12 +++--- app/models/models.py | 14 +++---- app/server/chat.py | 77 +++++++++++++++++++++-------------- app/services/client.py | 4 +- app/utils/helper.py | 10 ++++- 6 files changed, 75 insertions(+), 52 deletions(-) diff --git a/.github/workflows/docker.yaml b/.github/workflows/docker.yaml index 4527f3d..eef2a41 100644 --- a/.github/workflows/docker.yaml +++ b/.github/workflows/docker.yaml @@ -5,11 +5,11 @@ on: branches: - main tags: - - 'v*' + - "v*" paths-ignore: - - '**/*.md' - - '.github/workflows/ruff.yaml' - - '.github/workflows/track.yml' + - "**/*.md" + - ".github/workflows/ruff.yaml" + - ".github/workflows/track.yml" env: REGISTRY: ghcr.io @@ -57,4 +57,4 @@ jobs: labels: ${{ steps.meta.outputs.labels }} platforms: linux/amd64,linux/arm64 cache-from: type=gha - cache-to: type=gha,mode=max \ No newline at end of file + cache-to: type=gha,mode=max diff --git a/.github/workflows/track.yml b/.github/workflows/track.yml index 63afbec..838dcf8 100644 --- a/.github/workflows/track.yml +++ b/.github/workflows/track.yml @@ -2,7 +2,7 @@ name: Update gemini-webapi on: schedule: - - cron: '0 0 * * *' # Runs every day at midnight + - cron: "0 0 * * *" # Runs every day at midnight workflow_dispatch: jobs: @@ -24,7 +24,7 @@ jobs: run: | # Install dependencies first to enable uv pip show uv sync - + # Get current version of gemini-webapi before upgrade OLD_VERSION=$(uv pip show gemini-webapi 2>/dev/null | grep ^Version: | awk '{print $2}') if [ -z "$OLD_VERSION" ]; then @@ -32,10 +32,10 @@ jobs: exit 1 fi echo "Current gemini-webapi version: $OLD_VERSION" - + # Update the package using uv, which handles pyproject.toml and uv.lock uv add --upgrade gemini-webapi - + # Get new version of gemini-webapi after upgrade NEW_VERSION=$(uv pip show gemini-webapi | grep ^Version: | awk '{print $2}') if [ -z "$NEW_VERSION" ]; then @@ -43,7 +43,7 @@ jobs: exit 1 fi echo "New gemini-webapi version: $NEW_VERSION" - + # Only proceed if gemini-webapi version has changed if [ "$OLD_VERSION" != "$NEW_VERSION" ]; then echo "gemini-webapi has been updated from $OLD_VERSION to $NEW_VERSION" @@ -63,7 +63,7 @@ jobs: title: ":arrow_up: update gemini-webapi to ${{ steps.update.outputs.version }}" body: | Update `gemini-webapi` to version `${{ steps.update.outputs.version }}`. - + Auto-generated by GitHub Actions using `uv`. branch: update-gemini-webapi base: main diff --git a/app/models/models.py b/app/models/models.py index 3991f12..74d8cd5 100644 --- a/app/models/models.py +++ b/app/models/models.py @@ -154,11 +154,13 @@ class ConversationInStore(BaseModel): class ResponseInputContent(BaseModel): """Content item for Responses API input.""" - type: Literal["input_text", "input_image"] + type: Literal["input_text", "input_image", "input_file"] text: Optional[str] = None image_url: Optional[str] = None - image_base64: Optional[str] = None - mime_type: Optional[str] = None + detail: Optional[Literal["auto", "low", "high"]] = None + file_url: Optional[str] = None + file_data: Optional[str] = None + filename: Optional[str] = None class ResponseInputItem(BaseModel): @@ -212,12 +214,8 @@ class ResponseUsage(BaseModel): class ResponseOutputContent(BaseModel): """Content item for Responses API output.""" - type: Literal["output_text", "output_image"] + type: Literal["output_text"] text: Optional[str] = None - image_base64: Optional[str] = None - mime_type: Optional[str] = None - width: Optional[int] = None - height: Optional[int] = None class ResponseOutputMessage(BaseModel): diff --git a/app/server/chat.py b/app/server/chat.py index b4e88da..76dc632 100644 --- a/app/server/chat.py +++ b/app/server/chat.py @@ -381,14 +381,6 @@ def _strip_tagged_blocks(text: str) -> str: return "".join(result) -def _ensure_data_url(part: ResponseInputContent) -> str | None: - image_url = part.image_url - if not image_url and part.image_base64: - mime_type = part.mime_type or "image/png" - image_url = f"data:{mime_type};base64,{part.image_base64}" - return image_url - - def _response_items_to_messages( items: str | list[ResponseInputItem], ) -> tuple[list[Message], str | list[ResponseInputItem]]: @@ -422,14 +414,34 @@ def _response_items_to_messages( if text_value: converted.append(ContentItem(type="text", text=text_value)) elif part.type == "input_image": - image_url = _ensure_data_url(part) + image_url = part.image_url if image_url: normalized_contents.append( - ResponseInputContent(type="input_image", image_url=image_url) + ResponseInputContent( + type="input_image", + image_url=image_url, + detail=part.detail if part.detail else "auto", + ) ) converted.append( - ContentItem(type="image_url", image_url={"url": image_url}) + ContentItem( + type="image_url", + image_url={ + "url": image_url, + "detail": part.detail if part.detail else "auto", + }, + ) ) + elif part.type == "input_file": + if part.file_url or part.file_data: + normalized_contents.append(part) + file_info = {} + if part.file_data: + file_info["file_data"] = part.file_data + file_info["filename"] = part.filename + if part.file_url: + file_info["url"] = part.file_url + converted.append(ContentItem(type="file", file=file_info)) messages.append(Message(role=role, content=converted or None)) normalized_input.append( @@ -472,11 +484,26 @@ def _instructions_to_messages( if text_value: converted.append(ContentItem(type="text", text=text_value)) elif part.type == "input_image": - image_url = _ensure_data_url(part) + image_url = part.image_url if image_url: converted.append( - ContentItem(type="image_url", image_url={"url": image_url}) + ContentItem( + type="image_url", + image_url={ + "url": image_url, + "detail": part.detail if part.detail else "auto", + }, + ) ) + elif part.type == "input_file": + file_info = {} + if part.file_data: + file_info["file_data"] = part.file_data + file_info["filename"] = part.filename + if part.file_url: + file_info["url"] = part.file_url + if file_info: + converted.append(ContentItem(type="file", file=file_info)) instruction_messages.append(Message(role=role, content=converted or None)) return instruction_messages @@ -799,13 +826,13 @@ async def create_response( session, client, remaining_messages = await _find_reusable_session(db, pool, model, messages) async def _build_payload( - payload_messages: list[Message], reuse_session: bool + _payload_messages: list[Message], _reuse_session: bool ) -> tuple[str, list[Path | str]]: - if reuse_session and len(payload_messages) == 1: + if _reuse_session and len(_payload_messages) == 1: return await GeminiClientWrapper.process_message( - payload_messages[0], tmp_dir, tagged=False + _payload_messages[0], tmp_dir, tagged=False ) - return await GeminiClientWrapper.process_conversation(payload_messages, tmp_dir) + return await GeminiClientWrapper.process_conversation(_payload_messages, tmp_dir) reuse_session = session is not None if reuse_session: @@ -821,7 +848,7 @@ async def _build_payload( detail="No new messages to send for the existing session.", ) payload_messages = messages_to_send - model_input, files = await _build_payload(payload_messages, reuse_session=True) + model_input, files = await _build_payload(payload_messages, _reuse_session=True) logger.debug( f"Reused session {session.metadata} - sending {len(payload_messages)} prepared messages." ) @@ -830,7 +857,7 @@ async def _build_payload( client = await pool.acquire() session = client.start_chat(model=model) payload_messages = messages - model_input, files = await _build_payload(payload_messages, reuse_session=False) + model_input, files = await _build_payload(payload_messages, _reuse_session=False) except ValueError as e: raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) except RuntimeError as e: @@ -935,7 +962,6 @@ async def _build_payload( detail = f"{detail} Assistant response: {summary}" raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail=detail) - image_contents: list[ResponseOutputContent] = [] image_call_items: list[ResponseImageGenerationCall] = [] for image in images: try: @@ -943,16 +969,6 @@ async def _build_payload( except Exception as exc: logger.warning(f"Failed to download generated image: {exc}") continue - mime_type = "image/png" if isinstance(image, GeneratedImage) else "image/jpeg" - image_contents.append( - ResponseOutputContent( - type="output_image", - image_base64=image_base64, - mime_type=mime_type, - width=width, - height=height, - ) - ) image_call_items.append( ResponseImageGenerationCall( id=f"img_{uuid.uuid4().hex}", @@ -977,7 +993,6 @@ async def _build_payload( response_contents: list[ResponseOutputContent] = [] if assistant_text: response_contents.append(ResponseOutputContent(type="output_text", text=assistant_text)) - response_contents.extend(image_contents) if not response_contents: response_contents.append(ResponseOutputContent(type="output_text", text="")) diff --git a/app/services/client.py b/app/services/client.py index 166eb70..0207114 100644 --- a/app/services/client.py +++ b/app/services/client.py @@ -113,8 +113,10 @@ async def process_message( if file_data := item.file.get("file_data", None): filename = item.file.get("filename", "") files.append(await save_file_to_tempfile(file_data, filename, tempdir)) + elif url := item.file.get("url", None): + files.append(await save_url_to_tempfile(url, tempdir)) else: - raise ValueError("File must contain 'file_data' key") + raise ValueError("File must contain 'file_data' or 'url' key") elif message.content is not None: raise ValueError("Unsupported message content type.") diff --git a/app/utils/helper.py b/app/utils/helper.py index 48fc99d..3bff469 100644 --- a/app/utils/helper.py +++ b/app/utils/helper.py @@ -1,4 +1,5 @@ import base64 +import mimetypes import tempfile from pathlib import Path @@ -40,9 +41,16 @@ async def save_url_to_tempfile(url: str, tempdir: Path | None = None): suffix: str | None = None if url.startswith("data:image/"): # Base64 encoded image + metadata_part = url.split(",")[0] + mime_type = metadata_part.split(":")[1].split(";")[0] + base64_data = url.split(",")[1] data = base64.b64decode(base64_data) - suffix = ".png" + + # Guess extension from mime type, default to the subtype if not found + suffix = mimetypes.guess_extension(mime_type) + if not suffix: + suffix = f".{mime_type.split('/')[1]}" else: # http files async with httpx.AsyncClient() as client: From d911c33e81e83211ed53d77b300c4c203df7b53c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C6=B0u=20Quang=20V=C5=A9?= Date: Tue, 2 Dec 2025 15:50:45 +0700 Subject: [PATCH 008/291] Refactor tool handling to support standard and image generation tools separately --- app/models/models.py | 7 ++++--- app/server/chat.py | 36 +++++++++++++++++++++++++++++++++--- 2 files changed, 37 insertions(+), 6 deletions(-) diff --git a/app/models/models.py b/app/models/models.py index 74d8cd5..52dd414 100644 --- a/app/models/models.py +++ b/app/models/models.py @@ -174,7 +174,8 @@ class ResponseInputItem(BaseModel): class ResponseToolChoice(BaseModel): """Tool choice enforcing a specific tool in Responses API.""" - type: Literal["image_generation"] + type: Literal["function", "image_generation"] + function: Optional[ToolChoiceFunctionDetail] = None class ResponseImageTool(BaseModel): @@ -195,8 +196,8 @@ class ResponseCreateRequest(BaseModel): top_p: Optional[float] = 1.0 max_output_tokens: Optional[int] = None stream: Optional[bool] = False - tool_choice: Optional[ResponseToolChoice] = None - tools: Optional[List[ResponseImageTool]] = None + tool_choice: Optional[Union[str, ResponseToolChoice]] = None + tools: Optional[List[Union[Tool, ResponseImageTool]]] = None store: Optional[bool] = None user: Optional[str] = None response_format: Optional[Dict[str, Any]] = None diff --git a/app/server/chat.py b/app/server/chat.py index 76dc632..8277d0c 100644 --- a/app/server/chat.py +++ b/app/server/chat.py @@ -795,7 +795,28 @@ async def create_response( f"Structured response requested for /v1/responses (schema={structured_requirement.schema_name})." ) - image_instruction = _build_image_generation_instruction(request.tools, request.tool_choice) + # Separate standard tools from image generation tools + standard_tools: list[Tool] = [] + image_tools: list[ResponseImageTool] = [] + + if request.tools: + for t in request.tools: + if isinstance(t, Tool): + standard_tools.append(t) + elif isinstance(t, ResponseImageTool): + image_tools.append(t) + # Handle dicts if Pydantic didn't convert them fully (fallback) + elif isinstance(t, dict): + t_type = t.get("type") + if t_type == "function": + standard_tools.append(Tool.model_validate(t)) + elif t_type == "image_generation": + image_tools.append(ResponseImageTool.model_validate(t)) + + image_instruction = _build_image_generation_instruction( + image_tools, + request.tool_choice if isinstance(request.tool_choice, ResponseToolChoice) else None, + ) if image_instruction: extra_instructions.append(image_instruction) logger.debug("Image generation support enabled for /v1/responses request.") @@ -808,10 +829,19 @@ async def create_response( f"Injected {len(preface_messages)} instruction messages before sending to Gemini." ) + # Pass standard tools to the prompt builder + # Determine tool_choice for standard tools (ignore image_generation choice here as it is handled via instruction) + model_tool_choice = None + if isinstance(request.tool_choice, str): + model_tool_choice = request.tool_choice + elif isinstance(request.tool_choice, ToolChoiceFunction): + model_tool_choice = request.tool_choice + # If tool_choice is ResponseToolChoice (image_generation), we don't pass it as a function tool choice. + messages = _prepare_messages_for_model( conversation_messages, - tools=None, - tool_choice=None, + tools=standard_tools or None, + tool_choice=model_tool_choice, extra_instructions=extra_instructions or None, ) From a8241ad78831b675d0321bbe5271c1bf10a6ce2a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C6=B0u=20Quang=20V=C5=A9?= Date: Tue, 2 Dec 2025 17:17:27 +0700 Subject: [PATCH 009/291] Fix: use "ascii" decoding for base64-encoded image data consistency --- app/server/chat.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/server/chat.py b/app/server/chat.py index 8277d0c..67790ab 100644 --- a/app/server/chat.py +++ b/app/server/chat.py @@ -1524,4 +1524,4 @@ async def _image_to_base64(image: Image, temp_dir: Path) -> tuple[str, int | Non data = Path(saved_path).read_bytes() width, height = _extract_image_dimensions(data) - return base64.b64encode(data).decode("utf-8"), width, height + return base64.b64encode(data).decode("ascii"), width, height From fd2723d49b5929cb770a231aeb479f392f7a7d53 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C6=B0u=20Quang=20V=C5=A9?= Date: Wed, 3 Dec 2025 12:08:19 +0700 Subject: [PATCH 010/291] Fix: replace `running` with `_running` for internal client status checks --- app/services/pool.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/app/services/pool.py b/app/services/pool.py index 24a21dc..28a3435 100644 --- a/app/services/pool.py +++ b/app/services/pool.py @@ -37,7 +37,7 @@ async def init(self) -> None: """Initialize all clients in the pool.""" success_count = 0 for client in self._clients: - if not client.running: + if not client._running: try: await client.init( timeout=g_config.gemini.timeout, @@ -48,7 +48,7 @@ async def init(self) -> None: except Exception: logger.exception(f"Failed to initialize client {client.id}") - if client.running: + if client._running: success_count += 1 if success_count == 0: @@ -79,7 +79,7 @@ async def acquire(self, client_id: Optional[str] = None) -> GeminiClientWrapper: async def _ensure_client_ready(self, client: GeminiClientWrapper) -> bool: """Make sure the client is running, attempting a restart if needed.""" - if client.running: + if client._running: return True lock = self._restart_locks.get(client.id) @@ -87,7 +87,7 @@ async def _ensure_client_ready(self, client: GeminiClientWrapper) -> bool: return False # Should not happen async with lock: - if client.running: + if client._running: return True try: @@ -110,4 +110,4 @@ def clients(self) -> List[GeminiClientWrapper]: def status(self) -> Dict[str, bool]: """Return running status for each client.""" - return {client.id: client.running for client in self._clients} + return {client.id: client._running for client in self._clients} From 8ee6cc0335e4b63df2126a6bf69d6c9e42505485 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C6=B0u=20Quang=20V=C5=A9?= Date: Wed, 3 Dec 2025 14:10:06 +0700 Subject: [PATCH 011/291] Refactor: replace direct `_running` access with `running()` method in client status checks --- app/services/client.py | 3 +++ app/services/pool.py | 10 +++++----- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/app/services/client.py b/app/services/client.py index 0207114..09c52c1 100644 --- a/app/services/client.py +++ b/app/services/client.py @@ -76,6 +76,9 @@ async def init( logger.exception(f"Failed to initialize GeminiClient {self.id}") raise + def running(self) -> bool: + return self._running + @staticmethod async def process_message( message: Message, tempdir: Path | None = None, tagged: bool = True diff --git a/app/services/pool.py b/app/services/pool.py index 28a3435..a134dda 100644 --- a/app/services/pool.py +++ b/app/services/pool.py @@ -37,7 +37,7 @@ async def init(self) -> None: """Initialize all clients in the pool.""" success_count = 0 for client in self._clients: - if not client._running: + if not client.running(): try: await client.init( timeout=g_config.gemini.timeout, @@ -48,7 +48,7 @@ async def init(self) -> None: except Exception: logger.exception(f"Failed to initialize client {client.id}") - if client._running: + if client.running(): success_count += 1 if success_count == 0: @@ -79,7 +79,7 @@ async def acquire(self, client_id: Optional[str] = None) -> GeminiClientWrapper: async def _ensure_client_ready(self, client: GeminiClientWrapper) -> bool: """Make sure the client is running, attempting a restart if needed.""" - if client._running: + if client.running(): return True lock = self._restart_locks.get(client.id) @@ -87,7 +87,7 @@ async def _ensure_client_ready(self, client: GeminiClientWrapper) -> bool: return False # Should not happen async with lock: - if client._running: + if client.running(): return True try: @@ -110,4 +110,4 @@ def clients(self) -> List[GeminiClientWrapper]: def status(self) -> Dict[str, bool]: """Return running status for each client.""" - return {client.id: client._running for client in self._clients} + return {client.id: client.running() for client in self._clients} From 453700eba682cfdd4bfc2e061a8139129654d017 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C6=B0u=20Quang=20V=C5=A9?= Date: Wed, 3 Dec 2025 22:11:11 +0700 Subject: [PATCH 012/291] Extend models with new fields for annotations, reasoning, audio, log probabilities, and token details; adjust response handling accordingly. --- app/models/models.py | 13 ++++++++++++- app/server/chat.py | 7 ++++--- 2 files changed, 16 insertions(+), 4 deletions(-) diff --git a/app/models/models.py b/app/models/models.py index 52dd414..1d7368c 100644 --- a/app/models/models.py +++ b/app/models/models.py @@ -12,7 +12,9 @@ class ContentItem(BaseModel): type: Literal["text", "image_url", "file", "input_audio"] text: Optional[str] = None image_url: Optional[Dict[str, str]] = None + input_audio: Optional[Dict[str, Any]] = None file: Optional[Dict[str, str]] = None + annotations: List[Dict[str, Any]] = Field(default_factory=list) class Message(BaseModel): @@ -22,6 +24,10 @@ class Message(BaseModel): content: Union[str, List[ContentItem], None] = None name: Optional[str] = None tool_calls: Optional[List["ToolCall"]] = 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) class Choice(BaseModel): @@ -30,6 +36,7 @@ class Choice(BaseModel): index: int message: Message finish_reason: str + logprobs: Optional[Dict[str, Any]] = None class FunctionCall(BaseModel): @@ -81,6 +88,8 @@ class Usage(BaseModel): prompt_tokens: int completion_tokens: int total_tokens: int + prompt_tokens_details: Optional[Dict[str, int]] = None + completion_tokens_details: Optional[Dict[str, int]] = None class ModelData(BaseModel): @@ -118,6 +127,8 @@ class ChatCompletionResponse(BaseModel): model: str choices: List[Choice] usage: Usage + system_fingerprint: Optional[str] = None + service_tier: Optional[str] = None class ModelListResponse(BaseModel): @@ -217,6 +228,7 @@ class ResponseOutputContent(BaseModel): type: Literal["output_text"] text: Optional[str] = None + annotations: List[Dict[str, Any]] = Field(default_factory=list) class ResponseOutputMessage(BaseModel): @@ -257,7 +269,6 @@ class ResponseCreateResponse(BaseModel): created: int model: str output: List[Union[ResponseOutputMessage, ResponseImageGenerationCall, ResponseToolCall]] - output_text: Optional[str] = None status: Literal[ "in_progress", "completed", diff --git a/app/server/chat.py b/app/server/chat.py index 67790ab..5848a39 100644 --- a/app/server/chat.py +++ b/app/server/chat.py @@ -1022,10 +1022,12 @@ async def _build_payload( response_contents: list[ResponseOutputContent] = [] if assistant_text: - response_contents.append(ResponseOutputContent(type="output_text", text=assistant_text)) + response_contents.append( + ResponseOutputContent(type="output_text", text=assistant_text, annotations=[]) + ) if not response_contents: - response_contents.append(ResponseOutputContent(type="output_text", text="")) + response_contents.append(ResponseOutputContent(type="output_text", text="", annotations=[])) created_time = int(datetime.now(tz=timezone.utc).timestamp()) response_id = f"resp_{uuid.uuid4().hex}" @@ -1059,7 +1061,6 @@ async def _build_payload( *tool_call_items, *image_call_items, ], - output_text=assistant_text or None, status="completed", usage=usage, input=normalized_input or None, From 9260f8b5cc37192716d4127ed6ab98a087e7e3ea Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C6=B0u=20Quang=20V=C5=A9?= Date: Wed, 3 Dec 2025 22:51:54 +0700 Subject: [PATCH 013/291] Extend models with new fields (annotations, error), add `normalize_output_text` validator, rename `created` to `created_at`, and update response handling accordingly. --- app/models/models.py | 16 +++++++++++++--- app/server/chat.py | 8 ++++---- 2 files changed, 17 insertions(+), 7 deletions(-) diff --git a/app/models/models.py b/app/models/models.py index 1d7368c..8d5102c 100644 --- a/app/models/models.py +++ b/app/models/models.py @@ -3,7 +3,7 @@ from datetime import datetime from typing import Any, Dict, List, Literal, Optional, Union -from pydantic import BaseModel, Field +from pydantic import BaseModel, Field, model_validator class ContentItem(BaseModel): @@ -127,7 +127,6 @@ class ChatCompletionResponse(BaseModel): model: str choices: List[Choice] usage: Usage - system_fingerprint: Optional[str] = None service_tier: Optional[str] = None @@ -172,6 +171,15 @@ class ResponseInputContent(BaseModel): file_url: Optional[str] = None file_data: Optional[str] = None filename: Optional[str] = None + annotations: List[Dict[str, Any]] = Field(default_factory=list) + + @model_validator(mode="before") + @classmethod + def normalize_output_text(cls, data: Any) -> Any: + """Allow output_text (from previous turns) to be treated as input_text.""" + if isinstance(data, dict) and data.get("type") == "output_text": + data["type"] = "input_text" + return data class ResponseInputItem(BaseModel): @@ -266,7 +274,7 @@ class ResponseCreateResponse(BaseModel): id: str object: Literal["response"] = "response" - created: int + created_at: int model: str output: List[Union[ResponseOutputMessage, ResponseImageGenerationCall, ResponseToolCall]] status: Literal[ @@ -274,9 +282,11 @@ class ResponseCreateResponse(BaseModel): "completed", "failed", "incomplete", + "cancelled", "requires_action", ] = "completed" usage: ResponseUsage + error: Optional[Dict[str, Any]] = None metadata: Optional[Dict[str, Any]] = None system_fingerprint: Optional[str] = None input: Optional[Union[str, List[ResponseInputItem]]] = None diff --git a/app/server/chat.py b/app/server/chat.py index 5848a39..ef508b9 100644 --- a/app/server/chat.py +++ b/app/server/chat.py @@ -1049,7 +1049,7 @@ async def _build_payload( response_payload = ResponseCreateResponse( id=response_id, - created=created_time, + created_at=created_time, model=request.model, output=[ ResponseOutputMessage( @@ -1334,7 +1334,7 @@ def _create_responses_streaming_response( response_dict = response_payload.model_dump(mode="json") response_id = response_payload.id - created_time = response_payload.created + created_time = response_payload.created_at model = response_payload.model logger.debug( @@ -1344,14 +1344,14 @@ def _create_responses_streaming_response( base_event = { "id": response_id, "object": "response", - "created": created_time, + "created_at": created_time, "model": model, } created_snapshot: dict[str, Any] = { "id": response_id, "object": "response", - "created": created_time, + "created_at": created_time, "model": model, "status": "in_progress", } From d6a8e6bdb786bb90dd653cd9aa3fc88469c2b505 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C6=B0u=20Quang=20V=C5=A9?= Date: Thu, 4 Dec 2025 09:35:30 +0700 Subject: [PATCH 014/291] Extend response models to support tool choices, image output, and improved streaming of response items. Refactor image generation handling for consistency and add compatibility with output content. --- app/models/models.py | 7 ++-- app/server/chat.py | 83 ++++++++++++++++++++++++++++++++------------ 2 files changed, 65 insertions(+), 25 deletions(-) diff --git a/app/models/models.py b/app/models/models.py index 8d5102c..bbc2140 100644 --- a/app/models/models.py +++ b/app/models/models.py @@ -127,7 +127,6 @@ class ChatCompletionResponse(BaseModel): model: str choices: List[Choice] usage: Usage - service_tier: Optional[str] = None class ModelListResponse(BaseModel): @@ -234,8 +233,9 @@ class ResponseUsage(BaseModel): class ResponseOutputContent(BaseModel): """Content item for Responses API output.""" - type: Literal["output_text"] + type: Literal["output_text", "output_image"] text: Optional[str] = None + image_url: Optional[str] = None annotations: List[Dict[str, Any]] = Field(default_factory=list) @@ -285,10 +285,11 @@ class ResponseCreateResponse(BaseModel): "cancelled", "requires_action", ] = "completed" + tool_choice: Optional[Union[str, ResponseToolChoice]] = None + tools: Optional[List[Union[Tool, ResponseImageTool]]] = None usage: ResponseUsage error: Optional[Dict[str, Any]] = None metadata: Optional[Dict[str, Any]] = None - system_fingerprint: Optional[str] = None input: Optional[Union[str, List[ResponseInputItem]]] = None diff --git a/app/server/chat.py b/app/server/chat.py index ef508b9..cb498a5 100644 --- a/app/server/chat.py +++ b/app/server/chat.py @@ -992,6 +992,7 @@ async def _build_payload( detail = f"{detail} Assistant response: {summary}" raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail=detail) + response_contents: list[ResponseOutputContent] = [] image_call_items: list[ResponseImageGenerationCall] = [] for image in images: try: @@ -999,15 +1000,25 @@ async def _build_payload( except Exception as exc: logger.warning(f"Failed to download generated image: {exc}") continue + + img_format = "png" if isinstance(image, GeneratedImage) else "jpeg" image_call_items.append( ResponseImageGenerationCall( id=f"img_{uuid.uuid4().hex}", status="completed", result=image_base64, - output_format="png" if isinstance(image, GeneratedImage) else "jpeg", + output_format=img_format, size=f"{width}x{height}" if width and height else None, ) ) + # Add as output_image content for compatibility + response_contents.append( + ResponseOutputContent( + type="output_image", + image_url=f"data:image/{img_format};base64,{image_base64}", + annotations=[], + ) + ) tool_call_items: list[ResponseToolCall] = [] if detected_tool_calls: @@ -1020,7 +1031,6 @@ async def _build_payload( for call in detected_tool_calls ] - response_contents: list[ResponseOutputContent] = [] if assistant_text: response_contents.append( ResponseOutputContent(type="output_text", text=assistant_text, annotations=[]) @@ -1065,6 +1075,8 @@ async def _build_payload( usage=usage, input=normalized_input or None, metadata=request.metadata or None, + tools=request.tools, + tool_choice=request.tool_choice, ) try: @@ -1359,6 +1371,10 @@ def _create_responses_streaming_response( created_snapshot["metadata"] = response_dict["metadata"] if response_dict.get("input") is not None: created_snapshot["input"] = response_dict["input"] + if response_dict.get("tools") is not None: + created_snapshot["tools"] = response_dict["tools"] + if response_dict.get("tool_choice") is not None: + created_snapshot["tool_choice"] = response_dict["tool_choice"] async def generate_stream(): # Emit creation event @@ -1369,30 +1385,53 @@ async def generate_stream(): } yield f"data: {orjson.dumps(data).decode('utf-8')}\n\n" - # Stream textual content, if any - if assistant_text: - for chunk in _iter_stream_segments(assistant_text): - delta_event = { - **base_event, - "type": "response.output_text.delta", - "output_index": 0, - "delta": chunk, - } - yield f"data: {orjson.dumps(delta_event).decode('utf-8')}\n\n" - - done_event = { + # Stream output items (Message/Text, Tool Calls, Images) + for i, item in enumerate(response_payload.output): + item_json = item.model_dump(mode="json", exclude_none=True) + + added_event = { **base_event, - "type": "response.output_text.done", - "output_index": 0, + "type": "response.output_item.added", + "output_index": i, + "item": item_json, } - yield f"data: {orjson.dumps(done_event).decode('utf-8')}\n\n" - else: - done_event = { + yield f"data: {orjson.dumps(added_event).decode('utf-8')}\n\n" + + # 2. Stream content if it's a message (text) + if item.type == "message": + content_text = "" + # Aggregate text content to stream + for c in item.content: + if c.type == "output_text" and c.text: + content_text += c.text + + if content_text: + for chunk in _iter_stream_segments(content_text): + delta_event = { + **base_event, + "type": "response.output_text.delta", + "output_index": i, + "delta": chunk, + } + yield f"data: {orjson.dumps(delta_event).decode('utf-8')}\n\n" + + # Text done + done_event = { + **base_event, + "type": "response.output_text.done", + "output_index": i, + } + yield f"data: {orjson.dumps(done_event).decode('utf-8')}\n\n" + + # 3. Emit output_item.done for all types + # This confirms the item is fully transferred. + item_done_event = { **base_event, - "type": "response.output_text.done", - "output_index": 0, + "type": "response.output_item.done", + "output_index": i, + "item": item_json, } - yield f"data: {orjson.dumps(done_event).decode('utf-8')}\n\n" + yield f"data: {orjson.dumps(item_done_event).decode('utf-8')}\n\n" # Emit completed event with full payload completed_event = { From 16435a2ce12a4d37e9f3cfa758f384000aa41123 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C6=B0u=20Quang=20V=C5=A9?= Date: Thu, 4 Dec 2025 09:50:47 +0700 Subject: [PATCH 015/291] Set default `text` value to an empty string for `ResponseOutputContent` and ensure consistent initialization in image output handling. --- app/models/models.py | 2 +- app/server/chat.py | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/app/models/models.py b/app/models/models.py index bbc2140..2c987b8 100644 --- a/app/models/models.py +++ b/app/models/models.py @@ -234,7 +234,7 @@ class ResponseOutputContent(BaseModel): """Content item for Responses API output.""" type: Literal["output_text", "output_image"] - text: Optional[str] = None + text: Optional[str] = "" image_url: Optional[str] = None annotations: List[Dict[str, Any]] = Field(default_factory=list) diff --git a/app/server/chat.py b/app/server/chat.py index cb498a5..7745a26 100644 --- a/app/server/chat.py +++ b/app/server/chat.py @@ -1015,6 +1015,7 @@ async def _build_payload( response_contents.append( ResponseOutputContent( type="output_image", + text="", image_url=f"data:image/{img_format};base64,{image_base64}", annotations=[], ) From fc99c2d60193f346006f5cf17af4e849d8ea2669 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C6=B0u=20Quang=20V=C5=A9?= Date: Thu, 4 Dec 2025 11:03:50 +0700 Subject: [PATCH 016/291] feat: Add /images endpoint with dedicated router and improved image management Add dedicated router for /images endpoint and refactor image handling logic for better modularity. Enhance temporary image management with secure naming, token verification, and cleanup functionality. --- app/main.py | 9 +++++- app/server/chat.py | 35 ++++++++++++++++-------- app/server/images.py | 15 ++++++++++ app/server/middleware.py | 59 ++++++++++++++++++++++++++++++++++++++++ 4 files changed, 105 insertions(+), 13 deletions(-) create mode 100644 app/server/images.py diff --git a/app/main.py b/app/main.py index 95458d3..c215e2a 100644 --- a/app/main.py +++ b/app/main.py @@ -6,7 +6,12 @@ from .server.chat import router as chat_router from .server.health import router as health_router -from .server.middleware import add_cors_middleware, add_exception_handler +from .server.images import router as images_router +from .server.middleware import ( + add_cors_middleware, + add_exception_handler, + cleanup_expired_images, +) from .services import GeminiClientPool, LMDBConversationStore RETENTION_CLEANUP_INTERVAL_SECONDS = 6 * 60 * 60 # 6 hours @@ -28,6 +33,7 @@ async def _run_retention_cleanup(stop_event: asyncio.Event) -> None: while not stop_event.is_set(): try: store.cleanup_expired() + cleanup_expired_images(store.retention_days) except Exception: logger.exception("LMDB retention cleanup task failed.") @@ -93,5 +99,6 @@ def create_app() -> FastAPI: app.include_router(health_router, tags=["Health"]) app.include_router(chat_router, tags=["Chat"]) + app.include_router(images_router, tags=["Images"]) return app diff --git a/app/server/chat.py b/app/server/chat.py index 7745a26..db92dbc 100644 --- a/app/server/chat.py +++ b/app/server/chat.py @@ -44,7 +44,7 @@ from ..services.client import CODE_BLOCK_HINT, XML_WRAP_HINT from ..utils import g_config from ..utils.helper import estimate_tokens -from .middleware import get_temp_dir, verify_api_key +from .middleware import get_image_store_dir, get_temp_dir, verify_api_key # 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) @@ -588,6 +588,7 @@ async def create_chat_completion( request: ChatCompletionRequest, api_key: str = Depends(verify_api_key), tmp_dir: Path = Depends(get_temp_dir), + image_store: Path = Depends(get_image_store_dir), ): pool = GeminiClientPool() db = LMDBConversationStore() @@ -775,6 +776,7 @@ async def create_response( request: ResponseCreateRequest, api_key: str = Depends(verify_api_key), tmp_dir: Path = Depends(get_temp_dir), + image_store: Path = Depends(get_image_store_dir), ): base_messages, normalized_input = _response_items_to_messages(request.input) if not base_messages: @@ -996,12 +998,16 @@ async def _build_payload( image_call_items: list[ResponseImageGenerationCall] = [] for image in images: try: - image_base64, width, height = await _image_to_base64(image, tmp_dir) + image_base64, width, height, filename = await _image_to_base64(image, tmp_dir) except Exception as exc: logger.warning(f"Failed to download generated image: {exc}") continue img_format = "png" if isinstance(image, GeneratedImage) else "jpeg" + + # Use static URL for compatibility + image_url = f"{request.base_url}images/{filename}" + image_call_items.append( ResponseImageGenerationCall( id=f"img_{uuid.uuid4().hex}", @@ -1013,12 +1019,10 @@ async def _build_payload( ) # Add as output_image content for compatibility response_contents.append( - ResponseOutputContent( - type="output_image", - text="", - image_url=f"data:image/{img_format};base64,{image_base64}", - annotations=[], - ) + ResponseOutputContent(type="output_text", text=image_url, annotations=[]) + ) + response_contents.append( + ResponseOutputContent(type="output_image", text="", image_url=image_url, annotations=[]) ) tool_call_items: list[ResponseToolCall] = [] @@ -1553,8 +1557,8 @@ def _extract_image_dimensions(data: bytes) -> tuple[int | None, int | None]: return None, None -async def _image_to_base64(image: Image, temp_dir: Path) -> tuple[str, int | None, int | None]: - """Persist an image provided by gemini_webapi and return base64 plus dimensions.""" +async def _image_to_base64(image: Image, temp_dir: Path) -> tuple[str, int | None, int | None, str]: + """Persist an image provided by gemini_webapi and return base64 plus dimensions and filename.""" if isinstance(image, GeneratedImage): saved_path = await image.save(path=str(temp_dir), full_size=True) else: @@ -1563,6 +1567,13 @@ async def _image_to_base64(image: Image, temp_dir: Path) -> tuple[str, int | Non if not saved_path: raise ValueError("Failed to save generated image") - data = Path(saved_path).read_bytes() + # Rename file to a random UUID to ensure uniqueness and unpredictability + original_path = Path(saved_path) + random_name = f"img_{uuid.uuid4().hex}{original_path.suffix}" + new_path = temp_dir / random_name + original_path.rename(new_path) + + data = new_path.read_bytes() width, height = _extract_image_dimensions(data) - return base64.b64encode(data).decode("ascii"), width, height + filename = random_name + return base64.b64encode(data).decode("ascii"), width, height, filename diff --git a/app/server/images.py b/app/server/images.py new file mode 100644 index 0000000..2867239 --- /dev/null +++ b/app/server/images.py @@ -0,0 +1,15 @@ +from fastapi import APIRouter, HTTPException +from fastapi.responses import FileResponse + +from ..server.middleware import get_image_store_dir + +router = APIRouter() + + +@router.get("/images/{filename}", tags=["Images"]) +async def get_image(filename: str): + image_store = get_image_store_dir() + file_path = image_store / filename + if not file_path.exists(): + raise HTTPException(status_code=404, detail="Image not found") + return FileResponse(file_path) diff --git a/app/server/middleware.py b/app/server/middleware.py index b12024f..60e4c8d 100644 --- a/app/server/middleware.py +++ b/app/server/middleware.py @@ -1,13 +1,72 @@ +import hashlib +import hmac import tempfile +import time from pathlib import Path from fastapi import Depends, FastAPI, HTTPException, Request, status from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import ORJSONResponse from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer +from loguru import logger from ..utils import g_config +# Persistent directory for storing generated images +IMAGE_STORE_DIR = Path(tempfile.gettempdir()) / "gemini_fastapi_images" +IMAGE_STORE_DIR.mkdir(parents=True, exist_ok=True) + + +def get_image_store_dir() -> Path: + """Returns a persistent directory for storing images.""" + return IMAGE_STORE_DIR + + +def get_image_token(filename: str) -> str: + """Generate a HMAC-SHA256 token for a filename using the API key.""" + secret = g_config.server.api_key + if not secret: + return "" + + msg = filename.encode("utf-8") + secret_bytes = secret.encode("utf-8") + return hmac.new(secret_bytes, msg, hashlib.sha256).hexdigest() + + +def verify_image_token(filename: str, token: str | None) -> bool: + """Verify the provided token against the filename.""" + expected = get_image_token(filename) + if not expected: + return True # No auth required + if not token: + return False + return hmac.compare_digest(token, expected) + + +def cleanup_expired_images(retention_days: int) -> int: + """Delete images in IMAGE_STORE_DIR older than retention_days.""" + if retention_days <= 0: + return 0 + + now = time.time() + retention_seconds = retention_days * 24 * 60 * 60 + cutoff = now - retention_seconds + + count = 0 + for file_path in IMAGE_STORE_DIR.iterdir(): + if not file_path.is_file(): + continue + try: + if file_path.stat().st_mtime < cutoff: + file_path.unlink() + count += 1 + except Exception as e: + logger.warning(f"Failed to delete expired image {file_path}: {e}") + + if count > 0: + logger.info(f"Cleaned up {count} expired images.") + return count + def global_exception_handler(request: Request, exc: Exception): if isinstance(exc, HTTPException): From 28441765f3fa47787027620cdc4a6d9e7ddbdd94 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C6=B0u=20Quang=20V=C5=A9?= Date: Thu, 4 Dec 2025 11:10:29 +0700 Subject: [PATCH 017/291] feat: Add token-based verification for image access --- app/server/chat.py | 4 ++-- app/server/images.py | 9 ++++++--- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/app/server/chat.py b/app/server/chat.py index db92dbc..9371137 100644 --- a/app/server/chat.py +++ b/app/server/chat.py @@ -44,7 +44,7 @@ from ..services.client import CODE_BLOCK_HINT, XML_WRAP_HINT from ..utils import g_config from ..utils.helper import estimate_tokens -from .middleware import get_image_store_dir, get_temp_dir, verify_api_key +from .middleware import get_image_store_dir, get_image_token, get_temp_dir, verify_api_key # 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) @@ -1006,7 +1006,7 @@ async def _build_payload( img_format = "png" if isinstance(image, GeneratedImage) else "jpeg" # Use static URL for compatibility - image_url = f"{request.base_url}images/{filename}" + image_url = f"{request.base_url}images/{filename}?token={get_image_token(filename)}" image_call_items.append( ResponseImageGenerationCall( diff --git a/app/server/images.py b/app/server/images.py index 2867239..fe078f7 100644 --- a/app/server/images.py +++ b/app/server/images.py @@ -1,13 +1,16 @@ -from fastapi import APIRouter, HTTPException +from fastapi import APIRouter, HTTPException, Query from fastapi.responses import FileResponse -from ..server.middleware import get_image_store_dir +from ..server.middleware import get_image_store_dir, verify_image_token router = APIRouter() @router.get("/images/{filename}", tags=["Images"]) -async def get_image(filename: str): +async def get_image(filename: str, token: str | None = Query(default=None)): + if not verify_image_token(filename, token): + raise HTTPException(status_code=403, detail="Invalid token") + image_store = get_image_store_dir() file_path = image_store / filename if not file_path.exists(): From 4509c14dfd5a38dfa6b989b3e9ac308e3bc8c982 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C6=B0u=20Quang=20V=C5=A9?= Date: Thu, 4 Dec 2025 11:22:07 +0700 Subject: [PATCH 018/291] Refactor: rename image store directory to `ai_generated_images` for clarity --- app/server/middleware.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/server/middleware.py b/app/server/middleware.py index 60e4c8d..630e1f5 100644 --- a/app/server/middleware.py +++ b/app/server/middleware.py @@ -13,7 +13,7 @@ from ..utils import g_config # Persistent directory for storing generated images -IMAGE_STORE_DIR = Path(tempfile.gettempdir()) / "gemini_fastapi_images" +IMAGE_STORE_DIR = Path(tempfile.gettempdir()) / "ai_generated_images" IMAGE_STORE_DIR.mkdir(parents=True, exist_ok=True) From 75e2f61d3a6b1d12269af2ee82344ab643f34e83 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C6=B0u=20Quang=20V=C5=A9?= Date: Thu, 4 Dec 2025 11:31:15 +0700 Subject: [PATCH 019/291] fix: Update create_response to use FastAPI Request object for base_url and refactor variable handling --- app/server/chat.py | 44 +++++++++++++++++++++----------------------- 1 file changed, 21 insertions(+), 23 deletions(-) diff --git a/app/server/chat.py b/app/server/chat.py index 9371137..0010f4a 100644 --- a/app/server/chat.py +++ b/app/server/chat.py @@ -9,7 +9,7 @@ from typing import Any, Iterator import orjson -from fastapi import APIRouter, Depends, HTTPException, status +from fastapi import APIRouter, Depends, HTTPException, Request, status from fastapi.responses import StreamingResponse from gemini_webapi.client import ChatSession from gemini_webapi.constants import Model @@ -773,19 +773,15 @@ async def create_chat_completion( @router.post("/v1/responses") async def create_response( - request: ResponseCreateRequest, + request_data: ResponseCreateRequest, + request: Request, api_key: str = Depends(verify_api_key), tmp_dir: Path = Depends(get_temp_dir), image_store: Path = Depends(get_image_store_dir), ): - base_messages, normalized_input = _response_items_to_messages(request.input) - if not base_messages: - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, detail="No message input provided." - ) - - structured_requirement = _build_structured_requirement(request.response_format) - if structured_requirement and request.stream: + base_messages, normalized_input = _response_items_to_messages(request_data.input) + structured_requirement = _build_structured_requirement(request_data.response_format) + if structured_requirement and request_data.stream: logger.debug( "Structured response requested with streaming enabled; streaming not supported for Responses." ) @@ -801,7 +797,7 @@ async def create_response( standard_tools: list[Tool] = [] image_tools: list[ResponseImageTool] = [] - if request.tools: + if request_data.tools: for t in request.tools: if isinstance(t, Tool): standard_tools.append(t) @@ -817,13 +813,15 @@ async def create_response( image_instruction = _build_image_generation_instruction( image_tools, - request.tool_choice if isinstance(request.tool_choice, ResponseToolChoice) else None, + request_data.tool_choice + if isinstance(request_data.tool_choice, ResponseToolChoice) + else None, ) if image_instruction: extra_instructions.append(image_instruction) logger.debug("Image generation support enabled for /v1/responses request.") - preface_messages = _instructions_to_messages(request.instructions) + preface_messages = _instructions_to_messages(request_data.instructions) conversation_messages = base_messages if preface_messages: conversation_messages = [*preface_messages, *base_messages] @@ -834,10 +832,10 @@ async def create_response( # Pass standard tools to the prompt builder # Determine tool_choice for standard tools (ignore image_generation choice here as it is handled via instruction) model_tool_choice = None - if isinstance(request.tool_choice, str): - model_tool_choice = request.tool_choice - elif isinstance(request.tool_choice, ToolChoiceFunction): - model_tool_choice = request.tool_choice + if isinstance(request_data.tool_choice, str): + model_tool_choice = request_data.tool_choice + elif isinstance(request_data.tool_choice, ToolChoiceFunction): + model_tool_choice = request_data.tool_choice # If tool_choice is ResponseToolChoice (image_generation), we don't pass it as a function tool choice. messages = _prepare_messages_for_model( @@ -851,7 +849,7 @@ async def create_response( db = LMDBConversationStore() try: - model = Model.from_name(request.model) + model = Model.from_name(request_data.model) except ValueError as exc: raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc @@ -971,7 +969,7 @@ async def _build_payload( ) expects_image = ( - request.tool_choice is not None and request.tool_choice.type == "image_generation" + request_data.tool_choice is not None and request_data.tool_choice.type == "image_generation" ) images = model_output.images or [] logger.debug( @@ -1065,7 +1063,7 @@ async def _build_payload( response_payload = ResponseCreateResponse( id=response_id, created_at=created_time, - model=request.model, + model=request_data.model, output=[ ResponseOutputMessage( id=message_id, @@ -1079,9 +1077,9 @@ async def _build_payload( status="completed", usage=usage, input=normalized_input or None, - metadata=request.metadata or None, - tools=request.tools, - tool_choice=request.tool_choice, + metadata=request_data.metadata or None, + tools=request_data.tools, + tool_choice=request_data.tool_choice, ) try: From bde6d0d146fc9088df947cfc0958dc88963e93ca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C6=B0u=20Quang=20V=C5=A9?= Date: Thu, 4 Dec 2025 11:35:44 +0700 Subject: [PATCH 020/291] fix: Correct attribute access in request_data handling within `chat.py` for tools, tool_choice, and streaming settings --- app/server/chat.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/app/server/chat.py b/app/server/chat.py index 0010f4a..9a3f19f 100644 --- a/app/server/chat.py +++ b/app/server/chat.py @@ -798,7 +798,7 @@ async def create_response( image_tools: list[ResponseImageTool] = [] if request_data.tools: - for t in request.tools: + for t in request_data.tools: if isinstance(t, Tool): standard_tools.append(t) elif isinstance(t, ResponseImageTool): @@ -984,7 +984,7 @@ async def _build_payload( summary = f"{summary[:197]}..." logger.warning( "Image generation requested but Gemini produced no images. " - f"client_id={client_id}, forced_tool_choice={request.tool_choice is not None}, " + f"client_id={client_id}, forced_tool_choice={request_data.tool_choice is not None}, " f"instruction_applied={bool(image_instruction)}, assistant_preview='{summary}'" ) detail = "LLM returned no images for the requested image_generation tool." @@ -1100,7 +1100,7 @@ async def _build_payload( except Exception as exc: logger.warning(f"Failed to save Responses conversation to LMDB: {exc}") - if request.stream: + if request_data.stream: logger.debug( f"Streaming Responses API payload (response_id={response_payload.id}, text_chunks={bool(assistant_text)})." ) From 601451a8dbf8cf689a482fd75cda399b5e815cd9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C6=B0u=20Quang=20V=C5=A9?= Date: Thu, 4 Dec 2025 11:45:49 +0700 Subject: [PATCH 021/291] fix: Save generated images to persistent storage --- app/server/chat.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/server/chat.py b/app/server/chat.py index 9a3f19f..4246c53 100644 --- a/app/server/chat.py +++ b/app/server/chat.py @@ -996,7 +996,7 @@ async def _build_payload( image_call_items: list[ResponseImageGenerationCall] = [] for image in images: try: - image_base64, width, height, filename = await _image_to_base64(image, tmp_dir) + image_base64, width, height, filename = await _image_to_base64(image, image_store) except Exception as exc: logger.warning(f"Failed to download generated image: {exc}") continue From 893eb6d47305f60c4b13896bfc48beb89909dd88 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C6=B0u=20Quang=20V=C5=A9?= Date: Thu, 4 Dec 2025 11:51:42 +0700 Subject: [PATCH 022/291] fix: Remove unused `output_image` type from `ResponseOutputContent` and update response handling for consistency --- app/models/models.py | 3 +-- app/server/chat.py | 5 +---- 2 files changed, 2 insertions(+), 6 deletions(-) diff --git a/app/models/models.py b/app/models/models.py index 2c987b8..c27e024 100644 --- a/app/models/models.py +++ b/app/models/models.py @@ -233,9 +233,8 @@ class ResponseUsage(BaseModel): class ResponseOutputContent(BaseModel): """Content item for Responses API output.""" - type: Literal["output_text", "output_image"] + type: Literal["output_text"] text: Optional[str] = "" - image_url: Optional[str] = None annotations: List[Dict[str, Any]] = Field(default_factory=list) diff --git a/app/server/chat.py b/app/server/chat.py index 4246c53..3396df0 100644 --- a/app/server/chat.py +++ b/app/server/chat.py @@ -1015,13 +1015,10 @@ async def _build_payload( size=f"{width}x{height}" if width and height else None, ) ) - # Add as output_image content for compatibility + # Add as output_text content for compatibility response_contents.append( ResponseOutputContent(type="output_text", text=image_url, annotations=[]) ) - response_contents.append( - ResponseOutputContent(type="output_image", text="", image_url=image_url, annotations=[]) - ) tool_call_items: list[ResponseToolCall] = [] if detected_tool_calls: From 80462b586a110cad7e5b5cc259424e405ecbafc3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C6=B0u=20Quang=20V=C5=A9?= Date: Thu, 4 Dec 2025 13:24:14 +0700 Subject: [PATCH 023/291] fix: Update image URL generation in chat response to use Markdown format for compatibility --- app/server/chat.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/app/server/chat.py b/app/server/chat.py index 3396df0..c2a60ab 100644 --- a/app/server/chat.py +++ b/app/server/chat.py @@ -1004,7 +1004,9 @@ async def _build_payload( img_format = "png" if isinstance(image, GeneratedImage) else "jpeg" # Use static URL for compatibility - image_url = f"{request.base_url}images/{filename}?token={get_image_token(filename)}" + image_url = ( + f"![{filename}]({request.base_url}images/{filename}?token={get_image_token(filename)})" + ) image_call_items.append( ResponseImageGenerationCall( From 8d49a72e0b5c605e2439d6dcbf149925cb670ded Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C6=B0u=20Quang=20V=C5=A9?= Date: Mon, 8 Dec 2025 09:45:58 +0700 Subject: [PATCH 024/291] fix: Enhance error handling for full-size image saving and add fallback to default size --- app/server/chat.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/app/server/chat.py b/app/server/chat.py index c2a60ab..d14e9ce 100644 --- a/app/server/chat.py +++ b/app/server/chat.py @@ -1557,7 +1557,11 @@ def _extract_image_dimensions(data: bytes) -> tuple[int | None, int | None]: async def _image_to_base64(image: Image, temp_dir: Path) -> tuple[str, int | None, int | None, str]: """Persist an image provided by gemini_webapi and return base64 plus dimensions and filename.""" if isinstance(image, GeneratedImage): - saved_path = await image.save(path=str(temp_dir), full_size=True) + try: + saved_path = await image.save(path=str(temp_dir), full_size=True) + except Exception as e: + logger.warning(f"Failed to download full-size image, retrying with default size: {e}") + saved_path = await image.save(path=str(temp_dir), full_size=False) else: saved_path = await image.save(path=str(temp_dir)) From d37eae0ab8c4590b3301dc8853ef22a512ab0d98 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C6=B0u=20Quang=20V=C5=A9?= Date: Tue, 9 Dec 2025 20:46:03 +0700 Subject: [PATCH 025/291] fix: Use filename as image ID to ensure consistency in generated image handling --- app/server/chat.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/server/chat.py b/app/server/chat.py index d14e9ce..fc69293 100644 --- a/app/server/chat.py +++ b/app/server/chat.py @@ -1010,7 +1010,7 @@ async def _build_payload( image_call_items.append( ResponseImageGenerationCall( - id=f"img_{uuid.uuid4().hex}", + id=filename.split(".")[0], status="completed", result=image_base64, output_format=img_format, From b9f776dfbb9d251ee016e05a1f6001907c3f8b84 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C6=B0u=20Quang=20V=C5=A9?= Date: Tue, 16 Dec 2025 19:50:07 +0700 Subject: [PATCH 026/291] fix: Enhance tempfile saving by adding custom headers, content-type handling, and improved extension determination --- app/utils/helper.py | 24 +++++++++++++++++++----- 1 file changed, 19 insertions(+), 5 deletions(-) diff --git a/app/utils/helper.py b/app/utils/helper.py index 3bff469..89fc31e 100644 --- a/app/utils/helper.py +++ b/app/utils/helper.py @@ -2,12 +2,17 @@ import mimetypes import tempfile from pathlib import Path +from urllib.parse import urlparse import httpx from loguru import logger VALID_TAG_ROLES = {"user", "assistant", "system", "tool"} +HEADERS = { + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36" +} + def add_tag(role: str, content: str, unclose: bool = False) -> str: """Surround content with role tags""" @@ -36,7 +41,7 @@ async def save_file_to_tempfile( return path -async def save_url_to_tempfile(url: str, tempdir: Path | None = None): +async def save_url_to_tempfile(url: str, tempdir: Path | None = None) -> Path: data: bytes | None = None suffix: str | None = None if url.startswith("data:image/"): @@ -47,17 +52,26 @@ async def save_url_to_tempfile(url: str, tempdir: Path | None = None): base64_data = url.split(",")[1] data = base64.b64decode(base64_data) - # Guess extension from mime type, default to the subtype if not found suffix = mimetypes.guess_extension(mime_type) if not suffix: suffix = f".{mime_type.split('/')[1]}" else: - # http files - async with httpx.AsyncClient() as client: + async with httpx.AsyncClient(headers=HEADERS, follow_redirects=True) as client: resp = await client.get(url) resp.raise_for_status() data = resp.content - suffix = Path(url).suffix or ".bin" + content_type = resp.headers.get("content-type") + + if content_type: + mime_type = content_type.split(";")[0].strip() + suffix = mimetypes.guess_extension(mime_type) + + if not suffix: + path_url = urlparse(url).path + suffix = Path(path_url).suffix + + if not suffix: + suffix = ".bin" with tempfile.NamedTemporaryFile(delete=False, suffix=suffix, dir=tempdir) as tmp: tmp.write(data) From 4b5fe078250ce0496ca93b1861f9622fc5171746 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C6=B0u=20Quang=20V=C5=A9?= Date: Tue, 30 Dec 2025 22:39:05 +0700 Subject: [PATCH 027/291] feat: Add support for custom Gemini models and model loading strategies - Introduced `model_strategy` configuration for "append" (default + custom models) or "overwrite" (custom models only). - Enhanced `/v1/models` endpoint to return models based on the configured strategy. - Improved model loading with environment variable overrides and validation. - Refactored model handling logic for improved modularity and error handling. --- app/server/chat.py | 70 ++++++++++++++++++++++++++++++++++-------- app/utils/config.py | 75 ++++++++++++++++++++++++++++++++++++++++++++- config/config.yaml | 5 +++ 3 files changed, 136 insertions(+), 14 deletions(-) diff --git a/app/server/chat.py b/app/server/chat.py index fc69293..0a4c16c 100644 --- a/app/server/chat.py +++ b/app/server/chat.py @@ -562,24 +562,64 @@ def _replace(match: re.Match[str]) -> str: return cleaned, tool_calls -@router.get("/v1/models", response_model=ModelListResponse) -async def list_models(api_key: str = Depends(verify_api_key)): - now = int(datetime.now(tz=timezone.utc).timestamp()) +def _get_model_by_name(name: str) -> Model: + """ + Retrieve a Model instance by name, considering custom models from config + and the update strategy (append or overwrite). + """ + strategy = g_config.gemini.model_strategy + custom_models = {m.model_name: m for m in g_config.gemini.models if m.model_name} - models = [] - for model in Model: - m_name = model.model_name - if not m_name or m_name == "unspecified": - continue + if name in custom_models: + return Model.from_dict(custom_models[name].model_dump()) + + if strategy == "overwrite": + raise ValueError(f"Model '{name}' not found in custom models (strategy='overwrite').") - models.append( + return Model.from_name(name) + + +def _get_available_models() -> list[ModelData]: + """ + Return a list of available models based on configuration strategy. + """ + now = int(datetime.now(tz=timezone.utc).timestamp()) + strategy = g_config.gemini.model_strategy + models_data = [] + + custom_models = [m for m in g_config.gemini.models if m.model_name] + for m in custom_models: + models_data.append( ModelData( - id=m_name, + id=m.model_name, created=now, - owned_by="gemini-web", + owned_by="custom", ) ) + if strategy == "append": + custom_ids = {m.model_name for m in custom_models} + for model in Model: + m_name = model.model_name + if not m_name or m_name == "unspecified": + continue + if m_name in custom_ids: + continue + + models_data.append( + ModelData( + id=m_name, + created=now, + owned_by="gemini-web", + ) + ) + + return models_data + + +@router.get("/v1/models", response_model=ModelListResponse) +async def list_models(api_key: str = Depends(verify_api_key)): + models = _get_available_models() return ModelListResponse(data=models) @@ -592,7 +632,11 @@ async def create_chat_completion( ): pool = GeminiClientPool() db = LMDBConversationStore() - model = Model.from_name(request.model) + + try: + model = _get_model_by_name(request.model) + except ValueError as exc: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc if len(request.messages) == 0: raise HTTPException( @@ -849,7 +893,7 @@ async def create_response( db = LMDBConversationStore() try: - model = Model.from_name(request_data.model) + model = _get_model_by_name(request_data.model) except ValueError as exc: raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc diff --git a/app/utils/config.py b/app/utils/config.py index 796ca75..a5c924a 100644 --- a/app/utils/config.py +++ b/app/utils/config.py @@ -50,12 +50,26 @@ def _blank_proxy_to_none(cls, value: Optional[str]) -> Optional[str]: return stripped or None +class GeminiModelConfig(BaseModel): + """Configuration for a custom Gemini model.""" + + model_name: Optional[str] = Field(default=None, description="Name of the model") + model_header: Optional[dict[str, Optional[str]]] = Field( + default=None, description="Header for the model" + ) + + class GeminiConfig(BaseModel): """Gemini API configuration""" clients: list[GeminiClientSettings] = Field( ..., description="List of Gemini client credential pairs" ) + models: list[GeminiModelConfig] = Field(default=[], description="List of custom Gemini models") + model_strategy: Literal["append", "overwrite"] = Field( + default="append", + description="Strategy for loading models: 'append' merges custom with default, 'overwrite' uses only custom", + ) timeout: int = Field(default=120, ge=1, description="Init timeout") auto_refresh: bool = Field(True, description="Enable auto-refresh for Gemini cookies") refresh_interval: int = Field( @@ -68,6 +82,13 @@ class GeminiConfig(BaseModel): description="Maximum characters Gemini Web can accept per request", ) + @field_validator("models") + @classmethod + def _filter_valid_models(cls, v: list[GeminiModelConfig]) -> list[GeminiModelConfig]: + """Filter out models that don't have a name set (placeholders).""" + + return [model for model in v if model.model_name] + class CORSConfig(BaseModel): """CORS configuration""" @@ -211,6 +232,53 @@ def _merge_clients_with_env( return result_clients if result_clients else base_clients +def extract_gemini_models_env() -> dict[int, dict[str, str]]: + """Extract and remove all Gemini models related environment variables, return a mapping from index to field dict.""" + prefix = "CONFIG_GEMINI__MODELS__" + env_overrides: dict[int, dict[str, str]] = {} + to_delete = [] + for k, v in os.environ.items(): + if k.startswith(prefix): + parts = k.split("__") + if len(parts) < 4: + continue + index_str, field = parts[2], parts[3].lower() + if not index_str.isdigit(): + continue + idx = int(index_str) + env_overrides.setdefault(idx, {})[field] = v + to_delete.append(k) + # Remove these environment variables to avoid Pydantic parsing errors + for k in to_delete: + del os.environ[k] + return env_overrides + + +def _merge_models_with_env( + base_models: list[GeminiModelConfig] | None, + env_overrides: dict[int, dict[str, str]], +): + """Override base_models with env_overrides, return the new models list.""" + if not env_overrides: + return base_models or [] + result_models: list[GeminiModelConfig] = [] + if base_models: + result_models = [model.model_copy() for model in base_models] + + for idx in sorted(env_overrides): + overrides = env_overrides[idx] + if idx < len(result_models): + model_dict = result_models[idx].model_dump() + model_dict.update(overrides) + result_models[idx] = GeminiModelConfig(**model_dict) + elif idx == len(result_models): + new_model = GeminiModelConfig(**overrides) + result_models.append(new_model) + else: + raise IndexError(f"Model index {idx} in env is out of range (must be contiguous).") + return result_models + + def initialize_config() -> Config: """ Initialize the configuration. @@ -221,6 +289,8 @@ def initialize_config() -> Config: try: # First, extract and remove Gemini clients related environment variables env_clients_overrides = extract_gemini_clients_env() + # Extract and remove Gemini models related environment variables + env_models_overrides = extract_gemini_models_env() # Then, initialize Config with pydantic_settings config = Config() # type: ignore @@ -228,7 +298,10 @@ def initialize_config() -> Config: # Synthesize clients config.gemini.clients = _merge_clients_with_env( config.gemini.clients, env_clients_overrides - ) # type: ignore + ) + + # Synthesize models + config.gemini.models = _merge_models_with_env(config.gemini.models, env_models_overrides) return config except ValidationError as e: diff --git a/config/config.yaml b/config/config.yaml index 89c88b7..84c4602 100644 --- a/config/config.yaml +++ b/config/config.yaml @@ -27,6 +27,11 @@ gemini: refresh_interval: 540 # Refresh interval in seconds verbose: false # Enable verbose logging for Gemini requests max_chars_per_request: 1000000 # Maximum characters Gemini Web accepts per request. Non-pro users might have a lower limit + models: + - model_name: null + model_header: + x-goog-ext-xxxxxxxxx-jspb: null + model_strategy: "append" # Strategy: 'append' (default + custom) or 'overwrite' (custom only) storage: path: "data/lmdb" # Database storage path From 5cb29e8ea7333fd3c207f60a75b5269105bae8b1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C6=B0u=20Quang=20V=C5=A9?= Date: Tue, 30 Dec 2025 23:19:49 +0700 Subject: [PATCH 028/291] feat: Improve Gemini model environment variable parsing and nested field support - Enhanced `extract_gemini_models_env` to handle nested fields within environment variables. - Updated type hints for more flexibility in model overrides. - Improved `_merge_models_with_env` to better support field-level updates and appending new models. --- app/utils/config.py | 31 +++++++++++++++++++++++-------- config/config.yaml | 2 +- 2 files changed, 24 insertions(+), 9 deletions(-) diff --git a/app/utils/config.py b/app/utils/config.py index a5c924a..5782c66 100644 --- a/app/utils/config.py +++ b/app/utils/config.py @@ -1,6 +1,6 @@ import os import sys -from typing import Literal, Optional +from typing import Any, Literal, Optional from loguru import logger from pydantic import BaseModel, Field, ValidationError, field_validator @@ -232,21 +232,34 @@ def _merge_clients_with_env( return result_clients if result_clients else base_clients -def extract_gemini_models_env() -> dict[int, dict[str, str]]: - """Extract and remove all Gemini models related environment variables, return a mapping from index to field dict.""" +def extract_gemini_models_env() -> dict[int, dict[str, Any]]: + """Extract and remove all Gemini models related environment variables, supporting nested fields.""" prefix = "CONFIG_GEMINI__MODELS__" - env_overrides: dict[int, dict[str, str]] = {} + env_overrides: dict[int, dict[str, Any]] = {} to_delete = [] for k, v in os.environ.items(): if k.startswith(prefix): parts = k.split("__") if len(parts) < 4: continue - index_str, field = parts[2], parts[3].lower() + index_str = parts[2] if not index_str.isdigit(): continue idx = int(index_str) - env_overrides.setdefault(idx, {})[field] = v + + # Navigate to the correct nested dict + current = env_overrides.setdefault(idx, {}) + for i in range(3, len(parts) - 1): + field_name = parts[i].lower() + current = current.setdefault(field_name, {}) + + # Set the value (lowercase root field names, preserve sub-key casing) + last_part = parts[-1] + if len(parts) == 4: + current[last_part.lower()] = v + else: + current[last_part] = v + to_delete.append(k) # Remove these environment variables to avoid Pydantic parsing errors for k in to_delete: @@ -256,9 +269,9 @@ def extract_gemini_models_env() -> dict[int, dict[str, str]]: def _merge_models_with_env( base_models: list[GeminiModelConfig] | None, - env_overrides: dict[int, dict[str, str]], + env_overrides: dict[int, dict[str, Any]], ): - """Override base_models with env_overrides, return the new models list.""" + """Override base_models with env_overrides using standard update (replace whole fields).""" if not env_overrides: return base_models or [] result_models: list[GeminiModelConfig] = [] @@ -268,10 +281,12 @@ def _merge_models_with_env( for idx in sorted(env_overrides): overrides = env_overrides[idx] if idx < len(result_models): + # Update existing model: overwrite fields found in env model_dict = result_models[idx].model_dump() model_dict.update(overrides) result_models[idx] = GeminiModelConfig(**model_dict) elif idx == len(result_models): + # Append new model new_model = GeminiModelConfig(**overrides) result_models.append(new_model) else: diff --git a/config/config.yaml b/config/config.yaml index 84c4602..2fbc061 100644 --- a/config/config.yaml +++ b/config/config.yaml @@ -27,11 +27,11 @@ gemini: refresh_interval: 540 # Refresh interval in seconds verbose: false # Enable verbose logging for Gemini requests max_chars_per_request: 1000000 # Maximum characters Gemini Web accepts per request. Non-pro users might have a lower limit + model_strategy: "append" # Strategy: 'append' (default + custom) or 'overwrite' (custom only) models: - model_name: null model_header: x-goog-ext-xxxxxxxxx-jspb: null - model_strategy: "append" # Strategy: 'append' (default + custom) or 'overwrite' (custom only) storage: path: "data/lmdb" # Database storage path From f25f16d00118ebeea7936cea34797270d5137b5b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C6=B0u=20Quang=20V=C5=A9?= Date: Wed, 31 Dec 2025 09:52:49 +0700 Subject: [PATCH 029/291] refactor: Consolidate utility functions and clean up unused code - Moved utility functions like `strip_code_fence`, `extract_tool_calls`, and `iter_stream_segments` to a centralized helper module. - Removed unused and redundant private methods from `chat.py`, including `_strip_code_fence`, `_strip_tagged_blocks`, and `_strip_system_hints`. - Updated imports and references across modules for consistency. - Simplified tool call and streaming logic by replacing inline implementations with shared helper functions. --- app/server/chat.py | 306 ++++------------------------------------ app/services/client.py | 16 +-- app/utils/helper.py | 312 ++++++++++++++++++++++++++++++++++++++++- 3 files changed, 342 insertions(+), 292 deletions(-) diff --git a/app/server/chat.py b/app/server/chat.py index 0a4c16c..9485f7a 100644 --- a/app/server/chat.py +++ b/app/server/chat.py @@ -1,12 +1,11 @@ import base64 import json import re -import struct import uuid from dataclasses import dataclass from datetime import datetime, timezone from pathlib import Path -from typing import Any, Iterator +from typing import Any import orjson from fastapi import APIRouter, Depends, HTTPException, Request, status @@ -21,7 +20,6 @@ ChatCompletionRequest, ContentItem, ConversationInStore, - FunctionCall, Message, ModelData, ModelListResponse, @@ -37,26 +35,28 @@ ResponseToolChoice, ResponseUsage, Tool, - ToolCall, ToolChoiceFunction, ) from ..services import GeminiClientPool, GeminiClientWrapper, LMDBConversationStore -from ..services.client import CODE_BLOCK_HINT, XML_WRAP_HINT from ..utils import g_config -from ..utils.helper import estimate_tokens +from ..utils.helper import ( + CODE_BLOCK_HINT, + CODE_HINT_STRIPPED, + XML_HINT_STRIPPED, + XML_WRAP_HINT, + estimate_tokens, + extract_image_dimensions, + extract_tool_calls, + iter_stream_segments, + remove_tool_call_blocks, + strip_code_fence, + text_from_message, +) from .middleware import get_image_store_dir, get_image_token, get_temp_dir, verify_api_key # 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.')" -TOOL_BLOCK_RE = re.compile(r"```xml\s*(.*?)```", re.DOTALL | re.IGNORECASE) -TOOL_CALL_RE = re.compile( - r"(.*?)", re.DOTALL | re.IGNORECASE -) -JSON_FENCE_RE = re.compile(r"^```(?:json)?\s*(.*?)\s*```$", re.DOTALL | re.IGNORECASE) -CONTROL_TOKEN_RE = re.compile(r"<\|im_(?:start|end)\|>") -XML_HINT_STRIPPED = XML_WRAP_HINT.strip() -CODE_HINT_STRIPPED = CODE_BLOCK_HINT.strip() router = APIRouter() @@ -118,14 +118,6 @@ def _build_structured_requirement( ) -def _strip_code_fence(text: str) -> str: - """Remove surrounding ```json fences if present.""" - match = JSON_FENCE_RE.match(text.strip()) - if match: - return match.group(1).strip() - return text.strip() - - def _build_tool_prompt( tools: list[Tool], tool_choice: str | ToolChoiceFunction | None, @@ -312,75 +304,6 @@ def _prepare_messages_for_model( return prepared -def _strip_system_hints(text: str) -> str: - """Remove system-level hint text from a given string.""" - if not text: - return text - cleaned = _strip_tagged_blocks(text) - cleaned = cleaned.replace(XML_WRAP_HINT, "").replace(XML_HINT_STRIPPED, "") - cleaned = cleaned.replace(CODE_BLOCK_HINT, "").replace(CODE_HINT_STRIPPED, "") - cleaned = CONTROL_TOKEN_RE.sub("", cleaned) - return cleaned.strip() - - -def _strip_tagged_blocks(text: str) -> str: - """Remove <|im_start|>role ... <|im_end|> sections, dropping tool blocks entirely. - - tool blocks are removed entirely (if missing end marker, drop to EOF). - - other roles: remove markers and role, keep inner content (if missing end marker, keep to EOF). - """ - if not text: - return text - - result: list[str] = [] - idx = 0 - length = len(text) - start_marker = "<|im_start|>" - end_marker = "<|im_end|>" - - while idx < length: - start = text.find(start_marker, idx) - if start == -1: - result.append(text[idx:]) - break - - # append any content before this block - result.append(text[idx:start]) - - role_start = start + len(start_marker) - newline = text.find("\n", role_start) - if newline == -1: - # malformed block; keep remainder as-is (safe behavior) - result.append(text[start:]) - break - - role = text[role_start:newline].strip().lower() - - end = text.find(end_marker, newline + 1) - if end == -1: - # missing end marker - if role == "tool": - # drop from start marker to EOF (skip remainder) - break - else: - # keep inner content from after the role newline to EOF - result.append(text[newline + 1 :]) - break - - block_end = end + len(end_marker) - - if role == "tool": - # drop whole block - idx = block_end - continue - - # keep the content without role markers - content = text[newline + 1 : end] - result.append(content) - idx = block_end - - return "".join(result) - - def _response_items_to_messages( items: str | list[ResponseInputItem], ) -> tuple[list[Message], str | list[ResponseInputItem]]: @@ -509,59 +432,6 @@ def _instructions_to_messages( return instruction_messages -def _remove_tool_call_blocks(text: str) -> str: - """Strip tool call code blocks from text.""" - if not text: - return text - cleaned = TOOL_BLOCK_RE.sub("", text) - return _strip_system_hints(cleaned) - - -def _extract_tool_calls(text: str) -> tuple[str, list[ToolCall]]: - """Extract tool call definitions and return cleaned text.""" - if not text: - return text, [] - - tool_calls: list[ToolCall] = [] - - def _replace(match: re.Match[str]) -> str: - block_content = match.group(1) - if not block_content: - return "" - - for call_match in TOOL_CALL_RE.finditer(block_content): - name = (call_match.group(1) or "").strip() - raw_args = (call_match.group(2) or "").strip() - if not name: - logger.warning( - f"Encountered tool_call block without a function name: {block_content}" - ) - continue - - arguments = raw_args - try: - parsed_args = json.loads(raw_args) - arguments = json.dumps(parsed_args, ensure_ascii=False) - except json.JSONDecodeError: - logger.warning( - f"Failed to parse tool call arguments for '{name}'. Passing raw string." - ) - - tool_calls.append( - ToolCall( - id=f"call_{uuid.uuid4().hex}", - type="function", - function=FunctionCall(name=name, arguments=arguments), - ) - ) - - return "" - - cleaned = TOOL_BLOCK_RE.sub(_replace, text) - cleaned = _strip_system_hints(cleaned) - return cleaned, tool_calls - - def _get_model_by_name(name: str) -> Model: """ Retrieve a Model instance by name, considering custom models from config @@ -742,12 +612,12 @@ async def create_chat_completion( detail="Gemini output parsing failed unexpectedly.", ) from exc - visible_output, tool_calls = _extract_tool_calls(raw_output_with_think) - storage_output = _remove_tool_call_blocks(raw_output_clean).strip() + visible_output, tool_calls = extract_tool_calls(raw_output_with_think) + storage_output = remove_tool_call_blocks(raw_output_clean).strip() tool_calls_payload = [call.model_dump(mode="json") for call in tool_calls] if structured_requirement: - cleaned_visible = _strip_code_fence(visible_output or "") + cleaned_visible = strip_code_fence(visible_output or "") if not cleaned_visible: raise HTTPException( status_code=status.HTTP_502_BAD_GATEWAY, @@ -982,12 +852,12 @@ async def _build_payload( detail="Gemini output parsing failed unexpectedly.", ) from exc - visible_text, detected_tool_calls = _extract_tool_calls(text_with_think) - storage_output = _remove_tool_call_blocks(text_without_think).strip() + visible_text, detected_tool_calls = extract_tool_calls(text_with_think) + storage_output = remove_tool_call_blocks(text_without_think).strip() assistant_text = LMDBConversationStore.remove_think_tags(visible_text.strip()) if structured_requirement: - cleaned_visible = _strip_code_fence(assistant_text or "") + cleaned_visible = strip_code_fence(assistant_text or "") if not cleaned_visible: raise HTTPException( status_code=status.HTTP_502_BAD_GATEWAY, @@ -1089,7 +959,7 @@ async def _build_payload( response_id = f"resp_{uuid.uuid4().hex}" message_id = f"msg_{uuid.uuid4().hex}" - input_tokens = sum(estimate_tokens(_text_from_message(msg)) for msg in messages) + input_tokens = sum(estimate_tokens(text_from_message(msg)) for msg in messages) tool_arg_text = "".join(call.function.arguments or "" for call in detected_tool_calls) completion_basis = assistant_text or "" if tool_arg_text: @@ -1152,25 +1022,6 @@ async def _build_payload( return response_payload -def _text_from_message(message: Message) -> str: - """Return text content from a message for token estimation.""" - base_text = "" - if isinstance(message.content, str): - base_text = message.content - elif isinstance(message.content, list): - base_text = "\n".join( - item.text or "" for item in message.content if getattr(item, "type", "") == "text" - ) - elif message.content is None: - base_text = "" - - if message.tool_calls: - tool_arg_text = "".join(call.function.arguments or "" for call in message.tool_calls) - base_text = f"{base_text}\n{tool_arg_text}" if base_text else tool_arg_text - - return base_text - - async def _find_reusable_session( db: LMDBConversationStore, pool: GeminiClientPool, @@ -1268,47 +1119,6 @@ async def _send_with_split(session: ChatSession, text: str, files: list[Path | s raise -def _iter_stream_segments(model_output: str, chunk_size: int = 64): - """Yield stream segments while keeping markers and words intact.""" - if not model_output: - return - - token_pattern = re.compile(r"\s+|\S+\s*") - pending = "" - - def _flush_pending() -> Iterator[str]: - nonlocal pending - if pending: - yield pending - pending = "" - - # Split on boundaries so the markers are never fragmented. - parts = re.split(r"()", model_output) - for part in parts: - if not part: - continue - if part in {"", ""}: - yield from _flush_pending() - yield part - continue - - for match in token_pattern.finditer(part): - token = match.group(0) - - if len(token) > chunk_size: - yield from _flush_pending() - for idx in range(0, len(token), chunk_size): - yield token[idx : idx + chunk_size] - continue - - if pending and len(pending) + len(token) > chunk_size: - yield from _flush_pending() - - pending += token - - yield from _flush_pending() - - def _create_streaming_response( model_output: str, tool_calls: list[dict], @@ -1320,7 +1130,7 @@ def _create_streaming_response( """Create streaming response with `usage` calculation included in the final chunk.""" # Calculate token usage - prompt_tokens = sum(estimate_tokens(_text_from_message(msg)) for msg in messages) + prompt_tokens = sum(estimate_tokens(text_from_message(msg)) for msg in messages) tool_args = "".join(call.get("function", {}).get("arguments", "") for call in tool_calls or []) completion_tokens = estimate_tokens(model_output + tool_args) total_tokens = prompt_tokens + completion_tokens @@ -1338,7 +1148,7 @@ async def generate_stream(): yield f"data: {orjson.dumps(data).decode('utf-8')}\n\n" # Stream output text in chunks for efficiency - for chunk in _iter_stream_segments(model_output): + for chunk in iter_stream_segments(model_output): data = { "id": completion_id, "object": "chat.completion.chunk", @@ -1452,7 +1262,7 @@ async def generate_stream(): content_text += c.text if content_text: - for chunk in _iter_stream_segments(content_text): + for chunk in iter_stream_segments(content_text): delta_event = { **base_event, "type": "response.output_text.delta", @@ -1501,7 +1311,7 @@ def _create_standard_response( ) -> dict: """Create standard response""" # Calculate token usage - prompt_tokens = sum(estimate_tokens(_text_from_message(msg)) for msg in messages) + prompt_tokens = sum(estimate_tokens(text_from_message(msg)) for msg in messages) tool_args = "".join(call.get("function", {}).get("arguments", "") for call in tool_calls or []) completion_tokens = estimate_tokens(model_output + tool_args) total_tokens = prompt_tokens + completion_tokens @@ -1534,70 +1344,6 @@ def _create_standard_response( return result -def _extract_image_dimensions(data: bytes) -> tuple[int | None, int | None]: - """Return image dimensions (width, height) if PNG or JPEG headers are present.""" - # PNG: dimensions stored in bytes 16..24 of the IHDR chunk - if len(data) >= 24 and data.startswith(b"\x89PNG\r\n\x1a\n"): - try: - width, height = struct.unpack(">II", data[16:24]) - return int(width), int(height) - except struct.error: - return None, None - - # JPEG: dimensions stored in SOF segment; iterate through markers to locate it - if len(data) >= 4 and data[0:2] == b"\xff\xd8": - idx = 2 - length = len(data) - sof_markers = { - 0xC0, - 0xC1, - 0xC2, - 0xC3, - 0xC5, - 0xC6, - 0xC7, - 0xC9, - 0xCA, - 0xCB, - 0xCD, - 0xCE, - 0xCF, - } - while idx < length: - # Find marker alignment (markers are prefixed with 0xFF bytes) - if data[idx] != 0xFF: - idx += 1 - continue - while idx < length and data[idx] == 0xFF: - idx += 1 - if idx >= length: - break - marker = data[idx] - idx += 1 - - if marker in (0xD8, 0xD9, 0x01) or 0xD0 <= marker <= 0xD7: - continue - - if idx + 1 >= length: - break - segment_length = (data[idx] << 8) + data[idx + 1] - idx += 2 - if segment_length < 2: - break - - if marker in sof_markers: - if idx + 4 < length: - # Skip precision byte at idx, then read height/width (big-endian) - height = (data[idx + 1] << 8) + data[idx + 2] - width = (data[idx + 3] << 8) + data[idx + 4] - return int(width), int(height) - break - - idx += segment_length - 2 - - return None, None - - async def _image_to_base64(image: Image, temp_dir: Path) -> tuple[str, int | None, int | None, str]: """Persist an image provided by gemini_webapi and return base64 plus dimensions and filename.""" if isinstance(image, GeneratedImage): @@ -1619,6 +1365,6 @@ async def _image_to_base64(image: Image, temp_dir: Path) -> tuple[str, int | Non original_path.rename(new_path) data = new_path.read_bytes() - width, height = _extract_image_dimensions(data) + width, height = extract_image_dimensions(data) filename = random_name return base64.b64encode(data).decode("ascii"), width, height, filename diff --git a/app/services/client.py b/app/services/client.py index 09c52c1..87c0ca7 100644 --- a/app/services/client.py +++ b/app/services/client.py @@ -9,18 +9,12 @@ from ..models import Message from ..utils import g_config -from ..utils.helper import add_tag, save_file_to_tempfile, save_url_to_tempfile - -XML_WRAP_HINT = ( - "\nYou MUST wrap every tool call response inside a single fenced block exactly like:\n" - '```xml\n{"arg": "value"}\n```\n' - "Do not surround the fence with any other text or whitespace; otherwise the call will be ignored.\n" -) -CODE_BLOCK_HINT = ( - "\nWhenever you include code, markup, or shell snippets, wrap each snippet in a Markdown fenced " - "block and supply the correct language label (for example, ```python ... ``` or ```html ... ```).\n" - "Fence ONLY the actual code/markup; keep all narrative or explanatory text outside the fences.\n" +from ..utils.helper import ( + add_tag, + save_file_to_tempfile, + save_url_to_tempfile, ) + HTML_ESCAPE_RE = re.compile(r"&(?:lt|gt|amp|quot|apos|#[0-9]+|#x[0-9a-fA-F]+);") MARKDOWN_ESCAPE_RE = re.compile(r"\\(?=[-\\`*_{}\[\]()#+.!<>])") CODE_FENCE_RE = re.compile(r"(```.*?```|`[^`\n]+?`)", re.DOTALL) diff --git a/app/utils/helper.py b/app/utils/helper.py index 89fc31e..2627faa 100644 --- a/app/utils/helper.py +++ b/app/utils/helper.py @@ -1,17 +1,41 @@ import base64 +import json 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 from loguru import logger -VALID_TAG_ROLES = {"user", "assistant", "system", "tool"} +from ..models import FunctionCall, Message, ToolCall HEADERS = { "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36" } +VALID_TAG_ROLES = {"user", "assistant", "system", "tool"} +XML_WRAP_HINT = ( + "\nYou MUST wrap every tool call response inside a single fenced block exactly like:\n" + '```xml\n{"arg": "value"}\n```\n' + "Do not surround the fence with any other text or whitespace; otherwise the call will be ignored.\n" +) +CODE_BLOCK_HINT = ( + "\nWhenever you include code, markup, or shell snippets, wrap each snippet in a Markdown fenced " + "block and supply the correct language label (for example, ```python ... ``` or ```html ... ```).\n" + "Fence ONLY the actual code/markup; keep all narrative or explanatory text outside the fences.\n" +) +TOOL_BLOCK_RE = re.compile(r"```xml\s*(.*?)\s*```", re.DOTALL | re.IGNORECASE) +TOOL_CALL_RE = re.compile( + r"(.*?)", re.DOTALL | re.IGNORECASE +) +JSON_FENCE_RE = re.compile(r"^```(?:json)?\s*(.*?)\s*```$", re.DOTALL | re.IGNORECASE) +CONTROL_TOKEN_RE = re.compile(r"<\|im_(?:start|end)\|>") +XML_HINT_STRIPPED = XML_WRAP_HINT.strip() +CODE_HINT_STRIPPED = CODE_BLOCK_HINT.strip() def add_tag(role: str, content: str, unclose: bool = False) -> str: @@ -78,3 +102,289 @@ async def save_url_to_tempfile(url: str, tempdir: Path | None = None) -> Path: path = Path(tmp.name) return path + + +def strip_code_fence(text: str) -> str: + """Remove surrounding ```json fences if present.""" + match = JSON_FENCE_RE.match(text.strip()) + if match: + return match.group(1).strip() + return text.strip() + + +def strip_tagged_blocks(text: str) -> str: + """Remove <|im_start|>role ... <|im_end|> sections, dropping tool blocks entirely. + - tool blocks are removed entirely (if missing end marker, drop to EOF). + - other roles: remove markers and role, keep inner content (if missing end marker, keep to EOF). + """ + if not text: + return text + + result: list[str] = [] + idx = 0 + length = len(text) + start_marker = "<|im_start|>" + end_marker = "<|im_end|>" + + while idx < length: + start = text.find(start_marker, idx) + if start == -1: + result.append(text[idx:]) + break + + # append any content before this block + result.append(text[idx:start]) + + role_start = start + len(start_marker) + newline = text.find("\n", role_start) + if newline == -1: + # malformed block; keep remainder as-is (safe behavior) + result.append(text[start:]) + break + + role = text[role_start:newline].strip().lower() + + end = text.find(end_marker, newline + 1) + if end == -1: + # missing end marker + if role == "tool": + # drop from start marker to EOF (skip remainder) + break + else: + # keep inner content from after the role newline to EOF + result.append(text[newline + 1 :]) + break + + block_end = end + len(end_marker) + + if role == "tool": + # drop whole block + idx = block_end + continue + + # keep the content without role markers + content = text[newline + 1 : end] + result.append(content) + idx = block_end + + return "".join(result) + + +def strip_system_hints(text: str) -> str: + """Remove system-level hint text from a given string.""" + if not text: + return text + cleaned = strip_tagged_blocks(text) + cleaned = cleaned.replace(XML_WRAP_HINT, "").replace(XML_HINT_STRIPPED, "") + cleaned = cleaned.replace(CODE_BLOCK_HINT, "").replace(CODE_HINT_STRIPPED, "") + cleaned = CONTROL_TOKEN_RE.sub("", cleaned) + return cleaned.strip() + + +def remove_tool_call_blocks(text: str) -> str: + """Strip tool call code blocks from text.""" + if not text: + return text + + # 1. Remove fenced blocks ONLY if they contain tool calls + def _replace_block(match: re.Match[str]) -> str: + block_content = match.group(1) + if not block_content: + return match.group(0) + + # Check if the block contains any tool call tag + if TOOL_CALL_RE.search(block_content): + return "" + + # Preserve the block if no tool call found + return match.group(0) + + cleaned = TOOL_BLOCK_RE.sub(_replace_block, text) + + # 2. Remove orphaned tool calls + cleaned = TOOL_CALL_RE.sub("", cleaned) + + return strip_system_hints(cleaned) + + +def extract_tool_calls(text: str) -> tuple[str, list[ToolCall]]: + """Extract tool call definitions and return cleaned text.""" + if not text: + return text, [] + + tool_calls: list[ToolCall] = [] + + def _create_tool_call(name: str, raw_args: str) -> None: + """Helper to parse args and append to tool_calls list.""" + if not name: + logger.warning("Encountered tool_call without a function name.") + return + + arguments = raw_args + try: + parsed_args = json.loads(raw_args) + arguments = json.dumps(parsed_args, ensure_ascii=False) + except json.JSONDecodeError: + logger.warning(f"Failed to parse tool call arguments for '{name}'. Passing raw string.") + + tool_calls.append( + ToolCall( + id=f"call_{uuid.uuid4().hex}", + type="function", + function=FunctionCall(name=name, arguments=arguments), + ) + ) + + def _replace_block(match: re.Match[str]) -> str: + block_content = match.group(1) + if not block_content: + return match.group(0) + + found_in_block = False + for call_match in TOOL_CALL_RE.finditer(block_content): + found_in_block = True + name = (call_match.group(1) or "").strip() + raw_args = (call_match.group(2) or "").strip() + _create_tool_call(name, raw_args) + + if found_in_block: + return "" + else: + return match.group(0) + + cleaned = TOOL_BLOCK_RE.sub(_replace_block, text) + + def _replace_orphan(match: re.Match[str]) -> str: + name = (match.group(1) or "").strip() + raw_args = (match.group(2) or "").strip() + _create_tool_call(name, raw_args) + return "" + + cleaned = TOOL_CALL_RE.sub(_replace_orphan, cleaned) + + cleaned = strip_system_hints(cleaned) + return cleaned, tool_calls + + +def iter_stream_segments(model_output: str, chunk_size: int = 64) -> Iterator[str]: + """Yield stream segments while keeping markers and words intact.""" + if not model_output: + return + + token_pattern = re.compile(r"\s+|\S+\s*") + pending = "" + + def _flush_pending() -> Iterator[str]: + nonlocal pending + if pending: + yield pending + pending = "" + + # Split on boundaries so the markers are never fragmented. + parts = re.split(r"()", model_output) + for part in parts: + if not part: + continue + if part in {"", ""}: + yield from _flush_pending() + yield part + continue + + for match in token_pattern.finditer(part): + token = match.group(0) + + if len(token) > chunk_size: + yield from _flush_pending() + for idx in range(0, len(token), chunk_size): + yield token[idx : idx + chunk_size] + continue + + if pending and len(pending) + len(token) > chunk_size: + yield from _flush_pending() + + pending += token + + yield from _flush_pending() + + +def text_from_message(message: Message) -> str: + """Return text content from a message for token estimation.""" + base_text = "" + if isinstance(message.content, str): + base_text = message.content + elif isinstance(message.content, list): + base_text = "\n".join( + item.text or "" for item in message.content if getattr(item, "type", "") == "text" + ) + elif message.content is None: + base_text = "" + + if message.tool_calls: + tool_arg_text = "".join(call.function.arguments or "" for call in message.tool_calls) + base_text = f"{base_text}\n{tool_arg_text}" if base_text else tool_arg_text + + return base_text + + +def extract_image_dimensions(data: bytes) -> tuple[int | None, int | None]: + """Return image dimensions (width, height) if PNG or JPEG headers are present.""" + # PNG: dimensions stored in bytes 16..24 of the IHDR chunk + if len(data) >= 24 and data.startswith(b"\x89PNG\r\n\x1a\n"): + try: + width, height = struct.unpack(">II", data[16:24]) + return int(width), int(height) + except struct.error: + return None, None + + # JPEG: dimensions stored in SOF segment; iterate through markers to locate it + if len(data) >= 4 and data[0:2] == b"\xff\xd8": + idx = 2 + length = len(data) + sof_markers = { + 0xC0, + 0xC1, + 0xC2, + 0xC3, + 0xC5, + 0xC6, + 0xC7, + 0xC9, + 0xCA, + 0xCB, + 0xCD, + 0xCE, + 0xCF, + } + while idx < length: + # Find marker alignment (markers are prefixed with 0xFF bytes) + if data[idx] != 0xFF: + idx += 1 + continue + while idx < length and data[idx] == 0xFF: + idx += 1 + if idx >= length: + break + marker = data[idx] + idx += 1 + + if marker in (0xD8, 0xD9, 0x01) or 0xD0 <= marker <= 0xD7: + continue + + if idx + 1 >= length: + break + segment_length = (data[idx] << 8) + data[idx + 1] + idx += 2 + if segment_length < 2: + break + + if marker in sof_markers: + if idx + 4 < length: + # Skip precision byte at idx, then read height/width (big-endian) + height = (data[idx + 1] << 8) + data[idx + 2] + width = (data[idx + 3] << 8) + data[idx + 4] + return int(width), int(height) + break + + idx += segment_length - 2 + + return None, None From a1bc8e289ee797a761eb506dc4d01e486c919aef Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C6=B0u=20Quang=20V=C5=A9?= Date: Wed, 31 Dec 2025 10:01:17 +0700 Subject: [PATCH 030/291] fix: Handle None input in `estimate_tokens` and return 0 for empty text --- app/utils/helper.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/app/utils/helper.py b/app/utils/helper.py index 2627faa..28be240 100644 --- a/app/utils/helper.py +++ b/app/utils/helper.py @@ -47,8 +47,10 @@ def add_tag(role: str, content: str, unclose: bool = False) -> str: return f"<|im_start|>{role}\n{content}" + ("\n<|im_end|>" if not unclose else "") -def estimate_tokens(text: str) -> int: +def estimate_tokens(text: str | None) -> int: """Estimate the number of tokens heuristically based on character count""" + if not text: + return 0 return int(len(text) / 3) From a7e15d96bd2a4f62094bea02be7e86c8d305e59e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C6=B0u=20Quang=20V=C5=A9?= Date: Wed, 31 Dec 2025 13:32:49 +0700 Subject: [PATCH 031/291] refactor: Simplify model configuration and add JSON parsing validators - Replaced unused model placeholder in `config.yaml` with an empty list. - Added JSON parsing validators for `model_header` and `models` to enhance flexibility and error handling. - Improved validation to filter out incomplete model configurations. --- app/utils/config.py | 24 +++++++++++++++++++++++- config/config.yaml | 5 +---- 2 files changed, 24 insertions(+), 5 deletions(-) diff --git a/app/utils/config.py b/app/utils/config.py index 5782c66..69a4fac 100644 --- a/app/utils/config.py +++ b/app/utils/config.py @@ -1,3 +1,4 @@ +import json import os import sys from typing import Any, Literal, Optional @@ -58,6 +59,17 @@ class GeminiModelConfig(BaseModel): default=None, description="Header for the model" ) + @field_validator("model_header", mode="before") + @classmethod + 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 the original value to let Pydantic handle the error or type mismatch + return v + return v + class GeminiConfig(BaseModel): """Gemini API configuration""" @@ -82,11 +94,21 @@ class GeminiConfig(BaseModel): description="Maximum characters Gemini Web can accept per request", ) + @field_validator("models", mode="before") + @classmethod + 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: + logger.warning(f"Failed to parse models JSON string: {e}") + return v + return v + @field_validator("models") @classmethod def _filter_valid_models(cls, v: list[GeminiModelConfig]) -> list[GeminiModelConfig]: """Filter out models that don't have a name set (placeholders).""" - return [model for model in v if model.model_name] diff --git a/config/config.yaml b/config/config.yaml index 2fbc061..f2b17fb 100644 --- a/config/config.yaml +++ b/config/config.yaml @@ -28,10 +28,7 @@ gemini: verbose: false # Enable verbose logging for Gemini requests max_chars_per_request: 1000000 # Maximum characters Gemini Web accepts per request. Non-pro users might have a lower limit model_strategy: "append" # Strategy: 'append' (default + custom) or 'overwrite' (custom only) - models: - - model_name: null - model_header: - x-goog-ext-xxxxxxxxx-jspb: null + models: [] storage: path: "data/lmdb" # Database storage path From 61c5f3b7af4ef6b78d5dc7e3d5ba9e6009b7d3cd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C6=B0u=20Quang=20V=C5=A9?= Date: Wed, 31 Dec 2025 13:46:58 +0700 Subject: [PATCH 032/291] refactor: Simplify Gemini model environment variable parsing with JSON support - Replaced prefix-based parsing with a root key approach. - Added JSON parsing to handle list-based model configurations. - Improved handling of errors and cleanup of environment variables. --- app/utils/config.py | 44 +++++++++++++++++--------------------------- 1 file changed, 17 insertions(+), 27 deletions(-) diff --git a/app/utils/config.py b/app/utils/config.py index 69a4fac..6cb5664 100644 --- a/app/utils/config.py +++ b/app/utils/config.py @@ -256,36 +256,26 @@ def _merge_clients_with_env( def extract_gemini_models_env() -> dict[int, dict[str, Any]]: """Extract and remove all Gemini models related environment variables, supporting nested fields.""" - prefix = "CONFIG_GEMINI__MODELS__" - env_overrides: dict[int, dict[str, Any]] = {} - to_delete = [] - for k, v in os.environ.items(): - if k.startswith(prefix): - parts = k.split("__") - if len(parts) < 4: - continue - index_str = parts[2] - if not index_str.isdigit(): - continue - idx = int(index_str) + import json - # Navigate to the correct nested dict - current = env_overrides.setdefault(idx, {}) - for i in range(3, len(parts) - 1): - field_name = parts[i].lower() - current = current.setdefault(field_name, {}) + root_key = "CONFIG_GEMINI__MODELS" + env_overrides: dict[int, dict[str, Any]] = {} - # Set the value (lowercase root field names, preserve sub-key casing) - last_part = parts[-1] - if len(parts) == 4: - current[last_part.lower()] = v - else: - current[last_part] = v + if root_key in os.environ: + try: + val = os.environ[root_key] + if val.strip().startswith("["): + models_list = json.loads(val) + if isinstance(models_list, list): + for idx, model_data in enumerate(models_list): + if isinstance(model_data, dict): + env_overrides[idx] = model_data + + # Remove the environment variable to avoid Pydantic parsing errors + del os.environ[root_key] + except Exception as e: + logger.warning(f"Failed to parse {root_key} as JSON: {e}") - to_delete.append(k) - # Remove these environment variables to avoid Pydantic parsing errors - for k in to_delete: - del os.environ[k] return env_overrides From efd056c270db5130c59b4e66c2543be7f5e8c6e5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C6=B0u=20Quang=20V=C5=A9?= Date: Wed, 31 Dec 2025 14:09:41 +0700 Subject: [PATCH 033/291] fix: Enhance Gemini model environment variable parsing with fallback to Python literals - Added `ast.literal_eval` as a fallback for parsing environment variables when JSON decoding fails. - Improved error handling and logging for invalid configurations. - Ensured proper cleanup of environment variables post-parsing. --- app/utils/config.py | 31 +++++++++++++++++++------------ 1 file changed, 19 insertions(+), 12 deletions(-) diff --git a/app/utils/config.py b/app/utils/config.py index 6cb5664..74a5294 100644 --- a/app/utils/config.py +++ b/app/utils/config.py @@ -1,3 +1,4 @@ +import ast import json import os import sys @@ -256,25 +257,31 @@ def _merge_clients_with_env( def extract_gemini_models_env() -> dict[int, dict[str, Any]]: """Extract and remove all Gemini models related environment variables, supporting nested fields.""" - import json - root_key = "CONFIG_GEMINI__MODELS" env_overrides: dict[int, dict[str, Any]] = {} if root_key in os.environ: + val = os.environ[root_key] + models_list = None + parsed_successfully = False + try: - val = os.environ[root_key] - if val.strip().startswith("["): - models_list = json.loads(val) - if isinstance(models_list, list): - for idx, model_data in enumerate(models_list): - if isinstance(model_data, dict): - env_overrides[idx] = model_data + models_list = json.loads(val) + parsed_successfully = True + except json.JSONDecodeError: + try: + models_list = ast.literal_eval(val) + parsed_successfully = True + except (ValueError, SyntaxError) as e: + logger.warning(f"Failed to parse {root_key} as JSON or Python literal: {e}") + + if parsed_successfully and isinstance(models_list, list): + for idx, model_data in enumerate(models_list): + if isinstance(model_data, dict): + env_overrides[idx] = model_data # Remove the environment variable to avoid Pydantic parsing errors del os.environ[root_key] - except Exception as e: - logger.warning(f"Failed to parse {root_key} as JSON: {e}") return env_overrides @@ -298,7 +305,7 @@ def _merge_models_with_env( model_dict.update(overrides) result_models[idx] = GeminiModelConfig(**model_dict) elif idx == len(result_models): - # Append new model + # Append new models new_model = GeminiModelConfig(**overrides) result_models.append(new_model) else: From 476b9dd228aa99501638987d1f44fe3c5eb23067 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C6=B0u=20Quang=20V=C5=A9?= Date: Wed, 31 Dec 2025 17:53:38 +0700 Subject: [PATCH 034/291] fix: Improve regex patterns in helper module - Adjusted `TOOL_CALL_RE` regex pattern for better accuracy. --- app/utils/helper.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/app/utils/helper.py b/app/utils/helper.py index 28be240..99e6d7a 100644 --- a/app/utils/helper.py +++ b/app/utils/helper.py @@ -30,7 +30,7 @@ ) TOOL_BLOCK_RE = re.compile(r"```xml\s*(.*?)\s*```", re.DOTALL | re.IGNORECASE) TOOL_CALL_RE = re.compile( - r"(.*?)", re.DOTALL | re.IGNORECASE + r"(.*?)", re.DOTALL | re.IGNORECASE ) JSON_FENCE_RE = re.compile(r"^```(?:json)?\s*(.*?)\s*```$", re.DOTALL | re.IGNORECASE) CONTROL_TOKEN_RE = re.compile(r"<\|im_(?:start|end)\|>") @@ -140,7 +140,7 @@ def strip_tagged_blocks(text: str) -> str: role_start = start + len(start_marker) newline = text.find("\n", role_start) if newline == -1: - # malformed block; keep remainder as-is (safe behavior) + # malformed block; keep the remainder as-is (safe behavior) result.append(text[start:]) break @@ -150,7 +150,7 @@ def strip_tagged_blocks(text: str) -> str: if end == -1: # missing end marker if role == "tool": - # drop from start marker to EOF (skip remainder) + # drop from the start marker to EOF (skip the remainder) break else: # keep inner content from after the role newline to EOF @@ -160,7 +160,7 @@ def strip_tagged_blocks(text: str) -> str: block_end = end + len(end_marker) if role == "tool": - # drop whole block + # drop the whole block idx = block_end continue @@ -217,7 +217,7 @@ def extract_tool_calls(text: str) -> tuple[str, list[ToolCall]]: tool_calls: list[ToolCall] = [] def _create_tool_call(name: str, raw_args: str) -> None: - """Helper to parse args and append to tool_calls list.""" + """Helper to parse args and append to the tool_calls list.""" if not name: logger.warning("Encountered tool_call without a function name.") return From 35c1e99993d11033ae9047e85f645ce5def7f09b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C6=B0u=20Quang=20V=C5=A9?= Date: Tue, 13 Jan 2026 09:02:10 +0700 Subject: [PATCH 035/291] docs: Update README files to include custom model configuration and environment variable setup --- README.md | 47 ++++++++++++++++++++++++++++++++++++++++++++++- README.zh.md | 51 ++++++++++++++++++++++++++++++++++++++++++++++++--- 2 files changed, 94 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 2df3a73..5d6de40 100644 --- a/README.md +++ b/README.md @@ -118,7 +118,7 @@ services: - CONFIG_GEMINI__CLIENTS__0__SECURE_1PSID=${SECURE_1PSID} - CONFIG_GEMINI__CLIENTS__0__SECURE_1PSIDTS=${SECURE_1PSIDTS} - GEMINI_COOKIE_PATH=/app/cache # must match the cache volume mount above - restart: on-failure:3 # Avoid retrying too many times + restart: on-failure:3 # Avoid retrying too many times ``` Then run: @@ -187,6 +187,51 @@ To use Gemini-FastAPI, you need to extract your Gemini session cookies: Each client entry can be configured with a different proxy to work around rate limits. Omit the `proxy` field or set it to `null` or an empty string to keep a direct connection. +### Custom Models + +You can define custom models in `config/config.yaml` or via environment variables. + +#### YAML Configuration + +```yaml +gemini: + model_strategy: "append" # "append" (default + custom) or "overwrite" (custom only) + models: + - model_name: "gemini-3.0-pro" + model_header: + x-goog-ext-525001261-jspb: '[1,null,null,null,"9d8ca3786ebdfbea",null,null,0,[4],null,null,1]' +``` + +#### Environment Variables + +You can supply models as a JSON string or list structure via `CONFIG_GEMINI__MODELS`. + +##### Bash + +```bash +export CONFIG_GEMINI__MODEL_STRATEGY="overwrite" +export CONFIG_GEMINI__MODELS='[{"model_name": "gemini-3.0-pro", "model_header": {"x-goog-ext-525001261-jspb": "[1,null,null,null,\"9d8ca3786ebdfbea\",null,null,0,[4],null,null,1]"}}]' +``` + +##### Docker Compose + +```yaml +services: + gemini-fastapi: + environment: + - CONFIG_GEMINI__MODEL_STRATEGY=overwrite + - CONFIG_GEMINI__MODELS=[{"model_name": "gemini-3.0-pro", "model_header": {"x-goog-ext-525001261-jspb": "[1,null,null,null,\"9d8ca3786ebdfbea\",null,null,0,[4],null,null,1]"}}] +``` + +##### Docker CLI + +```bash +docker run -d \ + -e CONFIG_GEMINI__MODEL_STRATEGY="overwrite" \ + -e CONFIG_GEMINI__MODELS='[{"model_name": "gemini-3.0-pro", "model_header": {"x-goog-ext-525001261-jspb": "[1,null,null,null,\"9d8ca3786ebdfbea\",null,null,0,[4],null,null,1]"}}]' \ + ghcr.io/nativu5/gemini-fastapi +``` + ## Acknowledgments - [HanaokaYuzu/Gemini-API](https://github.com/HanaokaYuzu/Gemini-API) - The underlying Gemini web API client diff --git a/README.zh.md b/README.zh.md index 6b7dd74..791afd8 100644 --- a/README.zh.md +++ b/README.zh.md @@ -4,7 +4,6 @@ [![FastAPI](https://img.shields.io/badge/FastAPI-0.115+-green.svg)](https://fastapi.tiangolo.com/) [![License](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE) - [ [English](README.md) | 中文 ] 将 Gemini 网页端模型封装为兼容 OpenAI API 的 API Server。基于 [HanaokaYuzu/Gemini-API](https://github.com/HanaokaYuzu/Gemini-API) 实现。 @@ -50,6 +49,7 @@ pip install -e . ### 配置 编辑 `config/config.yaml` 并提供至少一组凭证: + ```yaml gemini: clients: @@ -118,7 +118,7 @@ services: - CONFIG_GEMINI__CLIENTS__0__SECURE_1PSID=${SECURE_1PSID} - CONFIG_GEMINI__CLIENTS__0__SECURE_1PSIDTS=${SECURE_1PSIDTS} - GEMINI_COOKIE_PATH=/app/cache # must match the cache volume mount above - restart: on-failure:3 # Avoid retrying too many times + restart: on-failure:3 # Avoid retrying too many times ``` 然后运行: @@ -186,6 +186,51 @@ export CONFIG_STORAGE__MAX_SIZE=268435456 # 256 MB 每个客户端条目可以配置不同的代理,从而规避速率限制。省略 `proxy` 字段或将其设置为 `null` 或空字符串以保持直连。 +### 自定义模型 + +你可以在 `config/config.yaml` 中或通过环境变量定义自定义模型。 + +#### YAML 配置 + +```yaml +gemini: + model_strategy: "append" # "append" (默认 + 自定义) 或 "overwrite" (仅限自定义) + models: + - model_name: "gemini-3.0-pro" + model_header: + x-goog-ext-525001261-jspb: '[1,null,null,null,"9d8ca3786ebdfbea",null,null,0,[4],null,null,1]' +``` + +#### 环境变量 + +你可以通过 `CONFIG_GEMINI__MODELS` 以 JSON 字符串或列表结构的形式提供模型。 + +##### Bash + +```bash +export CONFIG_GEMINI__MODEL_STRATEGY="overwrite" +export CONFIG_GEMINI__MODELS='[{"model_name": "gemini-3.0-pro", "model_header": {"x-goog-ext-525001261-jspb": "[1,null,null,null,\"9d8ca3786ebdfbea\",null,null,0,[4],null,null,1]"}}]' +``` + +##### Docker Compose + +```yaml +services: + gemini-fastapi: + environment: + - CONFIG_GEMINI__MODEL_STRATEGY=overwrite + - CONFIG_GEMINI__MODELS=[{"model_name": "gemini-3.0-pro", "model_header": {"x-goog-ext-525001261-jspb": "[1,null,null,null,\"9d8ca3786ebdfbea\",null,null,0,[4],null,null,1]"}}] +``` + +##### Docker CLI + +```bash +docker run -d \ + -e CONFIG_GEMINI__MODEL_STRATEGY="overwrite" \ + -e CONFIG_GEMINI__MODELS='[{"model_name": "gemini-3.0-pro", "model_header": {"x-goog-ext-525001261-jspb": "[1,null,null,null,\"9d8ca3786ebdfbea\",null,null,0,[4],null,null,1]"}}]' \ + ghcr.io/nativu5/gemini-fastapi +``` + ## 鸣谢 - [HanaokaYuzu/Gemini-API](https://github.com/HanaokaYuzu/Gemini-API) - 底层 Gemini Web API 客户端 @@ -193,4 +238,4 @@ export CONFIG_STORAGE__MAX_SIZE=268435456 # 256 MB ## 免责声明 -本项目与 Google 或 OpenAI 无关,仅供学习和研究使用。本项目使用了逆向工程 API,可能不符合 Google 服务条款。使用风险自负。 \ No newline at end of file +本项目与 Google 或 OpenAI 无关,仅供学习和研究使用。本项目使用了逆向工程 API,可能不符合 Google 服务条款。使用风险自负。 From 9b8162133e86a323400e7e2fb36ed651b31c795f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C6=B0u=20Quang=20V=C5=A9?= Date: Tue, 13 Jan 2026 09:23:28 +0700 Subject: [PATCH 036/291] fix: Remove unused headers from HTTP client in helper module --- app/utils/helper.py | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/app/utils/helper.py b/app/utils/helper.py index 99e6d7a..51a6ccf 100644 --- a/app/utils/helper.py +++ b/app/utils/helper.py @@ -14,9 +14,6 @@ from ..models import FunctionCall, Message, ToolCall -HEADERS = { - "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36" -} VALID_TAG_ROLES = {"user", "assistant", "system", "tool"} XML_WRAP_HINT = ( "\nYou MUST wrap every tool call response inside a single fenced block exactly like:\n" @@ -82,7 +79,7 @@ async def save_url_to_tempfile(url: str, tempdir: Path | None = None) -> Path: if not suffix: suffix = f".{mime_type.split('/')[1]}" else: - async with httpx.AsyncClient(headers=HEADERS, follow_redirects=True) as client: + async with httpx.AsyncClient(follow_redirects=True) as client: resp = await client.get(url) resp.raise_for_status() data = resp.content From 32a48dcdc98d9e96e791ae6f914e6b3f12804c97 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C6=B0u=20Quang=20V=C5=A9?= Date: Thu, 15 Jan 2026 10:18:58 +0700 Subject: [PATCH 037/291] fix: Update README and README.zh to clarify model configuration via environment variables; enhance error logging in config validation --- README.md | 23 +---------------------- README.zh.md | 23 +---------------------- app/server/chat.py | 6 ++++-- app/utils/config.py | 27 +++++++++++++++++++++++---- 4 files changed, 29 insertions(+), 50 deletions(-) diff --git a/README.md b/README.md index 5d6de40..d7a7214 100644 --- a/README.md +++ b/README.md @@ -204,34 +204,13 @@ gemini: #### Environment Variables -You can supply models as a JSON string or list structure via `CONFIG_GEMINI__MODELS`. - -##### Bash +You can supply models as a JSON string via `CONFIG_GEMINI__MODELS`. This provides a flexible way to override settings via the shell or in automated environments without modifying the configuration file. ```bash export CONFIG_GEMINI__MODEL_STRATEGY="overwrite" export CONFIG_GEMINI__MODELS='[{"model_name": "gemini-3.0-pro", "model_header": {"x-goog-ext-525001261-jspb": "[1,null,null,null,\"9d8ca3786ebdfbea\",null,null,0,[4],null,null,1]"}}]' ``` -##### Docker Compose - -```yaml -services: - gemini-fastapi: - environment: - - CONFIG_GEMINI__MODEL_STRATEGY=overwrite - - CONFIG_GEMINI__MODELS=[{"model_name": "gemini-3.0-pro", "model_header": {"x-goog-ext-525001261-jspb": "[1,null,null,null,\"9d8ca3786ebdfbea\",null,null,0,[4],null,null,1]"}}] -``` - -##### Docker CLI - -```bash -docker run -d \ - -e CONFIG_GEMINI__MODEL_STRATEGY="overwrite" \ - -e CONFIG_GEMINI__MODELS='[{"model_name": "gemini-3.0-pro", "model_header": {"x-goog-ext-525001261-jspb": "[1,null,null,null,\"9d8ca3786ebdfbea\",null,null,0,[4],null,null,1]"}}]' \ - ghcr.io/nativu5/gemini-fastapi -``` - ## Acknowledgments - [HanaokaYuzu/Gemini-API](https://github.com/HanaokaYuzu/Gemini-API) - The underlying Gemini web API client diff --git a/README.zh.md b/README.zh.md index 791afd8..09d80a4 100644 --- a/README.zh.md +++ b/README.zh.md @@ -203,34 +203,13 @@ gemini: #### 环境变量 -你可以通过 `CONFIG_GEMINI__MODELS` 以 JSON 字符串或列表结构的形式提供模型。 - -##### Bash +你可以通过 `CONFIG_GEMINI__MODELS` 以 JSON 字符串的形式提供模型。这为通过 shell 或在自动化环境中覆盖设置提供了一种灵活的方式,无需修改配置文件。 ```bash export CONFIG_GEMINI__MODEL_STRATEGY="overwrite" export CONFIG_GEMINI__MODELS='[{"model_name": "gemini-3.0-pro", "model_header": {"x-goog-ext-525001261-jspb": "[1,null,null,null,\"9d8ca3786ebdfbea\",null,null,0,[4],null,null,1]"}}]' ``` -##### Docker Compose - -```yaml -services: - gemini-fastapi: - environment: - - CONFIG_GEMINI__MODEL_STRATEGY=overwrite - - CONFIG_GEMINI__MODELS=[{"model_name": "gemini-3.0-pro", "model_header": {"x-goog-ext-525001261-jspb": "[1,null,null,null,\"9d8ca3786ebdfbea\",null,null,0,[4],null,null,1]"}}] -``` - -##### Docker CLI - -```bash -docker run -d \ - -e CONFIG_GEMINI__MODEL_STRATEGY="overwrite" \ - -e CONFIG_GEMINI__MODELS='[{"model_name": "gemini-3.0-pro", "model_header": {"x-goog-ext-525001261-jspb": "[1,null,null,null,\"9d8ca3786ebdfbea\",null,null,0,[4],null,null,1]"}}]' \ - ghcr.io/nativu5/gemini-fastapi -``` - ## 鸣谢 - [HanaokaYuzu/Gemini-API](https://github.com/HanaokaYuzu/Gemini-API) - 底层 Gemini Web API 客户端 diff --git a/app/server/chat.py b/app/server/chat.py index 9485f7a..6e517ea 100644 --- a/app/server/chat.py +++ b/app/server/chat.py @@ -924,7 +924,7 @@ async def _build_payload( image_call_items.append( ResponseImageGenerationCall( - id=filename.split(".")[0], + id=filename.rsplit(".", 1)[0], status="completed", result=image_base64, output_format=img_format, @@ -1350,7 +1350,9 @@ async def _image_to_base64(image: Image, temp_dir: Path) -> tuple[str, int | Non try: saved_path = await image.save(path=str(temp_dir), full_size=True) except Exception as e: - logger.warning(f"Failed to download full-size image, retrying with default size: {e}") + logger.warning( + f"Failed to download full-size GeneratedImage, retrying with default size: {e}" + ) saved_path = await image.save(path=str(temp_dir), full_size=False) else: saved_path = await image.save(path=str(temp_dir)) diff --git a/app/utils/config.py b/app/utils/config.py index 74a5294..a9c5d44 100644 --- a/app/utils/config.py +++ b/app/utils/config.py @@ -109,8 +109,21 @@ def _parse_models_json(cls, v: Any) -> Any: @field_validator("models") @classmethod def _filter_valid_models(cls, v: list[GeminiModelConfig]) -> list[GeminiModelConfig]: - """Filter out models that don't have a name set (placeholders).""" - return [model for model in v if model.model_name] + """Filter out models that don't have all required fields set.""" + valid_models = [] + for model in v: + if model.model_name and model.model_header: + valid_models.append(model) + else: + missing = [] + if not model.model_name: + missing.append("model_name") + if not model.model_header: + missing.append("model_header") + logger.warning( + f"Discarding custom model due to missing {', '.join(missing)}: {model}" + ) + return valid_models class CORSConfig(BaseModel): @@ -251,7 +264,10 @@ def _merge_clients_with_env( new_client = GeminiClientSettings(**overrides) result_clients.append(new_client) else: - raise IndexError(f"Client index {idx} in env is out of range.") + raise IndexError( + f"Client index {idx} in env is out of range (current count: {len(result_clients)}). " + "Client indices must be contiguous starting from 0." + ) return result_clients if result_clients else base_clients @@ -309,7 +325,10 @@ def _merge_models_with_env( new_model = GeminiModelConfig(**overrides) result_models.append(new_model) else: - raise IndexError(f"Model index {idx} in env is out of range (must be contiguous).") + raise IndexError( + f"Model index {idx} in env is out of range (current count: {len(result_models)}). " + "Model indices must be contiguous starting from 0." + ) return result_models From 0c00b089d5b33e394abaac6a1d36ae08cede166c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C6=B0u=20Quang=20V=C5=A9?= Date: Thu, 15 Jan 2026 11:24:08 +0700 Subject: [PATCH 038/291] Update README and README.zh to clarify model configuration via JSON string or list structure for enhanced flexibility in automated environments --- README.md | 2 +- README.zh.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index d7a7214..330e9c8 100644 --- a/README.md +++ b/README.md @@ -204,7 +204,7 @@ gemini: #### Environment Variables -You can supply models as a JSON string via `CONFIG_GEMINI__MODELS`. This provides a flexible way to override settings via the shell or in automated environments without modifying the configuration file. +You can supply models as a JSON string or list structure via `CONFIG_GEMINI__MODELS`. This provides a flexible way to override settings via the shell or in automated environments (e.g. Docker) without modifying the configuration file. ```bash export CONFIG_GEMINI__MODEL_STRATEGY="overwrite" diff --git a/README.zh.md b/README.zh.md index 09d80a4..2f9e1b5 100644 --- a/README.zh.md +++ b/README.zh.md @@ -203,7 +203,7 @@ gemini: #### 环境变量 -你可以通过 `CONFIG_GEMINI__MODELS` 以 JSON 字符串的形式提供模型。这为通过 shell 或在自动化环境中覆盖设置提供了一种灵活的方式,无需修改配置文件。 +你可以通过 `CONFIG_GEMINI__MODELS` 以 JSON 字符串或列表结构的形式提供模型。这为通过 shell 或在自动化环境(例如 Docker)中覆盖设置提供了一种灵活的方式,而无需修改配置文件。 ```bash export CONFIG_GEMINI__MODEL_STRATEGY="overwrite" From b599d99f9967188bb8a277fd09951ddf32006f20 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C6=B0u=20Quang=20V=C5=A9?= Date: Fri, 23 Jan 2026 12:14:40 +0700 Subject: [PATCH 039/291] Refactor: compress JSON content to save tokens and streamline sending multiple chunks --- app/server/chat.py | 50 +++++++++++++++++++++++++++++------------- app/services/client.py | 4 ++-- app/utils/helper.py | 2 +- 3 files changed, 38 insertions(+), 18 deletions(-) diff --git a/app/server/chat.py b/app/server/chat.py index 6e517ea..1e7d786 100644 --- a/app/server/chat.py +++ b/app/server/chat.py @@ -1,5 +1,7 @@ +import asyncio import base64 import json +import random import re import uuid from dataclasses import dataclass @@ -95,7 +97,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 = json.dumps(schema, ensure_ascii=False, separators=(",", ":"), sort_keys=True) 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 +137,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 = json.dumps(function.parameters, ensure_ascii=False, separators=(",", ":")) lines.append("Arguments JSON schema:") lines.append(schema_text) else: @@ -635,7 +637,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 = json.dumps(structured_payload, ensure_ascii=False, separators=(",", ":")) visible_output = canonical_output storage_output = canonical_output @@ -875,7 +877,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 = json.dumps(structured_payload, ensure_ascii=False, separators=(",", ":")) assistant_text = canonical_output storage_output = canonical_output logger.debug( @@ -1081,38 +1083,56 @@ async def _send_with_split(session: ChatSession, text: str, files: list[Path | s that Gemini can produce the actual answer. """ 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 + safe_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 + remaining = total - pos + if remaining <= MAX_CHARS_PER_REQUEST: + chunks.append(text[pos:]) + break + + end = pos + safe_chunk_size + slice_candidate = text[pos:end] + # Try to find a safe split point + split_idx = -1 + idx = slice_candidate.rfind("\n") + if idx != -1: + split_idx = idx + + if split_idx != -1: + split_at = pos + split_idx + 1 + else: + split_at = end - # If this is NOT the last chunk, add the continuation hint. - if end < total: - chunk += CONTINUATION_HINT + chunk = text[pos:split_at] + CONTINUATION_HINT chunks.append(chunk) + pos = split_at - # Fire off all but the last chunk, discarding the interim "ok" replies. - for chk in chunks[:-1]: + chunks_size = len(chunks) + for i, chk in enumerate(chunks[:-1]): try: + logger.debug(f"Sending chunk {i + 1}/{chunks_size}...") await session.send_message(chk) + delay = random.uniform(1.0, 3.0) + logger.debug(f"Sleeping for {delay:.2f}s...") + await asyncio.sleep(delay) except Exception as e: logger.exception(f"Error sending chunk to Gemini: {e}") raise - # The last chunk carries the files (if any) and we return its response. try: + logger.debug(f"Sending final chunk {chunks_size}/{chunks_size}...") return await session.send_message(chunks[-1], files=files) except Exception as e: logger.exception(f"Error sending final chunk to Gemini: {e}") diff --git a/app/services/client.py b/app/services/client.py index 87c0ca7..1f23271 100644 --- a/app/services/client.py +++ b/app/services/client.py @@ -123,7 +123,7 @@ async def process_message( args_text = call.function.arguments.strip() try: parsed_args = json.loads(args_text) - args_text = json.dumps(parsed_args, ensure_ascii=False) + args_text = json.dumps(parsed_args, ensure_ascii=False, separators=(",", ":")) except (json.JSONDecodeError, TypeError): # Leave args_text as is if it is not valid JSON pass @@ -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/utils/helper.py b/app/utils/helper.py index 51a6ccf..578b666 100644 --- a/app/utils/helper.py +++ b/app/utils/helper.py @@ -222,7 +222,7 @@ 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) + arguments = json.dumps(parsed_args, ensure_ascii=False, separators=(",", ":")) except json.JSONDecodeError: logger.warning(f"Failed to parse tool call arguments for '{name}'. Passing raw string.") From 186b8448d7f088df621b627ca7b28c5a7acaf341 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C6=B0u=20Quang=20V=C5=A9?= Date: Fri, 23 Jan 2026 23:08:32 +0700 Subject: [PATCH 040/291] Refactor: Modify the LMDB store to fix issues where no conversation is found in either the raw or cleaned history. --- app/services/lmdb.py | 46 +++++++++++++++++++++++++++++++++++++------- 1 file changed, 39 insertions(+), 7 deletions(-) diff --git a/app/services/lmdb.py b/app/services/lmdb.py index 8ccb0d4..d671663 100644 --- a/app/services/lmdb.py +++ b/app/services/lmdb.py @@ -9,7 +9,7 @@ import orjson from loguru import logger -from ..models import ConversationInStore, Message +from ..models import ContentItem, ConversationInStore, Message from ..utils import g_config from ..utils.singleton import Singleton @@ -18,6 +18,19 @@ 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") + content = message_dict.get("content") + if isinstance(content, list): + is_pure_text = True + text_parts = [] + for item in content: + if not isinstance(item, dict) or item.get("type") != "text": + is_pure_text = False + break + text_parts.append(item.get("text") or "") + + if is_pure_text: + message_dict["content"] = "".join(text_parts) + message_bytes = orjson.dumps(message_dict, option=orjson.OPT_SORT_KEYS) return hashlib.sha256(message_bytes).hexdigest() @@ -435,12 +448,31 @@ def sanitize_assistant_messages(messages: list[Message]) -> list[Message]: """ 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): + normalized_content = LMDBConversationStore.remove_think_tags(msg.content) + if normalized_content != msg.content: + cleaned_msg = Message( + role=msg.role, content=normalized_content, name=msg.name + ) + cleaned_messages.append(cleaned_msg) + else: + cleaned_messages.append(msg) + elif isinstance(msg.content, list): + new_content = [] + changed = False + for item in msg.content: + if isinstance(item, ContentItem) and item.type == "text" and item.text: + cleaned_text = LMDBConversationStore.remove_think_tags(item.text) + if cleaned_text != item.text: + changed = True + item = item.model_copy(update={"text": cleaned_text}) + new_content.append(item) + + if changed: + cleaned_messages.append(msg.model_copy(update={"content": new_content})) + else: + cleaned_messages.append(msg) else: cleaned_messages.append(msg) else: From 6dd1fecdced932c537f579a3c5dd3db87847d475 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C6=B0u=20Quang=20V=C5=A9?= Date: Sat, 24 Jan 2026 10:03:24 +0700 Subject: [PATCH 041/291] Refactor: Modify the LMDB store to fix issues where no conversation is found. --- app/services/lmdb.py | 28 ++++++++++++++++++++++++++-- 1 file changed, 26 insertions(+), 2 deletions(-) diff --git a/app/services/lmdb.py b/app/services/lmdb.py index d671663..93c7723 100644 --- a/app/services/lmdb.py +++ b/app/services/lmdb.py @@ -18,8 +18,12 @@ 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") + + # Normalize content: empty string -> None content = message_dict.get("content") - if isinstance(content, list): + if content == "": + message_dict["content"] = None + elif isinstance(content, list): is_pure_text = True text_parts = [] for item in content: @@ -29,7 +33,27 @@ def _hash_message(message: Message) -> str: text_parts.append(item.get("text") or "") if is_pure_text: - message_dict["content"] = "".join(text_parts) + text_content = "".join(text_parts) + message_dict["content"] = text_content if text_content else None + + # Normalize tool_calls: empty list -> None, and canonicalize arguments + tool_calls = message_dict.get("tool_calls") + if not tool_calls: + message_dict["tool_calls"] = None + elif isinstance(tool_calls, list): + for tool_call in tool_calls: + if isinstance(tool_call, dict) and "function" in tool_call: + func = tool_call["function"] + args = func.get("arguments") + if isinstance(args, str): + try: + # Parse and re-dump to canonicalize (remove extra whitespace, sort keys) + parsed = orjson.loads(args) + func["arguments"] = orjson.dumps( + parsed, option=orjson.OPT_SORT_KEYS + ).decode("utf-8") + except Exception: + pass message_bytes = orjson.dumps(message_dict, option=orjson.OPT_SORT_KEYS) return hashlib.sha256(message_bytes).hexdigest() From 20ed2456d2324501bbe4ba6392870cd612c9083c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C6=B0u=20Quang=20V=C5=A9?= Date: Sat, 24 Jan 2026 10:46:27 +0700 Subject: [PATCH 042/291] Refactor: Update all functions to use orjson for better performance --- app/main.py | 2 ++ app/server/chat.py | 17 ++++++++--------- app/services/client.py | 8 ++++---- app/utils/config.py | 14 +++++++------- app/utils/helper.py | 8 ++++---- 5 files changed, 25 insertions(+), 24 deletions(-) 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/server/chat.py b/app/server/chat.py index 1e7d786..a9d9dec 100644 --- a/app/server/chat.py +++ b/app/server/chat.py @@ -1,6 +1,5 @@ import asyncio import base64 -import json import random import re import uuid @@ -97,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, separators=(",", ":"), 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.", @@ -137,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, separators=(",", ":")) + schema_text = orjson.dumps(function.parameters).decode("utf-8") lines.append("Arguments JSON schema:") lines.append(schema_text) else: @@ -626,8 +625,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}" @@ -637,7 +636,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, separators=(",", ":")) + canonical_output = orjson.dumps(structured_payload).decode("utf-8") visible_output = canonical_output storage_output = canonical_output @@ -866,8 +865,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}" @@ -877,7 +876,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, separators=(",", ":")) + canonical_output = orjson.dumps(structured_payload).decode("utf-8") assistant_text = canonical_output storage_output = canonical_output logger.debug( diff --git a/app/services/client.py b/app/services/client.py index 1f23271..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, separators=(",", ":")) - 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( 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 578b666..1dc518f 100644 --- a/app/utils/helper.py +++ b/app/utils/helper.py @@ -1,5 +1,4 @@ import base64 -import json import mimetypes import re import struct @@ -10,6 +9,7 @@ from urllib.parse import urlparse import httpx +import orjson from loguru import logger from ..models import FunctionCall, Message, ToolCall @@ -221,9 +221,9 @@ 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, separators=(",", ":")) - except json.JSONDecodeError: + parsed_args = orjson.loads(raw_args) + arguments = orjson.dumps(parsed_args).decode("utf-8") + except orjson.JSONDecodeError: logger.warning(f"Failed to parse tool call arguments for '{name}'. Passing raw string.") tool_calls.append( From f67fe63b3b654d3a28cc5ca0363a4ad894831d84 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C6=B0u=20Quang=20V=C5=A9?= Date: Sat, 24 Jan 2026 10:47:26 +0700 Subject: [PATCH 043/291] Update project dependencies --- pyproject.toml | 21 ++++----- uv.lock | 118 +++++++++++++++++++++++++------------------------ 2 files changed, 71 insertions(+), 68 deletions(-) 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]] From 889f2d257ba15a61339de924fb6a67a6fefe6516 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C6=B0u=20Quang=20V=C5=A9?= Date: Sat, 24 Jan 2026 11:15:41 +0700 Subject: [PATCH 044/291] Fix IDE warnings --- app/services/lmdb.py | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/app/services/lmdb.py b/app/services/lmdb.py index 93c7723..dec148b 100644 --- a/app/services/lmdb.py +++ b/app/services/lmdb.py @@ -52,7 +52,7 @@ def _hash_message(message: Message) -> str: func["arguments"] = orjson.dumps( parsed, option=orjson.OPT_SORT_KEYS ).decode("utf-8") - except Exception: + except orjson.JSONDecodeError: pass message_bytes = orjson.dumps(message_dict, option=orjson.OPT_SORT_KEYS) @@ -175,7 +175,7 @@ def store( value = orjson.dumps(conv.model_dump(mode="json")) try: - with self._get_transaction(write=True) as txn: + with self._get_transaction(self, write=True) as txn: # Store main data txn.put(storage_key.encode("utf-8"), value, overwrite=True) @@ -203,7 +203,7 @@ def get(self, key: str) -> Optional[ConversationInStore]: Conversation or None if not found """ try: - with self._get_transaction(write=False) as txn: + with self._get_transaction(self, write=False) as txn: data = txn.get(key.encode("utf-8"), default=None) if not data: return None @@ -255,7 +255,7 @@ def _find_by_message_list( key = f"{self.HASH_LOOKUP_PREFIX}{message_hash}" try: - with self._get_transaction(write=False) as txn: + with self._get_transaction(self, write=False) as txn: if mapped := txn.get(key.encode("utf-8")): # type: ignore return self.get(mapped.decode("utf-8")) # type: ignore except Exception as e: @@ -279,7 +279,7 @@ def exists(self, key: str) -> bool: bool: True if key exists, False otherwise """ try: - with self._get_transaction(write=False) as txn: + with self._get_transaction(self, write=False) as txn: return txn.get(key.encode("utf-8")) is not None except Exception as e: logger.error(f"Failed to check existence of key {key}: {e}") @@ -296,7 +296,7 @@ def delete(self, key: str) -> Optional[ConversationInStore]: ConversationInStore: The deleted conversation data, or None if not found """ try: - with self._get_transaction(write=True) as txn: + with self._get_transaction(self, write=True) as txn: # Get data first to clean up hash mapping data = txn.get(key.encode("utf-8")) if not data: @@ -333,7 +333,7 @@ def keys(self, prefix: str = "", limit: Optional[int] = None) -> List[str]: """ keys = [] try: - with self._get_transaction(write=False) as txn: + with self._get_transaction(self, write=False) as txn: cursor = txn.cursor() cursor.first() @@ -377,7 +377,7 @@ def cleanup_expired(self, retention_days: Optional[int] = None) -> int: expired_entries: list[tuple[str, ConversationInStore]] = [] try: - with self._get_transaction(write=False) as txn: + with self._get_transaction(self, write=False) as txn: cursor = txn.cursor() for key_bytes, value_bytes in cursor: @@ -407,7 +407,7 @@ def cleanup_expired(self, retention_days: Optional[int] = None) -> int: removed = 0 try: - with self._get_transaction(write=True) as txn: + with self._get_transaction(self, write=True) as txn: for key_str, conv in expired_entries: key_bytes = key_str.encode("utf-8") if not txn.delete(key_bytes): From 66b62020330e690499ef386e81cee52dc0f97cce Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C6=B0u=20Quang=20V=C5=A9?= Date: Sat, 24 Jan 2026 11:26:16 +0700 Subject: [PATCH 045/291] Incorrect IDE warnings --- app/services/lmdb.py | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/app/services/lmdb.py b/app/services/lmdb.py index dec148b..c8e78a9 100644 --- a/app/services/lmdb.py +++ b/app/services/lmdb.py @@ -175,7 +175,7 @@ def store( value = orjson.dumps(conv.model_dump(mode="json")) try: - with self._get_transaction(self, write=True) as txn: + with self._get_transaction(write=True) as txn: # Store main data txn.put(storage_key.encode("utf-8"), value, overwrite=True) @@ -203,7 +203,7 @@ def get(self, key: str) -> Optional[ConversationInStore]: Conversation or None if not found """ try: - with self._get_transaction(self, write=False) as txn: + with self._get_transaction(write=False) as txn: data = txn.get(key.encode("utf-8"), default=None) if not data: return None @@ -255,7 +255,7 @@ def _find_by_message_list( key = f"{self.HASH_LOOKUP_PREFIX}{message_hash}" try: - with self._get_transaction(self, write=False) as txn: + with self._get_transaction(write=False) as txn: if mapped := txn.get(key.encode("utf-8")): # type: ignore return self.get(mapped.decode("utf-8")) # type: ignore except Exception as e: @@ -279,7 +279,7 @@ def exists(self, key: str) -> bool: bool: True if key exists, False otherwise """ try: - with self._get_transaction(self, write=False) as txn: + with self._get_transaction(write=False) as txn: return txn.get(key.encode("utf-8")) is not None except Exception as e: logger.error(f"Failed to check existence of key {key}: {e}") @@ -296,7 +296,7 @@ def delete(self, key: str) -> Optional[ConversationInStore]: ConversationInStore: The deleted conversation data, or None if not found """ try: - with self._get_transaction(self, write=True) as txn: + with self._get_transaction(write=True) as txn: # Get data first to clean up hash mapping data = txn.get(key.encode("utf-8")) if not data: @@ -333,7 +333,7 @@ def keys(self, prefix: str = "", limit: Optional[int] = None) -> List[str]: """ keys = [] try: - with self._get_transaction(self, write=False) as txn: + with self._get_transaction(write=False) as txn: cursor = txn.cursor() cursor.first() @@ -377,7 +377,7 @@ def cleanup_expired(self, retention_days: Optional[int] = None) -> int: expired_entries: list[tuple[str, ConversationInStore]] = [] try: - with self._get_transaction(self, write=False) as txn: + with self._get_transaction(write=False) as txn: cursor = txn.cursor() for key_bytes, value_bytes in cursor: @@ -407,7 +407,7 @@ def cleanup_expired(self, retention_days: Optional[int] = None) -> int: removed = 0 try: - with self._get_transaction(self, write=True) as txn: + with self._get_transaction(write=True) as txn: for key_str, conv in expired_entries: key_bytes = key_str.encode("utf-8") if not txn.delete(key_bytes): From 3297f534f035f869bd7e4a867618b39bc7256f06 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C6=B0u=20Quang=20V=C5=A9?= Date: Sat, 24 Jan 2026 12:05:26 +0700 Subject: [PATCH 046/291] Refactor: Modify the LMDB store to fix issues where no conversation is found. --- app/services/lmdb.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/app/services/lmdb.py b/app/services/lmdb.py index c8e78a9..a55d3a9 100644 --- a/app/services/lmdb.py +++ b/app/services/lmdb.py @@ -476,9 +476,7 @@ def sanitize_assistant_messages(messages: list[Message]) -> list[Message]: if isinstance(msg.content, str): normalized_content = LMDBConversationStore.remove_think_tags(msg.content) if normalized_content != msg.content: - cleaned_msg = Message( - role=msg.role, content=normalized_content, name=msg.name - ) + cleaned_msg = msg.model_copy(update={"content": normalized_content}) cleaned_messages.append(cleaned_msg) else: cleaned_messages.append(msg) From 5399b260595e77d6c1f0a8d24a880c59d165a57b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C6=B0u=20Quang=20V=C5=A9?= Date: Sat, 24 Jan 2026 12:06:52 +0700 Subject: [PATCH 047/291] Refactor: Centralized the mapping of the 'developer' role to 'system' for better Gemini compatibility. --- app/models/models.py | 7 +++++++ app/server/chat.py | 6 +----- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/app/models/models.py b/app/models/models.py index c27e024..63ddb94 100644 --- a/app/models/models.py +++ b/app/models/models.py @@ -29,6 +29,13 @@ class Message(BaseModel): 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 a9d9dec..66a2720 100644 --- a/app/server/chat.py +++ b/app/server/chat.py @@ -319,8 +319,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] = [] @@ -394,8 +392,6 @@ def _instructions_to_messages( continue role = item.role - if role == "developer": - role = "system" content = item.content if isinstance(content, str): @@ -1054,7 +1050,7 @@ async def _find_reusable_session( while search_end >= 2: search_history = messages[:search_end] - # Only try to match if the last stored message would be assistant/system. + # Only try to match if the last stored message would be assistant/system before querying LMDB. if search_history[-1].role in {"assistant", "system"}: try: if conv := db.find(model.model_name, search_history): From de01c7850fa44f4dcbd8f31c47bccaf301861a56 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C6=B0u=20Quang=20V=C5=A9?= Date: Sat, 24 Jan 2026 13:04:31 +0700 Subject: [PATCH 048/291] Refactor: Modify the LMDB store to fix issues where no conversation is found. --- app/models/models.py | 1 + app/services/lmdb.py | 95 +++++++++++++++++++++++++------------------- app/utils/helper.py | 10 +++-- 3 files changed, 63 insertions(+), 43 deletions(-) diff --git a/app/models/models.py b/app/models/models.py index 63ddb94..4072b29 100644 --- a/app/models/models.py +++ b/app/models/models.py @@ -24,6 +24,7 @@ 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 diff --git a/app/services/lmdb.py b/app/services/lmdb.py index a55d3a9..594acf0 100644 --- a/app/services/lmdb.py +++ b/app/services/lmdb.py @@ -15,53 +15,69 @@ 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") - - # Normalize content: empty string -> None - content = message_dict.get("content") - if content == "": - message_dict["content"] = None + """Generate a consistent hash for a single message focusing only on core identity fields.""" + # Pick only fields that define the message in a conversation history + core_data = { + "role": message.role, + "name": message.name, + "tool_call_id": message.tool_call_id, + } + + # 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): + stripped = content.strip() + core_data["content"] = stripped if stripped else None elif isinstance(content, list): - is_pure_text = True text_parts = [] for item in content: - if not isinstance(item, dict) or item.get("type") != "text": - is_pure_text = False + 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 - text_parts.append(item.get("text") or "") - - if is_pure_text: - text_content = "".join(text_parts) - message_dict["content"] = text_content if text_content else None - - # Normalize tool_calls: empty list -> None, and canonicalize arguments - tool_calls = message_dict.get("tool_calls") - if not tool_calls: - message_dict["tool_calls"] = None - elif isinstance(tool_calls, list): - for tool_call in tool_calls: - if isinstance(tool_call, dict) and "function" in tool_call: - func = tool_call["function"] - args = func.get("arguments") - if isinstance(args, str): - try: - # Parse and re-dump to canonicalize (remove extra whitespace, sort keys) - parsed = orjson.loads(args) - func["arguments"] = orjson.dumps( - parsed, option=orjson.OPT_SORT_KEYS - ).decode("utf-8") - except orjson.JSONDecodeError: - pass - - message_bytes = orjson.dumps(message_dict, option=orjson.OPT_SORT_KEYS) + + if text_parts is not None: + text_content = "".join(text_parts).strip() + core_data["content"] = text_content if text_content else None + else: + core_data["content"] = message.model_dump(mode="json")["content"] + + # Normalize tool_calls: canonicalize arguments and sort by name if multiple calls exist + 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( + { + "id": tc.id, # Deterministic IDs ensure this is stable + "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")) @@ -252,7 +268,6 @@ def _find_by_message_list( """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: diff --git a/app/utils/helper.py b/app/utils/helper.py index 1dc518f..239b7f4 100644 --- a/app/utils/helper.py +++ b/app/utils/helper.py @@ -1,9 +1,9 @@ import base64 +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 @@ -222,13 +222,17 @@ def _create_tool_call(name: str, raw_args: str) -> None: arguments = raw_args try: parsed_args = orjson.loads(raw_args) - arguments = orjson.dumps(parsed_args).decode("utf-8") + 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 and arguments to avoid hash mismatch in LMDB + seed = f"{name}:{arguments}".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), ) From 196414755e860f1f6d9c840954eb45c53225a864 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C6=B0u=20Quang=20V=C5=A9?= Date: Sat, 24 Jan 2026 13:26:58 +0700 Subject: [PATCH 049/291] Refactor: Modify the LMDB store to fix issues where no conversation is found. --- app/server/chat.py | 10 +++++++++- app/services/lmdb.py | 7 ++----- app/utils/helper.py | 13 +++++++------ 3 files changed, 18 insertions(+), 12 deletions(-) diff --git a/app/server/chat.py b/app/server/chat.py index 66a2720..7c683cd 100644 --- a/app/server/chat.py +++ b/app/server/chat.py @@ -1047,6 +1047,8 @@ async def _find_reusable_session( # Start with the full history and iteratively trim from the end. search_end = len(messages) + logger.debug(f"Searching for reusable session in history of length {search_end}...") + while search_end >= 2: search_history = messages[:search_end] @@ -1057,14 +1059,20 @@ async def _find_reusable_session( 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 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. search_end -= 1 + logger.debug("No reusable session found after checking all possible prefixes.") return None, None, messages diff --git a/app/services/lmdb.py b/app/services/lmdb.py index 594acf0..5aefa4b 100644 --- a/app/services/lmdb.py +++ b/app/services/lmdb.py @@ -15,12 +15,10 @@ def _hash_message(message: Message) -> str: - """Generate a consistent hash for a single message focusing only on core identity fields.""" - # Pick only fields that define the message in a conversation history + """Generate a consistent hash for a single message focusing ONLY on logic/content, ignoring technical IDs.""" core_data = { "role": message.role, "name": message.name, - "tool_call_id": message.tool_call_id, } # Normalize content: strip, handle empty/None, and list-of-text items @@ -48,7 +46,7 @@ def _hash_message(message: Message) -> str: else: core_data["content"] = message.model_dump(mode="json")["content"] - # Normalize tool_calls: canonicalize arguments and sort by name if multiple calls exist + # Normalize tool_calls: Focus ONLY on function name and arguments if message.tool_calls: calls_data = [] for tc in message.tool_calls: @@ -61,7 +59,6 @@ def _hash_message(message: Message) -> str: calls_data.append( { - "id": tc.id, # Deterministic IDs ensure this is stable "name": tc.function.name, "arguments": canon_args, } diff --git a/app/utils/helper.py b/app/utils/helper.py index 239b7f4..ecf4a47 100644 --- a/app/utils/helper.py +++ b/app/utils/helper.py @@ -213,7 +213,7 @@ def extract_tool_calls(text: str) -> tuple[str, list[ToolCall]]: tool_calls: list[ToolCall] = [] - def _create_tool_call(name: str, raw_args: str) -> None: + def _create_tool_call(name: str, raw_args: str, index: int) -> None: """Helper to parse args and append to the tool_calls list.""" if not name: logger.warning("Encountered tool_call without a function name.") @@ -226,8 +226,8 @@ def _create_tool_call(name: str, raw_args: str) -> None: except orjson.JSONDecodeError: logger.warning(f"Failed to parse tool call arguments for '{name}'. Passing raw string.") - # Generate a deterministic ID based on name and arguments to avoid hash mismatch in LMDB - seed = f"{name}:{arguments}".encode("utf-8") + # Generate a deterministic ID based on name, arguments, and index to avoid collisions + seed = f"{name}:{arguments}:{index}".encode("utf-8") call_id = f"call_{hashlib.sha256(seed).hexdigest()[:24]}" tool_calls.append( @@ -244,11 +244,11 @@ def _replace_block(match: re.Match[str]) -> str: return match.group(0) found_in_block = False - for call_match in TOOL_CALL_RE.finditer(block_content): + for i, call_match in enumerate(TOOL_CALL_RE.finditer(block_content)): found_in_block = True name = (call_match.group(1) or "").strip() raw_args = (call_match.group(2) or "").strip() - _create_tool_call(name, raw_args) + _create_tool_call(name, raw_args, i) if found_in_block: return "" @@ -258,9 +258,10 @@ def _replace_block(match: re.Match[str]) -> str: cleaned = TOOL_BLOCK_RE.sub(_replace_block, text) def _replace_orphan(match: re.Match[str]) -> str: + # Note: orphan calls are handled with a fallback index if they appear outside blocks name = (match.group(1) or "").strip() raw_args = (match.group(2) or "").strip() - _create_tool_call(name, raw_args) + _create_tool_call(name, raw_args, len(tool_calls)) return "" cleaned = TOOL_CALL_RE.sub(_replace_orphan, cleaned) From 8c5c7498230bc680bf50464dacf0b6f001888981 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C6=B0u=20Quang=20V=C5=A9?= Date: Sat, 24 Jan 2026 13:42:09 +0700 Subject: [PATCH 050/291] Refactor: Modify the LMDB store to fix issues where no conversation is found. --- app/server/chat.py | 4 ++-- app/services/lmdb.py | 6 +++++- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/app/server/chat.py b/app/server/chat.py index 7c683cd..0d64b71 100644 --- a/app/server/chat.py +++ b/app/server/chat.py @@ -1052,8 +1052,8 @@ async def _find_reusable_session( while search_end >= 2: search_history = messages[:search_end] - # Only try to match if the last stored message would be assistant/system before querying LMDB. - 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) diff --git a/app/services/lmdb.py b/app/services/lmdb.py index 5aefa4b..c612d9e 100644 --- a/app/services/lmdb.py +++ b/app/services/lmdb.py @@ -70,7 +70,11 @@ def _hash_message(message: Message) -> str: core_data["tool_calls"] = None message_bytes = orjson.dumps(core_data, option=orjson.OPT_SORT_KEYS) - return hashlib.sha256(message_bytes).hexdigest() + msg_hash = hashlib.sha256(message_bytes).hexdigest() + logger.debug( + f"Hashing message (role={message.role}): {message_bytes.decode('utf-8')} -> {msg_hash}" + ) + return msg_hash def _hash_conversation(client_id: str, model: str, messages: List[Message]) -> str: From ce67d664b5443726fe518aee1cc9ef550ae640fb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C6=B0u=20Quang=20V=C5=A9?= Date: Sat, 24 Jan 2026 14:41:55 +0700 Subject: [PATCH 051/291] Refactor: Avoid reusing an existing chat session if its idle time exceeds METADATA_TTL_MINUTES. --- app/server/chat.py | 14 ++++++++++++-- app/services/lmdb.py | 9 ++------- 2 files changed, 14 insertions(+), 9 deletions(-) diff --git a/app/server/chat.py b/app/server/chat.py index 0d64b71..6fbb818 100644 --- a/app/server/chat.py +++ b/app/server/chat.py @@ -58,6 +58,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 = 20 router = APIRouter() @@ -1047,7 +1048,6 @@ async def _find_reusable_session( # Start with the full history and iteratively trim from the end. search_end = len(messages) - logger.debug(f"Searching for reusable session in history of length {search_end}...") while search_end >= 2: search_history = messages[:search_end] @@ -1056,6 +1056,17 @@ async def _find_reusable_session( if search_history[-1].role in {"assistant", "system", "tool"}: try: if conv := db.find(model.model_name, search_history): + # 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: + logger.debug( + f"Matched conversation is too old ({age_minutes:.1f}m), skipping reuse." + ) + break + client = await pool.acquire(conv.client_id) session = client.start_chat(metadata=conv.metadata, model=model) remain = messages[search_end:] @@ -1072,7 +1083,6 @@ async def _find_reusable_session( # Trim one message and try again. search_end -= 1 - logger.debug("No reusable session found after checking all possible prefixes.") return None, None, messages diff --git a/app/services/lmdb.py b/app/services/lmdb.py index c612d9e..424b357 100644 --- a/app/services/lmdb.py +++ b/app/services/lmdb.py @@ -68,13 +68,8 @@ def _hash_message(message: Message) -> str: core_data["tool_calls"] = calls_data else: core_data["tool_calls"] = None - - message_bytes = orjson.dumps(core_data, option=orjson.OPT_SORT_KEYS) - msg_hash = hashlib.sha256(message_bytes).hexdigest() - logger.debug( - f"Hashing message (role={message.role}): {message_bytes.decode('utf-8')} -> {msg_hash}" - ) - return msg_hash + 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: From 3d32d1226b1399f4286aadd95b2c4a52228fac45 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C6=B0u=20Quang=20V=C5=A9?= Date: Sat, 24 Jan 2026 14:58:58 +0700 Subject: [PATCH 052/291] Refactor: Update the LMDB store to resolve issues preventing conversation from being saved --- app/services/lmdb.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/app/services/lmdb.py b/app/services/lmdb.py index 424b357..2dbe7b2 100644 --- a/app/services/lmdb.py +++ b/app/services/lmdb.py @@ -68,15 +68,16 @@ def _hash_message(message: Message) -> str: 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() + + 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 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")) From 2eb9f05142ddfa1cb665b248f3faf2e278b619c1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C6=B0u=20Quang=20V=C5=A9?= Date: Sat, 24 Jan 2026 17:57:04 +0700 Subject: [PATCH 053/291] Refactor: Update the _prepare_messages_for_model helper to omit the system instruction when reusing a session to save tokens. --- app/server/chat.py | 66 +++++++++++++++++++++++++---------------- app/services/lmdb.py | 70 ++++++++++++++++++++++++++++++++++---------- 2 files changed, 96 insertions(+), 40 deletions(-) diff --git a/app/server/chat.py b/app/server/chat.py index 6fbb818..646f4fa 100644 --- a/app/server/chat.py +++ b/app/server/chat.py @@ -58,7 +58,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 = 20 +METADATA_TTL_MINUTES = 15 router = APIRouter() @@ -268,31 +268,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 "" @@ -530,8 +534,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( @@ -642,17 +652,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}") @@ -780,9 +793,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( @@ -994,17 +1008,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}") diff --git a/app/services/lmdb.py b/app/services/lmdb.py index 2dbe7b2..f4c9938 100644 --- a/app/services/lmdb.py +++ b/app/services/lmdb.py @@ -11,6 +11,7 @@ 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 @@ -26,8 +27,9 @@ def _hash_message(message: Message) -> str: if not content: core_data["content"] = None elif isinstance(content, str): - stripped = content.strip() - core_data["content"] = stripped if stripped else None + # 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: @@ -41,7 +43,7 @@ def _hash_message(message: Message) -> str: break if text_parts is not None: - text_content = "".join(text_parts).strip() + text_content = "".join(text_parts).replace("\r\n", "\n").strip() core_data["content"] = text_content if text_content else None else: core_data["content"] = message.model_dump(mode="json")["content"] @@ -260,7 +262,9 @@ 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: @@ -471,40 +475,76 @@ 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": if isinstance(msg.content, str): - normalized_content = LMDBConversationStore.remove_think_tags(msg.content) - if normalized_content != msg.content: - cleaned_msg = msg.model_copy(update={"content": normalized_content}) + 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: - cleaned_text = LMDBConversationStore.remove_think_tags(item.text) - if cleaned_text != 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": cleaned_text}) + 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})) + cleaned_messages.append( + msg.model_copy( + update={ + "content": new_content, + "tool_calls": all_extracted_calls or None, + } + ) + ) else: cleaned_messages.append(msg) else: From ade61d6826af1f256e7141ab6c1815b047cf8744 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C6=B0u=20Quang=20V=C5=A9?= Date: Mon, 26 Jan 2026 11:01:41 +0700 Subject: [PATCH 054/291] Refactor: Modify the logic to convert a large prompt into a temporary text file attachment - When multiple chunks are sent simultaneously, Google will immediately invalidate the access token and reject the request - When a prompt contains a structured format like JSON, splitting it can break the format and may cause the model to misunderstand the context - Another minor tweak as Copilot suggested --- app/server/chat.py | 104 ++++++++++++++++--------------------------- app/services/lmdb.py | 5 ++- app/utils/helper.py | 13 +++--- 3 files changed, 49 insertions(+), 73 deletions(-) diff --git a/app/server/chat.py b/app/server/chat.py index 646f4fa..063d4d4 100644 --- a/app/server/chat.py +++ b/app/server/chat.py @@ -1,7 +1,6 @@ -import asyncio import base64 -import random import re +import tempfile import uuid from dataclasses import dataclass from datetime import datetime, timezone @@ -375,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 @@ -1077,19 +1074,18 @@ async def _find_reusable_session( 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: + 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." ) - break - - 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 except Exception as e: logger.warning( f"Error checking LMDB for reusable session at length {search_end}: {e}" @@ -1103,13 +1099,9 @@ 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: try: @@ -1118,55 +1110,37 @@ async def _send_with_split(session: ChatSession, text: str, files: list[Path | s logger.exception(f"Error sending message to Gemini: {e}") raise - hint_len = len(CONTINUATION_HINT) - safe_chunk_size = MAX_CHARS_PER_REQUEST - hint_len - - chunks: list[str] = [] - pos = 0 - total = len(text) - - while pos < total: - remaining = total - pos - if remaining <= MAX_CHARS_PER_REQUEST: - chunks.append(text[pos:]) - break - - end = pos + safe_chunk_size - slice_candidate = text[pos:end] - # Try to find a safe split point - split_idx = -1 - idx = slice_candidate.rfind("\n") - if idx != -1: - split_idx = idx - - if split_idx != -1: - split_at = pos + split_idx + 1 - else: - split_at = end + logger.info( + f"Message length ({len(text)}) exceeds limit ({MAX_CHARS_PER_REQUEST}). Converting text to file attachment." + ) - chunk = text[pos:split_at] + CONTINUATION_HINT - chunks.append(chunk) - pos = split_at + # 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") - chunks_size = len(chunks) - for i, chk in enumerate(chunks[:-1]): try: - logger.debug(f"Sending chunk {i + 1}/{chunks_size}...") - await session.send_message(chk) - delay = random.uniform(1.0, 3.0) - logger.debug(f"Sleeping for {delay:.2f}s...") - await asyncio.sleep(delay) + # 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 - try: - logger.debug(f"Sending final chunk {chunks_size}/{chunks_size}...") - 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/lmdb.py b/app/services/lmdb.py index f4c9938..c9d42cd 100644 --- a/app/services/lmdb.py +++ b/app/services/lmdb.py @@ -43,8 +43,9 @@ def _hash_message(message: Message) -> str: break if text_parts is not None: - text_content = "".join(text_parts).replace("\r\n", "\n").strip() - core_data["content"] = text_content if text_content else 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"] diff --git a/app/utils/helper.py b/app/utils/helper.py index ecf4a47..190b5ce 100644 --- a/app/utils/helper.py +++ b/app/utils/helper.py @@ -213,7 +213,7 @@ def extract_tool_calls(text: str) -> tuple[str, list[ToolCall]]: tool_calls: list[ToolCall] = [] - def _create_tool_call(name: str, raw_args: str, index: int) -> None: + def _create_tool_call(name: str, raw_args: str) -> None: """Helper to parse args and append to the tool_calls list.""" if not name: logger.warning("Encountered tool_call without a function name.") @@ -226,7 +226,9 @@ def _create_tool_call(name: str, raw_args: str, index: int) -> None: 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 index to avoid collisions + # 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]}" @@ -244,11 +246,11 @@ def _replace_block(match: re.Match[str]) -> str: return match.group(0) found_in_block = False - for i, call_match in enumerate(TOOL_CALL_RE.finditer(block_content)): + for call_match in TOOL_CALL_RE.finditer(block_content): found_in_block = True name = (call_match.group(1) or "").strip() raw_args = (call_match.group(2) or "").strip() - _create_tool_call(name, raw_args, i) + _create_tool_call(name, raw_args) if found_in_block: return "" @@ -258,10 +260,9 @@ def _replace_block(match: re.Match[str]) -> str: cleaned = TOOL_BLOCK_RE.sub(_replace_block, text) def _replace_orphan(match: re.Match[str]) -> str: - # Note: orphan calls are handled with a fallback index if they appear outside blocks name = (match.group(1) or "").strip() raw_args = (match.group(2) or "").strip() - _create_tool_call(name, raw_args, len(tool_calls)) + _create_tool_call(name, raw_args) return "" cleaned = TOOL_CALL_RE.sub(_replace_orphan, cleaned) From bdd893ff9a2d2c58fcbc3eb0c01aab337177edd3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C6=B0u=20Quang=20V=C5=A9?= Date: Wed, 28 Jan 2026 13:37:47 +0700 Subject: [PATCH 055/291] Enable streaming responses and fully resolve the problem with reusable sessions. - Ensure that PR https://github.com/HanaokaYuzu/Gemini-API/pull/220 is merged before proceeding with this PR. --- app/main.py | 2 +- app/models/models.py | 4 +- app/server/chat.py | 1867 ++++++++++++++++++++++------------------ app/services/client.py | 11 +- app/services/lmdb.py | 152 ++-- app/services/pool.py | 4 +- app/utils/helper.py | 113 +-- 7 files changed, 1162 insertions(+), 991 deletions(-) diff --git a/app/main.py b/app/main.py index 307eb36..f4e6711 100644 --- a/app/main.py +++ b/app/main.py @@ -15,7 +15,7 @@ ) from .services import GeminiClientPool, LMDBConversationStore -RETENTION_CLEANUP_INTERVAL_SECONDS = 6 * 60 * 60 # 6 hours +RETENTION_CLEANUP_INTERVAL_SECONDS = 6 * 60 * 60 # Check every 6 hours async def _run_retention_cleanup(stop_event: asyncio.Event) -> None: diff --git a/app/models/models.py b/app/models/models.py index 4072b29..64ceaa9 100644 --- a/app/models/models.py +++ b/app/models/models.py @@ -7,7 +7,7 @@ class ContentItem(BaseModel): - """Content item model""" + """Individual content item (text, image, or file) within a message.""" type: Literal["text", "image_url", "file", "input_audio"] text: Optional[str] = None @@ -159,7 +159,7 @@ class ConversationInStore(BaseModel): created_at: Optional[datetime] = Field(default=None) updated_at: Optional[datetime] = Field(default=None) - # NOTE: Gemini Web API do not support changing models once a conversation is created. + # Gemini Web API does not support changing models once a conversation is created. model: str = Field(..., description="Model used for the conversation") client_id: str = Field(..., description="Identifier of the Gemini client") metadata: list[str | None] = Field( diff --git a/app/server/chat.py b/app/server/chat.py index 063d4d4..37d3c70 100644 --- a/app/server/chat.py +++ b/app/server/chat.py @@ -1,18 +1,18 @@ import base64 -import re -import tempfile +import io +import reprlib import uuid from dataclasses import dataclass from datetime import datetime, timezone from pathlib import Path -from typing import Any +from typing import Any, AsyncGenerator import orjson from fastapi import APIRouter, Depends, HTTPException, Request, status from fastapi.responses import StreamingResponse +from gemini_webapi import ModelOutput from gemini_webapi.client import ChatSession from gemini_webapi.constants import Model -from gemini_webapi.exceptions import APIError from gemini_webapi.types.image import GeneratedImage, Image from loguru import logger @@ -42,21 +42,18 @@ from ..utils.helper import ( CODE_BLOCK_HINT, CODE_HINT_STRIPPED, + CONTROL_TOKEN_RE, XML_HINT_STRIPPED, XML_WRAP_HINT, estimate_tokens, extract_image_dimensions, extract_tool_calls, - iter_stream_segments, - remove_tool_call_blocks, strip_code_fence, text_from_message, ) from .middleware import get_image_store_dir, get_image_token, get_temp_dir, verify_api_key -# 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() @@ -72,6 +69,210 @@ class StructuredOutputRequirement: raw_format: dict[str, Any] +# --- Helper Functions --- + + +async def _image_to_base64(image: Image, temp_dir: Path) -> tuple[str, int | None, int | None, str]: + """Persist an image provided by gemini_webapi and return base64 plus dimensions and filename.""" + if isinstance(image, GeneratedImage): + try: + saved_path = await image.save(path=str(temp_dir), full_size=True) + except Exception as e: + logger.warning( + f"Failed to download full-size GeneratedImage, retrying with default size: {e}" + ) + saved_path = await image.save(path=str(temp_dir), full_size=False) + else: + saved_path = await image.save(path=str(temp_dir)) + + if not saved_path: + raise ValueError("Failed to save generated image") + + original_path = Path(saved_path) + random_name = f"img_{uuid.uuid4().hex}{original_path.suffix}" + new_path = temp_dir / random_name + original_path.rename(new_path) + + data = new_path.read_bytes() + width, height = extract_image_dimensions(data) + filename = random_name + return base64.b64encode(data).decode("ascii"), width, height, filename + + +def _calculate_usage( + messages: list[Message], + assistant_text: str | None, + tool_calls: list[Any] | None, +) -> tuple[int, int, int]: + """Calculate prompt, completion and total tokens consistently.""" + prompt_tokens = sum(estimate_tokens(text_from_message(msg)) for msg in messages) + tool_args_text = "" + if tool_calls: + for call in tool_calls: + if hasattr(call, "function"): + tool_args_text += call.function.arguments or "" + elif isinstance(call, dict): + tool_args_text += call.get("function", {}).get("arguments", "") + + completion_basis = assistant_text or "" + if tool_args_text: + completion_basis = ( + f"{completion_basis}\n{tool_args_text}" if completion_basis else tool_args_text + ) + + completion_tokens = estimate_tokens(completion_basis) + return prompt_tokens, completion_tokens, prompt_tokens + completion_tokens + + +def _create_responses_standard_payload( + response_id: str, + created_time: int, + model_name: str, + assistant_text: str | None, + detected_tool_calls: list[Any] | None, + image_call_items: list[ResponseImageGenerationCall], + response_contents: list[ResponseOutputContent], + usage: ResponseUsage, + request_data: ResponseCreateRequest, + normalized_input: Any, +) -> ResponseCreateResponse: + """Unified factory for building ResponseCreateResponse objects.""" + message_id = f"msg_{uuid.uuid4().hex}" + tool_call_items: list[ResponseToolCall] = [] + if detected_tool_calls: + tool_call_items = [ + ResponseToolCall( + id=call.id if hasattr(call, "id") else call["id"], + status="completed", + function=call.function if hasattr(call, "function") else call["function"], + ) + for call in detected_tool_calls + ] + + return ResponseCreateResponse( + id=response_id, + created_at=created_time, + model=model_name, + output=[ + ResponseOutputMessage( + id=message_id, + type="message", + role="assistant", + content=response_contents, + ), + *tool_call_items, + *image_call_items, + ], + status="completed", + usage=usage, + input=normalized_input or None, + metadata=request_data.metadata or None, + tools=request_data.tools, + tool_choice=request_data.tool_choice, + ) + + +def _create_chat_completion_standard_payload( + completion_id: str, + created_time: int, + model_name: str, + visible_output: str | None, + tool_calls_payload: list[dict] | None, + finish_reason: str, + usage: dict, +) -> dict: + """Unified factory for building Chat Completion response dictionaries.""" + return { + "id": completion_id, + "object": "chat.completion", + "created": created_time, + "model": model_name, + "choices": [ + { + "index": 0, + "message": { + "role": "assistant", + "content": visible_output or None, + "tool_calls": tool_calls_payload or None, + }, + "finish_reason": finish_reason, + } + ], + "usage": usage, + } + + +def _process_llm_output( + raw_output_with_think: str, + raw_output_clean: str, + structured_requirement: StructuredOutputRequirement | None, +) -> tuple[str, str, list[Any]]: + """ + Common post-processing logic for Gemini output. + Returns: (visible_text, storage_output, tool_calls) + """ + visible_with_think, tool_calls = extract_tool_calls(raw_output_with_think) + if tool_calls: + logger.debug(f"Detected {len(tool_calls)} tool call(s) in model output.") + + visible_output = visible_with_think.strip() + + storage_output, _ = extract_tool_calls(raw_output_clean) + storage_output = storage_output.strip() + + if structured_requirement: + cleaned_for_json = LMDBConversationStore.remove_think_tags(visible_output) + json_text = strip_code_fence(cleaned_for_json or "") + if json_text: + try: + structured_payload = orjson.loads(json_text) + canonical_output = orjson.dumps(structured_payload).decode("utf-8") + visible_output = canonical_output + storage_output = canonical_output + logger.debug( + f"Structured response fulfilled (schema={structured_requirement.schema_name})." + ) + except orjson.JSONDecodeError: + logger.warning( + f"Failed to decode JSON for structured response (schema={structured_requirement.schema_name})." + ) + + return visible_output, storage_output, tool_calls + + +def _persist_conversation( + db: LMDBConversationStore, + model_name: str, + client_id: str, + metadata: list[str | None], + messages: list[Message], + storage_output: str | None, + tool_calls: list[Any] | None, +) -> str | None: + """Unified logic to save conversation history to LMDB.""" + try: + current_assistant_message = Message( + role="assistant", + content=storage_output or None, + tool_calls=tool_calls or None, + ) + full_history = [*messages, current_assistant_message] + cleaned_history = db.sanitize_assistant_messages(full_history) + + conv = ConversationInStore( + model=model_name, + client_id=client_id, + metadata=metadata, + messages=cleaned_history, + ) + key = db.store(conv) + logger.debug(f"Conversation saved to LMDB with key: {key[:12]}") + return key + except Exception as e: + logger.warning(f"Failed to save {len(messages) + 1} messages to LMDB: {e}") + return None + + def _build_structured_requirement( response_format: dict[str, Any] | None, ) -> StructuredOutputRequirement | None: @@ -80,17 +281,23 @@ def _build_structured_requirement( return None if response_format.get("type") != "json_schema": - logger.warning(f"Unsupported response_format type requested: {response_format}") + logger.warning( + f"Unsupported response_format type requested: {reprlib.repr(response_format)}" + ) return None json_schema = response_format.get("json_schema") if not isinstance(json_schema, dict): - logger.warning(f"Invalid json_schema payload in response_format: {response_format}") + logger.warning( + f"Invalid json_schema payload in response_format: {reprlib.repr(response_format)}" + ) return None schema = json_schema.get("schema") if not isinstance(schema, dict): - logger.warning(f"Missing `schema` object in response_format payload: {response_format}") + logger.warning( + f"Missing `schema` object in response_format payload: {reprlib.repr(response_format)}" + ) return None schema_name = json_schema.get("name") or "response" @@ -136,7 +343,9 @@ def _build_tool_prompt( description = function.description or "No description provided." lines.append(f"Tool `{function.name}`: {description}") if function.parameters: - schema_text = orjson.dumps(function.parameters).decode("utf-8") + schema_text = orjson.dumps(function.parameters, option=orjson.OPT_SORT_KEYS).decode( + "utf-8" + ) lines.append("Arguments JSON schema:") lines.append(schema_text) else: @@ -155,7 +364,6 @@ def _build_tool_prompt( lines.append( f"You are required to call the tool named `{target}`. Do not call any other tool." ) - # `auto` or None fall back to default instructions. lines.append( "When you decide to call a tool you MUST respond with nothing except a single fenced block exactly like the template below." @@ -221,7 +429,7 @@ def _append_xml_hint_to_last_user_message(messages: list[Message]) -> None: if isinstance(msg.content, str): if XML_HINT_STRIPPED not in msg.content: - msg.content = f"{msg.content}{XML_WRAP_HINT}" + msg.content = f"{msg.content}\n{XML_WRAP_HINT}" return if isinstance(msg.content, list): @@ -231,15 +439,13 @@ def _append_xml_hint_to_last_user_message(messages: list[Message]) -> None: text_value = part.text or "" if XML_HINT_STRIPPED in text_value: return - part.text = f"{text_value}{XML_WRAP_HINT}" + part.text = f"{text_value}\n{XML_WRAP_HINT}" return messages_text = XML_WRAP_HINT.strip() msg.content.append(ContentItem(type="text", text=messages_text)) return - # No user message to annotate; nothing to do. - def _conversation_has_code_hint(messages: list[Message]) -> bool: """Return True if any system message already includes the code block hint.""" @@ -290,7 +496,6 @@ def _prepare_messages_for_model( 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 @@ -323,7 +528,6 @@ def _response_items_to_messages( normalized_input: list[ResponseInputItem] = [] for item in items: role = item.role - content = item.content normalized_contents: list[ResponseInputContent] = [] if isinstance(content, str): @@ -394,7 +598,6 @@ def _instructions_to_messages( continue role = item.role - content = item.content if isinstance(content, str): instruction_messages.append(Message(role=role, content=content)) @@ -432,10 +635,7 @@ def _instructions_to_messages( def _get_model_by_name(name: str) -> Model: - """ - Retrieve a Model instance by name, considering custom models from config - and the update strategy (append or overwrite). - """ + """Retrieve a Model instance by name.""" strategy = g_config.gemini.model_strategy custom_models = {m.model_name: m for m in g_config.gemini.models if m.model_name} @@ -449,9 +649,7 @@ def _get_model_by_name(name: str) -> Model: def _get_available_models() -> list[ModelData]: - """ - Return a list of available models based on configuration strategy. - """ + """Return a list of available models based on configuration strategy.""" now = int(datetime.now(tz=timezone.utc).timestamp()) strategy = g_config.gemini.model_strategy models_data = [] @@ -486,910 +684,897 @@ def _get_available_models() -> list[ModelData]: return models_data -@router.get("/v1/models", response_model=ModelListResponse) -async def list_models(api_key: str = Depends(verify_api_key)): - models = _get_available_models() - return ModelListResponse(data=models) - - -@router.post("/v1/chat/completions") -async def create_chat_completion( - request: ChatCompletionRequest, - api_key: str = Depends(verify_api_key), - tmp_dir: Path = Depends(get_temp_dir), - image_store: Path = Depends(get_image_store_dir), -): - pool = GeminiClientPool() - db = LMDBConversationStore() - - try: - model = _get_model_by_name(request.model) - except ValueError as exc: - raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc - - if len(request.messages) == 0: - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail="At least one message is required in the conversation.", - ) +async def _find_reusable_session( + db: LMDBConversationStore, + pool: GeminiClientPool, + model: Model, + messages: list[Message], +) -> tuple[ChatSession | None, GeminiClientWrapper | None, list[Message]]: + """Find an existing chat session matching the longest suitable history prefix.""" + if len(messages) < 2: + return None, None, messages - structured_requirement = _build_structured_requirement(request.response_format) - if structured_requirement and request.stream: - logger.debug( - "Structured response requested with streaming enabled; will stream canonical JSON once ready." - ) - if structured_requirement: - logger.debug( - f"Structured response requested for /v1/chat/completions (schema={structured_requirement.schema_name})." - ) + search_end = len(messages) + while search_end >= 2: + search_history = messages[:search_end] + if search_history[-1].role in {"assistant", "system", "tool"}: + try: + if conv := db.find(model.model_name, search_history): + 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}/{len(messages)}. Client: {conv.client_id}" + ) + return session, client, remain + else: + logger.debug( + f"Matched conversation at length {search_end} is too old ({age_minutes:.1f}m), skipping reuse." + ) + else: + # Log that we tried this prefix but failed + pass + except Exception as e: + logger.warning( + f"Error checking LMDB for reusable session at length {search_end}: {e}" + ) + break + search_end -= 1 - extra_instructions = [structured_requirement.instruction] if structured_requirement else None + logger.debug(f"No reusable session found for {len(messages)} messages.") + return None, None, messages - # Check if conversation is reusable - session, client, remaining_messages = await _find_reusable_session( - db, pool, model, request.messages - ) - 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, - inject_system_defaults=False, - ) - if not messages_to_send: - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail="No new messages to send for the existing session.", - ) - if len(messages_to_send) == 1: - model_input, files = await GeminiClientWrapper.process_message( - messages_to_send[0], tmp_dir, tagged=False - ) - else: - model_input, files = await GeminiClientWrapper.process_conversation( - messages_to_send, tmp_dir - ) - logger.debug( - f"Reused session {session.metadata} - sending {len(messages_to_send)} prepared messages." - ) - else: - # Start a new session and concat messages into a single string +async def _send_with_split( + session: ChatSession, + text: str, + files: list[Path | str | io.BytesIO] | None = None, + stream: bool = False, +) -> AsyncGenerator[ModelOutput, None] | ModelOutput: + """Send text to Gemini, splitting or converting to attachment if too long.""" + if len(text) <= MAX_CHARS_PER_REQUEST: try: - client = await pool.acquire() - session = client.start_chat(model=model) - messages_to_send = _prepare_messages_for_model( - request.messages, request.tools, request.tool_choice, extra_instructions - ) - model_input, files = await GeminiClientWrapper.process_conversation( - messages_to_send, tmp_dir - ) - except ValueError as e: - raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) - except RuntimeError as e: - raise HTTPException(status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail=str(e)) + if stream: + return session.send_message_stream(text, files=files) + return await session.send_message(text, files=files) except Exception as e: - logger.exception(f"Error in preparing conversation: {e}") + logger.exception(f"Error sending message to Gemini: {e}") raise - logger.debug("New session started.") - # Generate response + logger.info( + f"Message length ({len(text)}) exceeds limit ({MAX_CHARS_PER_REQUEST}). Converting text to file attachment." + ) + file_obj = io.BytesIO(text.encode("utf-8")) + file_obj.name = "message.txt" try: - assert session and client, "Session and client not available" - client_id = client.id - logger.debug( - f"Client ID: {client_id}, Input length: {len(model_input)}, files count: {len(files)}" + final_files = list(files) if files else [] + final_files.append(file_obj) + 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" ) - response = await _send_with_split(session, model_input, files=files) - except APIError as exc: - client_id = client.id if client else "unknown" - logger.warning(f"Gemini API returned invalid response for client {client_id}: {exc}") - raise HTTPException( - status_code=status.HTTP_503_SERVICE_UNAVAILABLE, - detail="Gemini temporarily returned an invalid response. Please retry.", - ) from exc - except HTTPException: - raise + if stream: + return session.send_message_stream(instruction, files=final_files) + return await session.send_message(instruction, files=final_files) except Exception as e: - logger.exception(f"Unexpected error generating content from Gemini API: {e}") - raise HTTPException( - status_code=status.HTTP_502_BAD_GATEWAY, - detail="Gemini returned an unexpected error.", - ) from e + logger.exception(f"Error sending large text as file to Gemini: {e}") + raise - # Format the response from API - try: - raw_output_with_think = GeminiClientWrapper.extract_output(response, include_thoughts=True) - raw_output_clean = GeminiClientWrapper.extract_output(response, include_thoughts=False) - except IndexError as exc: - logger.exception("Gemini output parsing failed (IndexError).") - raise HTTPException( - status_code=status.HTTP_502_BAD_GATEWAY, - detail="Gemini returned malformed response content.", - ) from exc - except Exception as exc: - logger.exception("Gemini output parsing failed unexpectedly.") - raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail="Gemini output parsing failed unexpectedly.", - ) from exc - visible_output, tool_calls = extract_tool_calls(raw_output_with_think) - storage_output = remove_tool_call_blocks(raw_output_clean).strip() - tool_calls_payload = [call.model_dump(mode="json") for call in tool_calls] +class StreamingOutputFilter: + """ + Enhanced streaming filter that suppresses: + 1. XML tool call blocks: ```xml ... ``` + 2. ChatML tool blocks: <|im_start|>tool\n...<|im_end|> + 3. ChatML role headers: <|im_start|>role\n (only suppresses the header, keeps content) + 4. Control tokens: <|im_start|>, <|im_end|> + 5. System instructions/hints: XML_WRAP_HINT, CODE_BLOCK_HINT, etc. + """ - if structured_requirement: - cleaned_visible = strip_code_fence(visible_output or "") - if not cleaned_visible: - raise HTTPException( - status_code=status.HTTP_502_BAD_GATEWAY, - detail="LLM returned an empty response while JSON schema output was requested.", - ) - try: - 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}" - ) - raise HTTPException( - status_code=status.HTTP_502_BAD_GATEWAY, - detail="LLM returned invalid JSON for the requested response_format.", - ) from exc + def __init__(self): + self.buffer = "" + self.in_xml_tool = False + self.in_tagged_block = False + self.in_role_header = False + self.current_role = "" + + self.XML_START = "```xml" + self.XML_END = "```" + self.TAG_START = "<|im_start|>" + self.TAG_END = "<|im_end|>" + self.SYSTEM_HINTS = [ + XML_WRAP_HINT, + XML_HINT_STRIPPED, + CODE_BLOCK_HINT, + CODE_HINT_STRIPPED, + ] - canonical_output = orjson.dumps(structured_payload).decode("utf-8") - visible_output = canonical_output - storage_output = canonical_output + def process(self, chunk: str) -> str: + self.buffer += chunk + to_yield = "" + + while self.buffer: + if self.in_xml_tool: + end_idx = self.buffer.find(self.XML_END) + if end_idx != -1: + self.buffer = self.buffer[end_idx + len(self.XML_END) :] + self.in_xml_tool = False + else: + break + elif self.in_role_header: + nl_idx = self.buffer.find("\n") + if nl_idx != -1: + role_text = self.buffer[:nl_idx].strip().lower() + self.current_role = role_text + self.buffer = self.buffer[nl_idx + 1 :] + self.in_role_header = False + self.in_tagged_block = True + else: + break + elif self.in_tagged_block: + end_idx = self.buffer.find(self.TAG_END) + if end_idx != -1: + content = self.buffer[:end_idx] + if self.current_role != "tool": + to_yield += content + self.buffer = self.buffer[end_idx + len(self.TAG_END) :] + self.in_tagged_block = False + self.current_role = "" + else: + if self.current_role == "tool": + break + else: + yield_len = len(self.buffer) - (len(self.TAG_END) - 1) + if yield_len > 0: + to_yield += self.buffer[:yield_len] + self.buffer = self.buffer[yield_len:] + break + else: + # Outside any special block. Look for starts. + earliest_idx = -1 + match_type = "" + + xml_idx = self.buffer.find(self.XML_START) + if xml_idx != -1: + earliest_idx = xml_idx + match_type = "xml" + + tag_s_idx = self.buffer.find(self.TAG_START) + if tag_s_idx != -1: + if earliest_idx == -1 or tag_s_idx < earliest_idx: + earliest_idx = tag_s_idx + match_type = "tag_start" + + tag_e_idx = self.buffer.find(self.TAG_END) + if tag_e_idx != -1: + if earliest_idx == -1 or tag_e_idx < earliest_idx: + earliest_idx = tag_e_idx + match_type = "tag_end" + + if earliest_idx != -1: + # Yield text before the match + to_yield += self.buffer[:earliest_idx] + self.buffer = self.buffer[earliest_idx:] + + if match_type == "xml": + self.in_xml_tool = True + self.buffer = self.buffer[len(self.XML_START) :] + elif match_type == "tag_start": + self.in_role_header = True + self.buffer = self.buffer[len(self.TAG_START) :] + elif match_type == "tag_end": + # Orphaned end tag, just skip it + self.buffer = self.buffer[len(self.TAG_END) :] + continue + else: + # Check for prefixes + prefixes = [self.XML_START, self.TAG_START, self.TAG_END] + max_keep = 0 + for p in prefixes: + for i in range(len(p) - 1, 0, -1): + if self.buffer.endswith(p[:i]): + max_keep = max(max_keep, i) + break - if tool_calls_payload: - logger.debug(f"Detected tool calls: {tool_calls_payload}") + yield_len = len(self.buffer) - max_keep + if yield_len > 0: + to_yield += self.buffer[:yield_len] + self.buffer = self.buffer[yield_len:] + break - # After formatting, persist the conversation to LMDB - try: - current_assistant_message = Message( - role="assistant", - content=storage_output or None, - tool_calls=tool_calls or None, - ) - # 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) + # Final pass: filter out system hints from the text to be yielded + for hint in self.SYSTEM_HINTS: + if hint in to_yield: + to_yield = to_yield.replace(hint, "") - conv = ConversationInStore( - model=model.model_name, - client_id=client.id, - metadata=session.metadata, - messages=cleaned_history, - ) - key = db.store(conv) - logger.debug(f"Conversation saved to LMDB with key: {key}") - except Exception as e: - # We can still return the response even if saving fails - logger.warning(f"Failed to save conversation to LMDB: {e}") + return to_yield - # Return with streaming or standard response - completion_id = f"chatcmpl-{uuid.uuid4()}" - timestamp = int(datetime.now(tz=timezone.utc).timestamp()) - if request.stream: - return _create_streaming_response( - visible_output, - tool_calls_payload, - completion_id, - timestamp, - request.model, - request.messages, - ) - else: - return _create_standard_response( - visible_output, - tool_calls_payload, - completion_id, - timestamp, - request.model, - request.messages, - ) - - -@router.post("/v1/responses") -async def create_response( - request_data: ResponseCreateRequest, - request: Request, - api_key: str = Depends(verify_api_key), - tmp_dir: Path = Depends(get_temp_dir), - image_store: Path = Depends(get_image_store_dir), -): - base_messages, normalized_input = _response_items_to_messages(request_data.input) - structured_requirement = _build_structured_requirement(request_data.response_format) - if structured_requirement and request_data.stream: - logger.debug( - "Structured response requested with streaming enabled; streaming not supported for Responses." - ) - - extra_instructions: list[str] = [] - if structured_requirement: - extra_instructions.append(structured_requirement.instruction) - logger.debug( - f"Structured response requested for /v1/responses (schema={structured_requirement.schema_name})." - ) - - # Separate standard tools from image generation tools - standard_tools: list[Tool] = [] - image_tools: list[ResponseImageTool] = [] - - if request_data.tools: - for t in request_data.tools: - if isinstance(t, Tool): - standard_tools.append(t) - elif isinstance(t, ResponseImageTool): - image_tools.append(t) - # Handle dicts if Pydantic didn't convert them fully (fallback) - elif isinstance(t, dict): - t_type = t.get("type") - if t_type == "function": - standard_tools.append(Tool.model_validate(t)) - elif t_type == "image_generation": - image_tools.append(ResponseImageTool.model_validate(t)) - - image_instruction = _build_image_generation_instruction( - image_tools, - request_data.tool_choice - if isinstance(request_data.tool_choice, ResponseToolChoice) - else None, - ) - if image_instruction: - extra_instructions.append(image_instruction) - logger.debug("Image generation support enabled for /v1/responses request.") - - preface_messages = _instructions_to_messages(request_data.instructions) - conversation_messages = base_messages - if preface_messages: - conversation_messages = [*preface_messages, *base_messages] - logger.debug( - f"Injected {len(preface_messages)} instruction messages before sending to Gemini." - ) - - # Pass standard tools to the prompt builder - # Determine tool_choice for standard tools (ignore image_generation choice here as it is handled via instruction) - model_tool_choice = None - if isinstance(request_data.tool_choice, str): - model_tool_choice = request_data.tool_choice - elif isinstance(request_data.tool_choice, ToolChoiceFunction): - model_tool_choice = request_data.tool_choice - # If tool_choice is ResponseToolChoice (image_generation), we don't pass it as a function tool choice. - - messages = _prepare_messages_for_model( - conversation_messages, - tools=standard_tools or None, - tool_choice=model_tool_choice, - extra_instructions=extra_instructions or None, - ) - - pool = GeminiClientPool() - db = LMDBConversationStore() - - try: - model = _get_model_by_name(request_data.model) - except ValueError as exc: - raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc - - session, client, remaining_messages = await _find_reusable_session(db, pool, model, messages) - - async def _build_payload( - _payload_messages: list[Message], _reuse_session: bool - ) -> tuple[str, list[Path | str]]: - if _reuse_session and len(_payload_messages) == 1: - return await GeminiClientWrapper.process_message( - _payload_messages[0], tmp_dir, tagged=False - ) - return await GeminiClientWrapper.process_conversation(_payload_messages, tmp_dir) - - reuse_session = session is not None - if reuse_session: - messages_to_send = _prepare_messages_for_model( - remaining_messages, - 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( - status_code=status.HTTP_400_BAD_REQUEST, - detail="No new messages to send for the existing session.", - ) - payload_messages = messages_to_send - model_input, files = await _build_payload(payload_messages, _reuse_session=True) - logger.debug( - f"Reused session {session.metadata} - sending {len(payload_messages)} prepared messages." - ) - else: - try: - client = await pool.acquire() - session = client.start_chat(model=model) - payload_messages = messages - model_input, files = await _build_payload(payload_messages, _reuse_session=False) - except ValueError as e: - raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) - except RuntimeError as e: - raise HTTPException(status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail=str(e)) - except Exception as e: - logger.exception(f"Error in preparing conversation for responses API: {e}") - raise - logger.debug("New session started for /v1/responses request.") + def flush(self) -> str: + # If we are stuck in a tool block or role header at the end, + # it usually means malformed output. + if self.in_xml_tool or (self.in_tagged_block and self.current_role == "tool"): + return "" - try: - assert session and client, "Session and client not available" - client_id = client.id - logger.debug( - f"Client ID: {client_id}, Input length: {len(model_input)}, files count: {len(files)}" - ) - model_output = await _send_with_split(session, model_input, files=files) - except APIError as exc: - client_id = client.id if client else "unknown" - logger.warning(f"Gemini API returned invalid response for client {client_id}: {exc}") - raise HTTPException( - status_code=status.HTTP_503_SERVICE_UNAVAILABLE, - detail="Gemini temporarily returned an invalid response. Please retry.", - ) from exc - except HTTPException: - raise - except Exception as e: - logger.exception(f"Unexpected error generating content from Gemini API for responses: {e}") - raise HTTPException( - status_code=status.HTTP_502_BAD_GATEWAY, - detail="Gemini returned an unexpected error.", - ) from e + final_text = self.buffer + self.buffer = "" - try: - text_with_think = GeminiClientWrapper.extract_output(model_output, include_thoughts=True) - text_without_think = GeminiClientWrapper.extract_output( - model_output, include_thoughts=False - ) - except IndexError as exc: - logger.exception("Gemini output parsing failed (IndexError).") - raise HTTPException( - status_code=status.HTTP_502_BAD_GATEWAY, - detail="Gemini returned malformed response content.", - ) from exc - except Exception as exc: - logger.exception("Gemini output parsing failed unexpectedly.") - raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail="Gemini output parsing failed unexpectedly.", - ) from exc + # Filter out any orphaned/partial control tokens or hints + final_text = CONTROL_TOKEN_RE.sub("", final_text) + for hint in self.SYSTEM_HINTS: + final_text = final_text.replace(hint, "") - visible_text, detected_tool_calls = extract_tool_calls(text_with_think) - storage_output = remove_tool_call_blocks(text_without_think).strip() - assistant_text = LMDBConversationStore.remove_think_tags(visible_text.strip()) + return final_text.strip() - if structured_requirement: - cleaned_visible = strip_code_fence(assistant_text or "") - if not cleaned_visible: - raise HTTPException( - status_code=status.HTTP_502_BAD_GATEWAY, - detail="LLM returned an empty response while JSON schema output was requested.", - ) - try: - 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}" - ) - raise HTTPException( - status_code=status.HTTP_502_BAD_GATEWAY, - detail="LLM returned invalid JSON for the requested response_format.", - ) from exc - - canonical_output = orjson.dumps(structured_payload).decode("utf-8") - assistant_text = canonical_output - storage_output = canonical_output - logger.debug( - f"Structured response fulfilled for /v1/responses (schema={structured_requirement.schema_name})." - ) - expects_image = ( - request_data.tool_choice is not None and request_data.tool_choice.type == "image_generation" - ) - images = model_output.images or [] - logger.debug( - f"Gemini returned {len(images)} image(s) for /v1/responses " - f"(expects_image={expects_image}, instruction_applied={bool(image_instruction)})." - ) - if expects_image and not images: - summary = assistant_text.strip() if assistant_text else "" - if summary: - summary = re.sub(r"\s+", " ", summary) - if len(summary) > 200: - summary = f"{summary[:197]}..." - logger.warning( - "Image generation requested but Gemini produced no images. " - f"client_id={client_id}, forced_tool_choice={request_data.tool_choice is not None}, " - f"instruction_applied={bool(image_instruction)}, assistant_preview='{summary}'" - ) - detail = "LLM returned no images for the requested image_generation tool." - if summary: - detail = f"{detail} Assistant response: {summary}" - raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail=detail) +# --- Response Builders & Streaming --- - response_contents: list[ResponseOutputContent] = [] - image_call_items: list[ResponseImageGenerationCall] = [] - for image in images: - try: - image_base64, width, height, filename = await _image_to_base64(image, image_store) - except Exception as exc: - logger.warning(f"Failed to download generated image: {exc}") - continue - - img_format = "png" if isinstance(image, GeneratedImage) else "jpeg" - - # Use static URL for compatibility - image_url = ( - f"![{filename}]({request.base_url}images/{filename}?token={get_image_token(filename)})" - ) - - image_call_items.append( - ResponseImageGenerationCall( - id=filename.rsplit(".", 1)[0], - status="completed", - result=image_base64, - output_format=img_format, - size=f"{width}x{height}" if width and height else None, - ) - ) - # Add as output_text content for compatibility - response_contents.append( - ResponseOutputContent(type="output_text", text=image_url, annotations=[]) - ) - - tool_call_items: list[ResponseToolCall] = [] - if detected_tool_calls: - tool_call_items = [ - ResponseToolCall( - id=call.id, - status="completed", - function=call.function, - ) - for call in detected_tool_calls - ] - - if assistant_text: - response_contents.append( - ResponseOutputContent(type="output_text", text=assistant_text, annotations=[]) - ) - if not response_contents: - response_contents.append(ResponseOutputContent(type="output_text", text="", annotations=[])) - - created_time = int(datetime.now(tz=timezone.utc).timestamp()) - response_id = f"resp_{uuid.uuid4().hex}" - message_id = f"msg_{uuid.uuid4().hex}" - - input_tokens = sum(estimate_tokens(text_from_message(msg)) for msg in messages) - tool_arg_text = "".join(call.function.arguments or "" for call in detected_tool_calls) - completion_basis = assistant_text or "" - if tool_arg_text: - completion_basis = ( - f"{completion_basis}\n{tool_arg_text}" if completion_basis else tool_arg_text - ) - output_tokens = estimate_tokens(completion_basis) - usage = ResponseUsage( - input_tokens=input_tokens, - output_tokens=output_tokens, - total_tokens=input_tokens + output_tokens, - ) - - response_payload = ResponseCreateResponse( - id=response_id, - created_at=created_time, - model=request_data.model, - output=[ - ResponseOutputMessage( - id=message_id, - type="message", - role="assistant", - content=response_contents, - ), - *tool_call_items, - *image_call_items, - ], - status="completed", - usage=usage, - input=normalized_input or None, - metadata=request_data.metadata or None, - tools=request_data.tools, - tool_choice=request_data.tool_choice, - ) - - try: - current_assistant_message = Message( - role="assistant", - content=storage_output or None, - tool_calls=detected_tool_calls or None, - ) - 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, - ) - key = db.store(conv) - logger.debug(f"Conversation saved to LMDB with key: {key}") - except Exception as exc: - logger.warning(f"Failed to save Responses conversation to LMDB: {exc}") - - if request_data.stream: - logger.debug( - f"Streaming Responses API payload (response_id={response_payload.id}, text_chunks={bool(assistant_text)})." - ) - return _create_responses_streaming_response(response_payload, assistant_text or "") - - return response_payload - - -async def _find_reusable_session( +def _create_real_streaming_response( + generator: AsyncGenerator[ModelOutput, None], + completion_id: str, + created_time: int, + model_name: str, + messages: list[Message], db: LMDBConversationStore, - pool: GeminiClientPool, model: Model, - messages: list[Message], -) -> tuple[ChatSession | None, GeminiClientWrapper | None, list[Message]]: - """Find an existing chat session that matches the *longest* prefix of - ``messages`` **whose last element is an assistant/system reply**. - - Rationale - --------- - When a reply was generated by *another* server instance, the local LMDB may - only contain an older part of the conversation. However, as long as we can - line up **any** earlier assistant/system response, we can restore the - corresponding Gemini session and replay the *remaining* turns locally - (including that missing assistant reply and the subsequent user prompts). - - The algorithm therefore walks backwards through the history **one message at - a time**, each time requiring the current tail to be assistant/system before - querying LMDB. As soon as a match is found we recreate the session and - return the untouched suffix as ``remaining_messages``. - """ - - if len(messages) < 2: - return None, None, messages - - # 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/tool before querying LMDB. - if search_history[-1].role in {"assistant", "system", "tool"}: - try: - if conv := db.find(model.model_name, search_history): - # 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 at length {search_end}: {e}" - ) - break - - # Trim one message and try again. - search_end -= 1 - - return None, None, messages - - -async def _send_with_split(session: ChatSession, text: str, files: list[Path | str] | None = None): + client_wrapper: GeminiClientWrapper, + session: ChatSession, + structured_requirement: StructuredOutputRequirement | None = None, +) -> StreamingResponse: """ - 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. + Create a real-time streaming response. + Reconciles manual delta accumulation with the model's final authoritative state. """ - if len(text) <= MAX_CHARS_PER_REQUEST: - try: - return await session.send_message(text, files=files) - except Exception as e: - logger.exception(f"Error sending message to Gemini: {e}") - raise - - 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") + async def generate_stream(): + full_thoughts, full_text = "", "" + has_started = False + last_chunk_was_thought = False + all_outputs: list[ModelOutput] = [] + suppressor = StreamingOutputFilter() try: - # 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) - + async for chunk in generator: + all_outputs.append(chunk) + if not has_started: + data = { + "id": completion_id, + "object": "chat.completion.chunk", + "created": created_time, + "model": model_name, + "choices": [ + {"index": 0, "delta": {"role": "assistant"}, "finish_reason": None} + ], + } + yield f"data: {orjson.dumps(data).decode('utf-8')}\n\n" + has_started = True + + if t_delta := chunk.thoughts_delta: + if not last_chunk_was_thought and not full_thoughts: + yield f"data: {orjson.dumps({'id': completion_id, 'object': 'chat.completion.chunk', 'created': created_time, 'model': model_name, 'choices': [{'index': 0, 'delta': {'content': ''}, 'finish_reason': None}]}).decode('utf-8')}\n\n" + full_thoughts += t_delta + data = { + "id": completion_id, + "object": "chat.completion.chunk", + "created": created_time, + "model": model_name, + "choices": [ + {"index": 0, "delta": {"content": t_delta}, "finish_reason": None} + ], + } + yield f"data: {orjson.dumps(data).decode('utf-8')}\n\n" + last_chunk_was_thought = True + + if text_delta := chunk.text_delta: + if last_chunk_was_thought: + yield f"data: {orjson.dumps({'id': completion_id, 'object': 'chat.completion.chunk', 'created': created_time, 'model': model_name, 'choices': [{'index': 0, 'delta': {'content': '\n'}, 'finish_reason': None}]}).decode('utf-8')}\n\n" + last_chunk_was_thought = False + full_text += text_delta + if visible_delta := suppressor.process(text_delta): + data = { + "id": completion_id, + "object": "chat.completion.chunk", + "created": created_time, + "model": model_name, + "choices": [ + { + "index": 0, + "delta": {"content": visible_delta}, + "finish_reason": None, + } + ], + } + yield f"data: {orjson.dumps(data).decode('utf-8')}\n\n" except Exception as e: - logger.exception(f"Error sending large text as file to Gemini: {e}") - raise + logger.exception(f"Error during OpenAI streaming: {e}") + yield f"data: {orjson.dumps({'error': {'message': 'Streaming error occurred.', 'type': 'server_error', 'param': None, 'code': None}}).decode('utf-8')}\n\n" + return + if all_outputs: + final_chunk = all_outputs[-1] + if final_chunk.text: + full_text = final_chunk.text + if final_chunk.thoughts: + full_thoughts = final_chunk.thoughts -def _create_streaming_response( - model_output: str, - tool_calls: list[dict], - completion_id: str, - created_time: int, - model: str, - messages: list[Message], -) -> StreamingResponse: - """Create streaming response with `usage` calculation included in the final chunk.""" + if last_chunk_was_thought: + yield f"data: {orjson.dumps({'id': completion_id, 'object': 'chat.completion.chunk', 'created': created_time, 'model': model_name, 'choices': [{'index': 0, 'delta': {'content': '\n'}, 'finish_reason': None}]}).decode('utf-8')}\n\n" - # Calculate token usage - prompt_tokens = sum(estimate_tokens(text_from_message(msg)) for msg in messages) - tool_args = "".join(call.get("function", {}).get("arguments", "") for call in tool_calls or []) - completion_tokens = estimate_tokens(model_output + tool_args) - total_tokens = prompt_tokens + completion_tokens - finish_reason = "tool_calls" if tool_calls else "stop" + if remaining_text := suppressor.flush(): + data = { + "id": completion_id, + "object": "chat.completion.chunk", + "created": created_time, + "model": model_name, + "choices": [ + {"index": 0, "delta": {"content": remaining_text}, "finish_reason": None} + ], + } + yield f"data: {orjson.dumps(data).decode('utf-8')}\n\n" - async def generate_stream(): - # Send start event - data = { - "id": completion_id, - "object": "chat.completion.chunk", - "created": created_time, - "model": model, - "choices": [{"index": 0, "delta": {"role": "assistant"}, "finish_reason": None}], - } - yield f"data: {orjson.dumps(data).decode('utf-8')}\n\n" + raw_output_with_think = f"{full_thoughts}\n" if full_thoughts else "" + raw_output_with_think += full_text + assistant_text, storage_output, tool_calls = _process_llm_output( + raw_output_with_think, full_text, structured_requirement + ) - # Stream output text in chunks for efficiency - for chunk in iter_stream_segments(model_output): + images = [] + for out in all_outputs: + if out.images: + images.extend(out.images) + + image_markdown = "" + for image in images: + try: + image_store = get_image_store_dir() + _, _, _, filename = await _image_to_base64(image, image_store) + img_url = f"![{filename}](images/{filename}?token={get_image_token(filename)})" + image_markdown += f"\n\n{img_url}" + except Exception as exc: + logger.warning(f"Failed to process image in OpenAI stream: {exc}") + + if image_markdown: + assistant_text += image_markdown + storage_output += image_markdown + # Send the image markdown as a final text chunk before usage data = { "id": completion_id, "object": "chat.completion.chunk", "created": created_time, - "model": model, - "choices": [{"index": 0, "delta": {"content": chunk}, "finish_reason": None}], + "model": model_name, + "choices": [ + {"index": 0, "delta": {"content": image_markdown}, "finish_reason": None} + ], } yield f"data: {orjson.dumps(data).decode('utf-8')}\n\n" - if tool_calls: - tool_calls_delta = [{**call, "index": idx} for idx, call in enumerate(tool_calls)] + tool_calls_payload = [call.model_dump(mode="json") for call in tool_calls] + if tool_calls_payload: + tool_calls_delta = [ + {**call, "index": idx} for idx, call in enumerate(tool_calls_payload) + ] data = { "id": completion_id, "object": "chat.completion.chunk", "created": created_time, - "model": model, + "model": model_name, "choices": [ - { - "index": 0, - "delta": {"tool_calls": tool_calls_delta}, - "finish_reason": None, - } + {"index": 0, "delta": {"tool_calls": tool_calls_delta}, "finish_reason": None} ], } yield f"data: {orjson.dumps(data).decode('utf-8')}\n\n" - # Send end event + p_tok, c_tok, t_tok = _calculate_usage(messages, assistant_text, tool_calls) + usage = {"prompt_tokens": p_tok, "completion_tokens": c_tok, "total_tokens": t_tok} data = { "id": completion_id, "object": "chat.completion.chunk", "created": created_time, - "model": model, - "choices": [{"index": 0, "delta": {}, "finish_reason": finish_reason}], - "usage": { - "prompt_tokens": prompt_tokens, - "completion_tokens": completion_tokens, - "total_tokens": total_tokens, - }, + "model": model_name, + "choices": [ + {"index": 0, "delta": {}, "finish_reason": "tool_calls" if tool_calls else "stop"} + ], + "usage": usage, } + _persist_conversation( + db, + model.model_name, + client_wrapper.id, + session.metadata, + messages, # This should be the prepared messages + storage_output, + tool_calls, + ) yield f"data: {orjson.dumps(data).decode('utf-8')}\n\n" yield "data: [DONE]\n\n" return StreamingResponse(generate_stream(), media_type="text/event-stream") -def _create_responses_streaming_response( - response_payload: ResponseCreateResponse, - assistant_text: str | None, +def _create_responses_real_streaming_response( + generator: AsyncGenerator[ModelOutput, None], + response_id: str, + created_time: int, + model_name: str, + messages: list[Message], + db: LMDBConversationStore, + model: Model, + client_wrapper: GeminiClientWrapper, + session: ChatSession, + request_data: ResponseCreateRequest, + image_store: Path, + base_url: str, + structured_requirement: StructuredOutputRequirement | None = None, ) -> StreamingResponse: - """Create streaming response for Responses API using event types defined by OpenAI.""" - - response_dict = response_payload.model_dump(mode="json") - response_id = response_payload.id - created_time = response_payload.created_at - model = response_payload.model - - logger.debug( - f"Preparing streaming envelope for /v1/responses (response_id={response_id}, model={model})." - ) - + """ + Create a real-time streaming response for the Responses API. + Ensures final accumulated text and thoughts are synchronized. + """ base_event = { "id": response_id, "object": "response", "created_at": created_time, - "model": model, + "model": model_name, } - created_snapshot: dict[str, Any] = { - "id": response_id, - "object": "response", - "created_at": created_time, - "model": model, - "status": "in_progress", - } - if response_dict.get("metadata") is not None: - created_snapshot["metadata"] = response_dict["metadata"] - if response_dict.get("input") is not None: - created_snapshot["input"] = response_dict["input"] - if response_dict.get("tools") is not None: - created_snapshot["tools"] = response_dict["tools"] - if response_dict.get("tool_choice") is not None: - created_snapshot["tool_choice"] = response_dict["tool_choice"] - async def generate_stream(): - # Emit creation event - data = { - **base_event, - "type": "response.created", - "response": created_snapshot, - } - yield f"data: {orjson.dumps(data).decode('utf-8')}\n\n" + yield f"data: {orjson.dumps({**base_event, 'type': 'response.created', 'response': {'id': response_id, 'object': 'response', 'created_at': created_time, 'model': model_name, 'status': 'in_progress', 'metadata': request_data.metadata, 'input': None, 'tools': request_data.tools, 'tool_choice': request_data.tool_choice}}).decode('utf-8')}\n\n" + message_id = f"msg_{uuid.uuid4().hex}" + yield f"data: {orjson.dumps({**base_event, 'type': 'response.output_item.added', 'output_index': 0, 'item': {'id': message_id, 'type': 'message', 'role': 'assistant', 'content': []}}).decode('utf-8')}\n\n" - # Stream output items (Message/Text, Tool Calls, Images) - for i, item in enumerate(response_payload.output): - item_json = item.model_dump(mode="json", exclude_none=True) + full_thoughts, full_text = "", "" + last_chunk_was_thought = False + all_outputs: list[ModelOutput] = [] + suppressor = StreamingOutputFilter() - added_event = { - **base_event, - "type": "response.output_item.added", - "output_index": i, - "item": item_json, - } - yield f"data: {orjson.dumps(added_event).decode('utf-8')}\n\n" - - # 2. Stream content if it's a message (text) - if item.type == "message": - content_text = "" - # Aggregate text content to stream - for c in item.content: - if c.type == "output_text" and c.text: - content_text += c.text - - if content_text: - for chunk in iter_stream_segments(content_text): - delta_event = { - **base_event, - "type": "response.output_text.delta", - "output_index": i, - "delta": chunk, - } - yield f"data: {orjson.dumps(delta_event).decode('utf-8')}\n\n" + try: + async for chunk in generator: + all_outputs.append(chunk) + if t_delta := chunk.thoughts_delta: + if not last_chunk_was_thought and not full_thoughts: + yield f"data: {orjson.dumps({**base_event, 'type': 'response.output_text.delta', 'output_index': 0, 'delta': ''}).decode('utf-8')}\n\n" + full_thoughts += t_delta + yield f"data: {orjson.dumps({**base_event, 'type': 'response.output_text.delta', 'output_index': 0, 'delta': t_delta}).decode('utf-8')}\n\n" + last_chunk_was_thought = True + if text_delta := chunk.text_delta: + if last_chunk_was_thought: + yield f"data: {orjson.dumps({**base_event, 'type': 'response.output_text.delta', 'output_index': 0, 'delta': '\n'}).decode('utf-8')}\n\n" + last_chunk_was_thought = False + full_text += text_delta + if visible_delta := suppressor.process(text_delta): + yield f"data: {orjson.dumps({**base_event, 'type': 'response.output_text.delta', 'output_index': 0, 'delta': visible_delta}).decode('utf-8')}\n\n" + except Exception as e: + logger.exception(f"Error during Responses API streaming: {e}") + yield f"data: {orjson.dumps({**base_event, 'type': 'error', 'error': {'message': 'Streaming error.'}}).decode('utf-8')}\n\n" + return - # Text done - done_event = { - **base_event, - "type": "response.output_text.done", - "output_index": i, - } - yield f"data: {orjson.dumps(done_event).decode('utf-8')}\n\n" - - # 3. Emit output_item.done for all types - # This confirms the item is fully transferred. - item_done_event = { - **base_event, - "type": "response.output_item.done", - "output_index": i, - "item": item_json, - } - yield f"data: {orjson.dumps(item_done_event).decode('utf-8')}\n\n" + if all_outputs: + final_chunk = all_outputs[-1] + if final_chunk.text: + full_text = final_chunk.text + if final_chunk.thoughts: + full_thoughts = final_chunk.thoughts + + if last_chunk_was_thought: + yield f"data: {orjson.dumps({**base_event, 'type': 'response.output_text.delta', 'output_index': 0, 'delta': '\n'}).decode('utf-8')}\n\n" + if remaining_text := suppressor.flush(): + yield f"data: {orjson.dumps({**base_event, 'type': 'response.output_text.delta', 'output_index': 0, 'delta': remaining_text}).decode('utf-8')}\n\n" + yield f"data: {orjson.dumps({**base_event, 'type': 'response.output_text.done', 'output_index': 0}).decode('utf-8')}\n\n" + + raw_output_with_think = f"{full_thoughts}\n" if full_thoughts else "" + raw_output_with_think += full_text + assistant_text, storage_output, detected_tool_calls = _process_llm_output( + raw_output_with_think, full_text, structured_requirement + ) - # Emit completed event with full payload - completed_event = { - **base_event, - "type": "response.completed", - "response": response_dict, - } - yield f"data: {orjson.dumps(completed_event).decode('utf-8')}\n\n" + images = [] + for out in all_outputs: + if out.images: + images.extend(out.images) + + response_contents, image_call_items = [], [] + for image in images: + try: + image_base64, width, height, filename = await _image_to_base64(image, image_store) + img_format = "png" if isinstance(image, GeneratedImage) else "jpeg" + image_url = ( + f"![{filename}]({base_url}images/{filename}?token={get_image_token(filename)})" + ) + image_call_items.append( + ResponseImageGenerationCall( + id=filename.rsplit(".", 1)[0], + result=image_base64, + output_format=img_format, + size=f"{width}x{height}" if width and height else None, + ) + ) + response_contents.append(ResponseOutputContent(type="output_text", text=image_url)) + except Exception as exc: + logger.warning(f"Failed to process image in stream: {exc}") + + if assistant_text: + response_contents.append(ResponseOutputContent(type="output_text", text=assistant_text)) + if not response_contents: + response_contents.append(ResponseOutputContent(type="output_text", text="")) + + # Aggregate images for storage + image_markdown = "" + for img_call in image_call_items: + fname = f"{img_call.id}.{img_call.output_format}" + img_url = f"![{fname}](images/{fname}?token={get_image_token(fname)})" + image_markdown += f"\n\n{img_url}" + + if image_markdown: + storage_output += image_markdown + + yield f"data: {orjson.dumps({**base_event, 'type': 'response.output_item.done', 'output_index': 0, 'item': {'id': message_id, 'type': 'message', 'role': 'assistant', 'content': [c.model_dump(mode='json') for c in response_contents]}}).decode('utf-8')}\n\n" + + current_idx = 1 + for call in detected_tool_calls: + tc_item = ResponseToolCall(id=call.id, status="completed", function=call.function) + yield f"data: {orjson.dumps({**base_event, 'type': 'response.output_item.added', 'output_index': current_idx, 'item': tc_item.model_dump(mode='json')}).decode('utf-8')}\n\n" + yield f"data: {orjson.dumps({**base_event, 'type': 'response.output_item.done', 'output_index': current_idx, 'item': tc_item.model_dump(mode='json')}).decode('utf-8')}\n\n" + current_idx += 1 + for img_call in image_call_items: + yield f"data: {orjson.dumps({**base_event, 'type': 'response.output_item.added', 'output_index': current_idx, 'item': img_call.model_dump(mode='json')}).decode('utf-8')}\n\n" + yield f"data: {orjson.dumps({**base_event, 'type': 'response.output_item.done', 'output_index': current_idx, 'item': img_call.model_dump(mode='json')}).decode('utf-8')}\n\n" + current_idx += 1 + + p_tok, c_tok, t_tok = _calculate_usage(messages, assistant_text, detected_tool_calls) + usage = ResponseUsage(input_tokens=p_tok, output_tokens=c_tok, total_tokens=t_tok) + payload = _create_responses_standard_payload( + response_id, + created_time, + model_name, + assistant_text, + detected_tool_calls, + image_call_items, + response_contents, + usage, + request_data, + None, + ) + _persist_conversation( + db, + model.model_name, + client_wrapper.id, + session.metadata, + messages, + storage_output, + detected_tool_calls, + ) + yield f"data: {orjson.dumps({**base_event, 'type': 'response.completed', 'response': payload.model_dump(mode='json')}).decode('utf-8')}\n\n" yield "data: [DONE]\n\n" return StreamingResponse(generate_stream(), media_type="text/event-stream") -def _create_standard_response( - model_output: str, - tool_calls: list[dict], - completion_id: str, - created_time: int, - model: str, - messages: list[Message], -) -> dict: - """Create standard response""" - # Calculate token usage - prompt_tokens = sum(estimate_tokens(text_from_message(msg)) for msg in messages) - tool_args = "".join(call.get("function", {}).get("arguments", "") for call in tool_calls or []) - completion_tokens = estimate_tokens(model_output + tool_args) - total_tokens = prompt_tokens + completion_tokens - finish_reason = "tool_calls" if tool_calls else "stop" +# --- Main Router Endpoints --- - message_payload: dict = {"role": "assistant", "content": model_output or None} - if tool_calls: - message_payload["tool_calls"] = tool_calls - result = { - "id": completion_id, - "object": "chat.completion", - "created": created_time, - "model": model, - "choices": [ - { - "index": 0, - "message": message_payload, - "finish_reason": finish_reason, - } - ], - "usage": { - "prompt_tokens": prompt_tokens, - "completion_tokens": completion_tokens, - "total_tokens": total_tokens, - }, - } +@router.get("/v1/models", response_model=ModelListResponse) +async def list_models(api_key: str = Depends(verify_api_key)): + models = _get_available_models() + return ModelListResponse(data=models) + + +@router.post("/v1/chat/completions") +async def create_chat_completion( + request: ChatCompletionRequest, + api_key: str = Depends(verify_api_key), + tmp_dir: Path = Depends(get_temp_dir), + image_store: Path = Depends(get_image_store_dir), +): + pool, db = GeminiClientPool(), LMDBConversationStore() + try: + model = _get_model_by_name(request.model) + except ValueError as exc: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc + if not request.messages: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Messages required.") - logger.debug(f"Response created with {total_tokens} total tokens") - return result + structured_requirement = _build_structured_requirement(request.response_format) + extra_instr = [structured_requirement.instruction] if structured_requirement else None + # This ensures that server-injected system instructions are part of the history + msgs = _prepare_messages_for_model( + request.messages, request.tools, request.tool_choice, extra_instr + ) -async def _image_to_base64(image: Image, temp_dir: Path) -> tuple[str, int | None, int | None, str]: - """Persist an image provided by gemini_webapi and return base64 plus dimensions and filename.""" - if isinstance(image, GeneratedImage): + session, client, remain = await _find_reusable_session(db, pool, model, msgs) + + if session: + if not remain: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="No new messages.") + + # For reused sessions, we only need to process the remaining messages. + # We don't re-inject system defaults to avoid duplicating instructions already in history. + input_msgs = _prepare_messages_for_model( + remain, request.tools, request.tool_choice, extra_instr, False + ) + if len(input_msgs) == 1: + m_input, files = await GeminiClientWrapper.process_message( + input_msgs[0], tmp_dir, tagged=False + ) + else: + m_input, files = await GeminiClientWrapper.process_conversation(input_msgs, tmp_dir) + + logger.debug( + f"Reused session {reprlib.repr(session.metadata)} - sending {len(input_msgs)} prepared messages." + ) + else: try: - saved_path = await image.save(path=str(temp_dir), full_size=True) + client = await pool.acquire() + session = client.start_chat(model=model) + # Use the already prepared 'msgs' for a fresh session + m_input, files = await GeminiClientWrapper.process_conversation(msgs, tmp_dir) except Exception as e: - logger.warning( - f"Failed to download full-size GeneratedImage, retrying with default size: {e}" - ) - saved_path = await image.save(path=str(temp_dir), full_size=False) + logger.exception("Error in preparing conversation") + raise HTTPException(status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail=str(e)) + + completion_id = f"chatcmpl-{uuid.uuid4()}" + created_time = int(datetime.now(tz=timezone.utc).timestamp()) + + try: + assert session and client + logger.debug( + f"Client ID: {client.id}, Input length: {len(m_input)}, files count: {len(files)}" + ) + resp_or_stream = await _send_with_split( + session, m_input, files=files, stream=request.stream + ) + except Exception as e: + logger.exception("Gemini API error") + raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail=str(e)) + + if request.stream: + return _create_real_streaming_response( + resp_or_stream, + completion_id, + created_time, + request.model, + msgs, # Use prepared 'msgs' + db, + model, + client, + session, + structured_requirement, + ) + + try: + raw_with_t = GeminiClientWrapper.extract_output(resp_or_stream, include_thoughts=True) + raw_clean = GeminiClientWrapper.extract_output(resp_or_stream, include_thoughts=False) + except Exception as exc: + logger.exception("Gemini output parsing failed.") + raise HTTPException( + status_code=status.HTTP_502_BAD_GATEWAY, detail="Malformed response." + ) from exc + + visible_output, storage_output, tool_calls = _process_llm_output( + raw_with_t, raw_clean, structured_requirement + ) + + # Process images for OpenAI non-streaming flow + images = resp_or_stream.images or [] + image_markdown = "" + for image in images: + try: + _, _, _, filename = await _image_to_base64(image, image_store) + img_url = f"![{filename}](images/{filename}?token={get_image_token(filename)})" + image_markdown += f"\n\n{img_url}" + except Exception as exc: + logger.warning(f"Failed to process image in OpenAI response: {exc}") + + if image_markdown: + visible_output += image_markdown + storage_output += image_markdown + + tool_calls_payload = [call.model_dump(mode="json") for call in tool_calls] + if tool_calls_payload: + logger.debug(f"Detected tool calls: {reprlib.repr(tool_calls_payload)}") + + p_tok, c_tok, t_tok = _calculate_usage(request.messages, visible_output, tool_calls) + usage = {"prompt_tokens": p_tok, "completion_tokens": c_tok, "total_tokens": t_tok} + payload = _create_chat_completion_standard_payload( + completion_id, + created_time, + request.model, + visible_output, + tool_calls_payload, + "tool_calls" if tool_calls else "stop", + usage, + ) + _persist_conversation( + db, + model.model_name, + client.id, + session.metadata, + msgs, # Use prepared messages 'msgs' + storage_output, + tool_calls, + ) + return payload + + +@router.post("/v1/responses") +async def create_response( + request_data: ResponseCreateRequest, + request: Request, + api_key: str = Depends(verify_api_key), + tmp_dir: Path = Depends(get_temp_dir), + image_store: Path = Depends(get_image_store_dir), +): + base_messages, norm_input = _response_items_to_messages(request_data.input) + struct_req = _build_structured_requirement(request_data.response_format) + extra_instr = [struct_req.instruction] if struct_req else [] + + standard_tools, image_tools = [], [] + if request_data.tools: + for t in request_data.tools: + if isinstance(t, Tool): + standard_tools.append(t) + elif isinstance(t, ResponseImageTool): + image_tools.append(t) + elif isinstance(t, dict): + if t.get("type") == "function": + standard_tools.append(Tool.model_validate(t)) + elif t.get("type") == "image_generation": + image_tools.append(ResponseImageTool.model_validate(t)) + + img_instr = _build_image_generation_instruction( + image_tools, + request_data.tool_choice + if isinstance(request_data.tool_choice, ResponseToolChoice) + else None, + ) + if img_instr: + extra_instr.append(img_instr) + preface = _instructions_to_messages(request_data.instructions) + conv_messages = [*preface, *base_messages] if preface else base_messages + model_tool_choice = ( + request_data.tool_choice + if isinstance(request_data.tool_choice, (str, ToolChoiceFunction)) + else None + ) + + messages = _prepare_messages_for_model( + conv_messages, standard_tools or None, model_tool_choice, extra_instr or None + ) + pool, db = GeminiClientPool(), LMDBConversationStore() + try: + model = _get_model_by_name(request_data.model) + except ValueError as exc: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc + + session, client, remain = await _find_reusable_session(db, pool, model, messages) + if session: + msgs = _prepare_messages_for_model( + remain, request_data.tools, request_data.tool_choice, None, False + ) + if not msgs: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="No new messages.") + m_input, files = ( + await GeminiClientWrapper.process_message(msgs[0], tmp_dir, tagged=False) + if len(msgs) == 1 + else await GeminiClientWrapper.process_conversation(msgs, tmp_dir) + ) + logger.debug( + f"Reused session {reprlib.repr(session.metadata)} - sending {len(msgs)} prepared messages." + ) else: - saved_path = await image.save(path=str(temp_dir)) + try: + client = await pool.acquire() + session = client.start_chat(model=model) + m_input, files = await GeminiClientWrapper.process_conversation(messages, tmp_dir) + except Exception as e: + logger.exception("Error in preparing conversation") + raise HTTPException(status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail=str(e)) - if not saved_path: - raise ValueError("Failed to save generated image") + response_id = f"resp_{uuid.uuid4().hex}" + created_time = int(datetime.now(tz=timezone.utc).timestamp()) - # Rename file to a random UUID to ensure uniqueness and unpredictability - original_path = Path(saved_path) - random_name = f"img_{uuid.uuid4().hex}{original_path.suffix}" - new_path = temp_dir / random_name - original_path.rename(new_path) + try: + assert session and client + logger.debug( + f"Client ID: {client.id}, Input length: {len(m_input)}, files count: {len(files)}" + ) + resp_or_stream = await _send_with_split( + session, m_input, files=files, stream=request_data.stream + ) + except Exception as e: + logger.exception("Gemini API error") + raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail=str(e)) - data = new_path.read_bytes() - width, height = extract_image_dimensions(data) - filename = random_name - return base64.b64encode(data).decode("ascii"), width, height, filename + if request_data.stream: + return _create_responses_real_streaming_response( + resp_or_stream, + response_id, + created_time, + request_data.model, + messages, + db, + model, + client, + session, + request_data, + image_store, + str(request.base_url), + struct_req, + ) + + try: + raw_t = GeminiClientWrapper.extract_output(resp_or_stream, include_thoughts=True) + raw_c = GeminiClientWrapper.extract_output(resp_or_stream, include_thoughts=False) + except Exception as exc: + logger.exception("Gemini parsing failed") + raise HTTPException( + status_code=status.HTTP_502_BAD_GATEWAY, detail="Malformed response." + ) from exc + + assistant_text, storage_output, tool_calls = _process_llm_output(raw_t, raw_c, struct_req) + images = resp_or_stream.images or [] + if ( + request_data.tool_choice is not None and request_data.tool_choice.type == "image_generation" + ) and not images: + raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail="No images returned.") + + contents, img_calls = [], [] + for img in images: + try: + b64, w, h, fname = await _image_to_base64(img, image_store) + contents.append( + ResponseOutputContent( + type="output_text", + text=f"![{fname}]({request.base_url}images/{fname}?token={get_image_token(fname)})", + ) + ) + img_calls.append( + ResponseImageGenerationCall( + id=fname.rsplit(".", 1)[0], + result=b64, + output_format="png" if isinstance(img, GeneratedImage) else "jpeg", + size=f"{w}x{h}" if w and h else None, + ) + ) + except Exception as e: + logger.warning(f"Image error: {e}") + + if assistant_text: + contents.append(ResponseOutputContent(type="output_text", text=assistant_text)) + if not contents: + contents.append(ResponseOutputContent(type="output_text", text="")) + + # Aggregate images for storage + image_markdown = "" + for img_call in img_calls: + fname = f"{img_call.id}.{img_call.output_format}" + img_url = f"![{fname}](images/{fname}?token={get_image_token(fname)})" + image_markdown += f"\n\n{img_url}" + + if image_markdown: + storage_output += image_markdown + + p_tok, c_tok, t_tok = _calculate_usage(messages, assistant_text, tool_calls) + usage = ResponseUsage(input_tokens=p_tok, output_tokens=c_tok, total_tokens=t_tok) + payload = _create_responses_standard_payload( + response_id, + created_time, + request_data.model, + assistant_text, + tool_calls, + img_calls, + contents, + usage, + request_data, + norm_input, + ) + _persist_conversation( + db, model.model_name, client.id, session.metadata, messages, storage_output, tool_calls + ) + return payload diff --git a/app/services/client.py b/app/services/client.py index 55be11a..eda1691 100644 --- a/app/services/client.py +++ b/app/services/client.py @@ -78,7 +78,8 @@ async def process_message( message: Message, tempdir: Path | None = None, tagged: bool = True ) -> tuple[str, list[Path | str]]: """ - Process a single message and return model input. + Process a single Message object into a format suitable for the Gemini API. + Extracts text fragments, handles images and files, and appends tool call blocks if present. """ files: list[Path | str] = [] text_fragments: list[str] = [] @@ -88,8 +89,7 @@ async def process_message( if message.content: text_fragments.append(message.content) elif isinstance(message.content, list): - # Mixed content - # TODO: Use Pydantic to enforce the value checking + # Mixed content (text, image_url, or file) for item in message.content: if item.type == "text": # Append multiple text fragments @@ -177,7 +177,8 @@ async def process_conversation( @staticmethod def extract_output(response: ModelOutput, include_thoughts: bool = True) -> str: """ - Extract and format the output text from the Gemini response. + Extract and format the output text from a ModelOutput. + Includes reasoning thoughts (wrapped in tags) and unescapes content. """ text = "" @@ -191,6 +192,7 @@ def extract_output(response: ModelOutput, include_thoughts: bool = True) -> str: # Fix some escaped characters def _unescape_html(text_content: str) -> str: + """Unescape HTML entities only in non-code sections of the text.""" parts: list[str] = [] last_index = 0 for match in CODE_FENCE_RE.finditer(text_content): @@ -205,6 +207,7 @@ def _unescape_html(text_content: str) -> str: return "".join(parts) def _unescape_markdown(text_content: str) -> str: + """Remove backslash escapes for markdown characters in non-code sections.""" parts: list[str] = [] last_index = 0 for match in CODE_FENCE_RE.finditer(text_content): diff --git a/app/services/lmdb.py b/app/services/lmdb.py index c9d42cd..6ab2302 100644 --- a/app/services/lmdb.py +++ b/app/services/lmdb.py @@ -11,45 +11,98 @@ from ..models import ContentItem, ConversationInStore, Message from ..utils import g_config -from ..utils.helper import extract_tool_calls, remove_tool_call_blocks +from ..utils.helper import ( + CODE_BLOCK_HINT, + CODE_HINT_STRIPPED, + XML_HINT_STRIPPED, + XML_WRAP_HINT, + extract_tool_calls, + remove_tool_call_blocks, +) from ..utils.singleton import Singleton def _hash_message(message: Message) -> str: - """Generate a consistent hash for a single message focusing ONLY on logic/content, ignoring technical IDs.""" + """ + Generate a stable, canonical hash for a single message. + Strips system hints, thoughts, and tool call blocks to ensure + identical logical content produces the same hash regardless of format. + """ core_data = { "role": message.role, "name": message.name, + "tool_call_id": message.tool_call_id, } - # 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() + normalized = content.replace("\r\n", "\n") + + normalized = LMDBConversationStore.remove_think_tags(normalized) + + for hint in [ + XML_WRAP_HINT, + XML_HINT_STRIPPED, + CODE_BLOCK_HINT, + CODE_HINT_STRIPPED, + ]: + normalized = normalized.replace(hint, "") + + if message.tool_calls: + normalized = remove_tool_call_blocks(normalized) + else: + temp_text, _extracted = extract_tool_calls(normalized) + normalized = temp_text + + normalized = normalized.strip() core_data["content"] = normalized if normalized else None elif isinstance(content, list): text_parts = [] for item in content: + text_val = "" if isinstance(item, ContentItem) and item.type == "text": - text_parts.append(item.text or "") + text_val = item.text or "" elif isinstance(item, dict) and item.get("type") == "text": - text_parts.append(item.get("text") or "") + text_val = item.get("text") or "" + + if text_val: + text_val = text_val.replace("\r\n", "\n") + text_val = LMDBConversationStore.remove_think_tags(text_val) + for hint in [ + XML_WRAP_HINT, + XML_HINT_STRIPPED, + CODE_BLOCK_HINT, + CODE_HINT_STRIPPED, + ]: + text_val = text_val.replace(hint, "") + text_val = remove_tool_call_blocks(text_val).strip() + if text_val: + text_parts.append(text_val) + elif isinstance(item, ContentItem) and item.type in ("image_url", "file"): + # For non-text items, include their unique markers to distinguish them + if item.type == "image_url": + text_parts.append( + f"[image_url:{item.image_url.get('url') if item.image_url else ''}]" + ) + elif item.type == "file": + text_parts.append( + f"[file:{item.file.get('url') or item.file.get('filename') if item.file else ''}]" + ) 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"] + # Fallback for other dict-based content parts + part_type = item.get("type") if isinstance(item, dict) else None + if part_type == "image_url": + url = item.get("image_url", {}).get("url") + text_parts.append(f"[image_url:{url}]") + elif part_type == "file": + url = item.get("file", {}).get("url") or item.get("file", {}).get("filename") + text_parts.append(f"[file:{url}]") + + combined_text = "\n".join(text_parts).replace("\r\n", "\n").strip() + core_data["content"] = combined_text if combined_text else None - # Normalize tool_calls: Focus ONLY on function name and arguments if message.tool_calls: calls_data = [] for tc in message.tool_calls: @@ -66,14 +119,14 @@ def _hash_message(message: Message) -> str: "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() + digest = hashlib.sha256(message_bytes).hexdigest() + return digest def _hash_conversation(client_id: str, model: str, messages: List[Message]) -> str: @@ -123,16 +176,14 @@ def __init__( self._init_environment() def _ensure_db_path(self) -> None: - """Ensure database directory exists.""" self.db_path.parent.mkdir(parents=True, exist_ok=True) def _init_environment(self) -> None: - """Initialize LMDB environment.""" try: self._env = lmdb.open( str(self.db_path), map_size=self.max_db_size, - max_dbs=3, # main, metadata, and index databases + max_dbs=3, writemap=True, readahead=False, meminit=False, @@ -144,7 +195,6 @@ def _init_environment(self) -> None: @contextmanager def _get_transaction(self, write: bool = False): - """Get LMDB transaction context manager.""" if not self._env: raise RuntimeError("LMDB environment not initialized") @@ -178,11 +228,15 @@ def store( if not conv: raise ValueError("Messages list cannot be empty") + # Sanitize messages before computing hash and storing to ensure consistency + # with the search (find) logic, which also sanitizes its prefix. + sanitized_messages = self.sanitize_assistant_messages(conv.messages) + conv.messages = sanitized_messages + # Generate hash for the message list message_hash = _hash_conversation(conv.client_id, conv.model, conv.messages) storage_key = custom_key or message_hash - # Prepare data for storage now = datetime.now() if conv.created_at is None: conv.created_at = now @@ -192,20 +246,18 @@ def store( try: with self._get_transaction(write=True) as txn: - # Store main data txn.put(storage_key.encode("utf-8"), value, overwrite=True) - # Store hash -> key mapping for reverse lookup txn.put( f"{self.HASH_LOOKUP_PREFIX}{message_hash}".encode("utf-8"), storage_key.encode("utf-8"), ) - logger.debug(f"Stored {len(conv.messages)} messages with key: {storage_key}") + logger.debug(f"Stored {len(conv.messages)} messages with key: {storage_key[:12]}") return storage_key except Exception as e: - logger.error(f"Failed to store conversation: {e}") + logger.error(f"Failed to store messages with key {storage_key[:12]}: {e}") raise def get(self, key: str) -> Optional[ConversationInStore]: @@ -227,39 +279,35 @@ def get(self, key: str) -> Optional[ConversationInStore]: storage_data = orjson.loads(data) # type: ignore conv = ConversationInStore.model_validate(storage_data) - logger.debug(f"Retrieved {len(conv.messages)} messages for key: {key}") + logger.debug(f"Retrieved {len(conv.messages)} messages with key: {key[:12]}") return conv except Exception as e: - logger.error(f"Failed to retrieve messages for key {key}: {e}") + logger.error(f"Failed to retrieve messages with key {key[:12]}: {e}") return None def find(self, model: str, messages: List[Message]) -> Optional[ConversationInStore]: """ Search conversation data by message list. - - Args: - model: Model name of the conversations - messages: List of messages to search for - - Returns: - Conversation or None if not found """ if not messages: return None # --- Find with raw messages --- if conv := self._find_by_message_list(model, messages): - logger.debug("Found conversation with raw message history.") + logger.debug(f"Session found for '{model}' with {len(messages)} raw messages.") return conv # --- Find with cleaned messages --- cleaned_messages = self.sanitize_assistant_messages(messages) - if conv := self._find_by_message_list(model, cleaned_messages): - logger.debug("Found conversation with cleaned message history.") - return conv + if cleaned_messages != messages: + if conv := self._find_by_message_list(model, cleaned_messages): + logger.debug( + f"Session found for '{model}' with {len(cleaned_messages)} cleaned messages." + ) + return conv - logger.debug("No conversation found for either raw or cleaned history.") + logger.debug(f"No session found for '{model}' with {len(messages)} messages.") return None def _find_by_message_list( @@ -330,11 +378,11 @@ def delete(self, key: str) -> Optional[ConversationInStore]: if message_hash and key != message_hash: txn.delete(f"{self.HASH_LOOKUP_PREFIX}{message_hash}".encode("utf-8")) - logger.debug(f"Deleted messages with key: {key}") + logger.debug(f"Deleted messages with key: {key[:12]}") return conv except Exception as e: - logger.error(f"Failed to delete key {key}: {e}") + logger.error(f"Failed to delete messages with key {key[:12]}: {e}") return None def keys(self, prefix: str = "", limit: Optional[int] = None) -> List[str]: @@ -478,6 +526,8 @@ def remove_think_tags(text: str) -> str: """ Remove all ... tags and strip whitespace. """ + if not text: + return text # Remove all think blocks anywhere in the text cleaned_content = re.sub(r".*?", "", text, flags=re.DOTALL) return cleaned_content.strip() @@ -485,12 +535,8 @@ def remove_think_tags(text: str) -> str: @staticmethod def sanitize_assistant_messages(messages: list[Message]) -> list[Message]: """ - 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. + Produce a canonical history where assistant messages are cleaned of + internal markers and tool call blocks are moved to metadata. """ cleaned_messages = [] for msg in messages: @@ -503,12 +549,12 @@ def sanitize_assistant_messages(messages: list[Message]) -> list[Message]: else: text = remove_tool_call_blocks(text).strip() - normalized_content = text.strip() + normalized_content = text.strip() or None if normalized_content != msg.content or tool_calls != msg.tool_calls: cleaned_msg = msg.model_copy( update={ - "content": normalized_content or None, + "content": normalized_content, "tool_calls": tool_calls or None, } ) diff --git a/app/services/pool.py b/app/services/pool.py index a134dda..0f95203 100644 --- a/app/services/pool.py +++ b/app/services/pool.py @@ -31,7 +31,7 @@ def __init__(self) -> None: self._clients.append(client) self._id_map[c.id] = client self._round_robin.append(client) - self._restart_locks[c.id] = asyncio.Lock() # Pre-initialize + self._restart_locks[c.id] = asyncio.Lock() async def init(self) -> None: """Initialize all clients in the pool.""" @@ -84,7 +84,7 @@ async def _ensure_client_ready(self, client: GeminiClientWrapper) -> bool: lock = self._restart_locks.get(client.id) if lock is None: - return False # Should not happen + return False async with lock: if client.running(): diff --git a/app/utils/helper.py b/app/utils/helper.py index 190b5ce..7606dd3 100644 --- a/app/utils/helper.py +++ b/app/utils/helper.py @@ -5,7 +5,6 @@ import struct import tempfile from pathlib import Path -from typing import Iterator from urllib.parse import urlparse import httpx @@ -68,7 +67,6 @@ async def save_url_to_tempfile(url: str, tempdir: Path | None = None) -> Path: data: bytes | None = None suffix: str | None = None if url.startswith("data:image/"): - # Base64 encoded image metadata_part = url.split(",")[0] mime_type = metadata_part.split(":")[1].split(";")[0] @@ -131,13 +129,11 @@ def strip_tagged_blocks(text: str) -> str: result.append(text[idx:]) break - # append any content before this block result.append(text[idx:start]) role_start = start + len(start_marker) newline = text.find("\n", role_start) if newline == -1: - # malformed block; keep the remainder as-is (safe behavior) result.append(text[start:]) break @@ -145,23 +141,18 @@ def strip_tagged_blocks(text: str) -> str: end = text.find(end_marker, newline + 1) if end == -1: - # missing end marker if role == "tool": - # drop from the start marker to EOF (skip the remainder) break else: - # keep inner content from after the role newline to EOF result.append(text[newline + 1 :]) break block_end = end + len(end_marker) if role == "tool": - # drop the whole block idx = block_end continue - # keep the content without role markers content = text[newline + 1 : end] result.append(content) idx = block_end @@ -180,41 +171,19 @@ def strip_system_hints(text: str) -> str: return cleaned.strip() -def remove_tool_call_blocks(text: str) -> str: - """Strip tool call code blocks from text.""" - if not text: - return text - - # 1. Remove fenced blocks ONLY if they contain tool calls - def _replace_block(match: re.Match[str]) -> str: - block_content = match.group(1) - if not block_content: - return match.group(0) - - # Check if the block contains any tool call tag - if TOOL_CALL_RE.search(block_content): - return "" - - # Preserve the block if no tool call found - return match.group(0) - - cleaned = TOOL_BLOCK_RE.sub(_replace_block, text) - - # 2. Remove orphaned tool calls - cleaned = TOOL_CALL_RE.sub("", cleaned) - - return strip_system_hints(cleaned) - - -def extract_tool_calls(text: str) -> tuple[str, list[ToolCall]]: - """Extract tool call definitions and return cleaned text.""" +def _process_tools_internal(text: str, extract: bool = True) -> tuple[str, list[ToolCall]]: + """ + Unified engine for stripping tool call blocks and extracting tool metadata. + If extract=True, parses JSON arguments and assigns deterministic call IDs. + """ if not text: return text, [] tool_calls: list[ToolCall] = [] def _create_tool_call(name: str, raw_args: str) -> None: - """Helper to parse args and append to the tool_calls list.""" + if not extract: + return if not name: logger.warning("Encountered tool_call without a function name.") return @@ -226,8 +195,6 @@ def _create_tool_call(name: str, raw_args: str) -> None: 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]}" @@ -245,14 +212,14 @@ def _replace_block(match: re.Match[str]) -> str: if not block_content: return match.group(0) - found_in_block = False - for call_match in TOOL_CALL_RE.finditer(block_content): - found_in_block = True - name = (call_match.group(1) or "").strip() - raw_args = (call_match.group(2) or "").strip() - _create_tool_call(name, raw_args) + is_tool_block = bool(TOOL_CALL_RE.search(block_content)) - if found_in_block: + if is_tool_block: + if extract: + for call_match in TOOL_CALL_RE.finditer(block_content): + name = (call_match.group(1) or "").strip() + raw_args = (call_match.group(2) or "").strip() + _create_tool_call(name, raw_args) return "" else: return match.group(0) @@ -260,56 +227,26 @@ def _replace_block(match: re.Match[str]) -> str: cleaned = TOOL_BLOCK_RE.sub(_replace_block, text) def _replace_orphan(match: re.Match[str]) -> str: - name = (match.group(1) or "").strip() - raw_args = (match.group(2) or "").strip() - _create_tool_call(name, raw_args) + if extract: + name = (match.group(1) or "").strip() + raw_args = (match.group(2) or "").strip() + _create_tool_call(name, raw_args) return "" cleaned = TOOL_CALL_RE.sub(_replace_orphan, cleaned) - cleaned = strip_system_hints(cleaned) return cleaned, tool_calls -def iter_stream_segments(model_output: str, chunk_size: int = 64) -> Iterator[str]: - """Yield stream segments while keeping markers and words intact.""" - if not model_output: - return - - token_pattern = re.compile(r"\s+|\S+\s*") - pending = "" - - def _flush_pending() -> Iterator[str]: - nonlocal pending - if pending: - yield pending - pending = "" - - # Split on boundaries so the markers are never fragmented. - parts = re.split(r"()", model_output) - for part in parts: - if not part: - continue - if part in {"", ""}: - yield from _flush_pending() - yield part - continue - - for match in token_pattern.finditer(part): - token = match.group(0) - - if len(token) > chunk_size: - yield from _flush_pending() - for idx in range(0, len(token), chunk_size): - yield token[idx : idx + chunk_size] - continue - - if pending and len(pending) + len(token) > chunk_size: - yield from _flush_pending() +def remove_tool_call_blocks(text: str) -> str: + """Strip tool call code blocks from text.""" + cleaned, _ = _process_tools_internal(text, extract=False) + return cleaned - pending += token - yield from _flush_pending() +def extract_tool_calls(text: str) -> tuple[str, list[ToolCall]]: + """Extract tool call definitions and return cleaned text.""" + return _process_tools_internal(text, extract=True) def text_from_message(message: Message) -> str: From 52547a923276c5b5de3ba0394939478ac4166417 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C6=B0u=20Quang=20V=C5=A9?= Date: Fri, 30 Jan 2026 13:34:04 +0700 Subject: [PATCH 056/291] Enable real-time streaming responses and completely solve the issue with reusable sessions. - Ensure that PR https://github.com/HanaokaYuzu/Gemini-API/pull/220 is merged before proceeding with this PR. - Introducing a new feature for real-time streaming responses. - Fully resolve the problem with reusable sessions. - Break down similar flow logic into helper functions. - All endpoints now support inline Markdown images. - Switch large prompts to use BytesIO to avoid reading and writing to disk. --- app/server/chat.py | 70 +++++++++++++++++------------------------- app/services/client.py | 2 +- 2 files changed, 29 insertions(+), 43 deletions(-) diff --git a/app/server/chat.py b/app/server/chat.py index 37d3c70..ae1533e 100644 --- a/app/server/chat.py +++ b/app/server/chat.py @@ -8,7 +8,7 @@ from typing import Any, AsyncGenerator import orjson -from fastapi import APIRouter, Depends, HTTPException, Request, status +from fastapi import APIRouter, Depends, HTTPException, status from fastapi.responses import StreamingResponse from gemini_webapi import ModelOutput from gemini_webapi.client import ChatSession @@ -128,12 +128,11 @@ def _create_responses_standard_payload( response_id: str, created_time: int, model_name: str, - assistant_text: str | None, detected_tool_calls: list[Any] | None, image_call_items: list[ResponseImageGenerationCall], response_contents: list[ResponseOutputContent], usage: ResponseUsage, - request_data: ResponseCreateRequest, + request: ResponseCreateRequest, normalized_input: Any, ) -> ResponseCreateResponse: """Unified factory for building ResponseCreateResponse objects.""" @@ -166,9 +165,9 @@ def _create_responses_standard_payload( status="completed", usage=usage, input=normalized_input or None, - metadata=request_data.metadata or None, - tools=request_data.tools, - tool_choice=request_data.tool_choice, + metadata=request.metadata or None, + tools=request.tools, + tool_choice=request.tool_choice, ) @@ -1042,7 +1041,7 @@ async def generate_stream(): if image_markdown: assistant_text += image_markdown storage_output += image_markdown - # Send the image markdown as a final text chunk before usage + # Send the image Markdown as a final text chunk before usage data = { "id": completion_id, "object": "chat.completion.chunk", @@ -1107,9 +1106,8 @@ def _create_responses_real_streaming_response( model: Model, client_wrapper: GeminiClientWrapper, session: ChatSession, - request_data: ResponseCreateRequest, + request: ResponseCreateRequest, image_store: Path, - base_url: str, structured_requirement: StructuredOutputRequirement | None = None, ) -> StreamingResponse: """ @@ -1124,7 +1122,7 @@ def _create_responses_real_streaming_response( } async def generate_stream(): - yield f"data: {orjson.dumps({**base_event, 'type': 'response.created', 'response': {'id': response_id, 'object': 'response', 'created_at': created_time, 'model': model_name, 'status': 'in_progress', 'metadata': request_data.metadata, 'input': None, 'tools': request_data.tools, 'tool_choice': request_data.tool_choice}}).decode('utf-8')}\n\n" + yield f"data: {orjson.dumps({**base_event, 'type': 'response.created', 'response': {'id': response_id, 'object': 'response', 'created_at': created_time, 'model': model_name, 'status': 'in_progress', 'metadata': request.metadata, 'input': None, 'tools': request.tools, 'tool_choice': request.tool_choice}}).decode('utf-8')}\n\n" message_id = f"msg_{uuid.uuid4().hex}" yield f"data: {orjson.dumps({**base_event, 'type': 'response.output_item.added', 'output_index': 0, 'item': {'id': message_id, 'type': 'message', 'role': 'assistant', 'content': []}}).decode('utf-8')}\n\n" @@ -1183,9 +1181,7 @@ async def generate_stream(): try: image_base64, width, height, filename = await _image_to_base64(image, image_store) img_format = "png" if isinstance(image, GeneratedImage) else "jpeg" - image_url = ( - f"![{filename}]({base_url}images/{filename}?token={get_image_token(filename)})" - ) + image_url = f"![{filename}](images/{filename}?token={get_image_token(filename)})" image_call_items.append( ResponseImageGenerationCall( id=filename.rsplit(".", 1)[0], @@ -1232,12 +1228,11 @@ async def generate_stream(): response_id, created_time, model_name, - assistant_text, detected_tool_calls, image_call_items, response_contents, usage, - request_data, + request, None, ) _persist_conversation( @@ -1404,19 +1399,18 @@ async def create_chat_completion( @router.post("/v1/responses") async def create_response( - request_data: ResponseCreateRequest, - request: Request, + request: ResponseCreateRequest, api_key: str = Depends(verify_api_key), tmp_dir: Path = Depends(get_temp_dir), image_store: Path = Depends(get_image_store_dir), ): - base_messages, norm_input = _response_items_to_messages(request_data.input) - struct_req = _build_structured_requirement(request_data.response_format) + base_messages, norm_input = _response_items_to_messages(request.input) + struct_req = _build_structured_requirement(request.response_format) extra_instr = [struct_req.instruction] if struct_req else [] standard_tools, image_tools = [], [] - if request_data.tools: - for t in request_data.tools: + if request.tools: + for t in request.tools: if isinstance(t, Tool): standard_tools.append(t) elif isinstance(t, ResponseImageTool): @@ -1429,18 +1423,14 @@ async def create_response( img_instr = _build_image_generation_instruction( image_tools, - request_data.tool_choice - if isinstance(request_data.tool_choice, ResponseToolChoice) - else None, + request.tool_choice if isinstance(request.tool_choice, ResponseToolChoice) else None, ) if img_instr: extra_instr.append(img_instr) - preface = _instructions_to_messages(request_data.instructions) + preface = _instructions_to_messages(request.instructions) conv_messages = [*preface, *base_messages] if preface else base_messages model_tool_choice = ( - request_data.tool_choice - if isinstance(request_data.tool_choice, (str, ToolChoiceFunction)) - else None + request.tool_choice if isinstance(request.tool_choice, (str, ToolChoiceFunction)) else None ) messages = _prepare_messages_for_model( @@ -1448,15 +1438,13 @@ async def create_response( ) pool, db = GeminiClientPool(), LMDBConversationStore() try: - model = _get_model_by_name(request_data.model) + model = _get_model_by_name(request.model) except ValueError as exc: raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc session, client, remain = await _find_reusable_session(db, pool, model, messages) if session: - msgs = _prepare_messages_for_model( - remain, request_data.tools, request_data.tool_choice, None, False - ) + msgs = _prepare_messages_for_model(remain, request.tools, request.tool_choice, None, False) if not msgs: raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="No new messages.") m_input, files = ( @@ -1485,26 +1473,25 @@ async def create_response( f"Client ID: {client.id}, Input length: {len(m_input)}, files count: {len(files)}" ) resp_or_stream = await _send_with_split( - session, m_input, files=files, stream=request_data.stream + session, m_input, files=files, stream=request.stream ) except Exception as e: logger.exception("Gemini API error") raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail=str(e)) - if request_data.stream: + if request.stream: return _create_responses_real_streaming_response( resp_or_stream, response_id, created_time, - request_data.model, + request.model, messages, db, model, client, session, - request_data, + request, image_store, - str(request.base_url), struct_req, ) @@ -1520,7 +1507,7 @@ async def create_response( assistant_text, storage_output, tool_calls = _process_llm_output(raw_t, raw_c, struct_req) images = resp_or_stream.images or [] if ( - request_data.tool_choice is not None and request_data.tool_choice.type == "image_generation" + request.tool_choice is not None and request.tool_choice.type == "image_generation" ) and not images: raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail="No images returned.") @@ -1531,7 +1518,7 @@ async def create_response( contents.append( ResponseOutputContent( type="output_text", - text=f"![{fname}]({request.base_url}images/{fname}?token={get_image_token(fname)})", + text=f"![{fname}](images/{fname}?token={get_image_token(fname)})", ) ) img_calls.append( @@ -1565,13 +1552,12 @@ async def create_response( payload = _create_responses_standard_payload( response_id, created_time, - request_data.model, - assistant_text, + request.model, tool_calls, img_calls, contents, usage, - request_data, + request, norm_input, ) _persist_conversation( diff --git a/app/services/client.py b/app/services/client.py index eda1691..dd1d74f 100644 --- a/app/services/client.py +++ b/app/services/client.py @@ -207,7 +207,7 @@ def _unescape_html(text_content: str) -> str: return "".join(parts) def _unescape_markdown(text_content: str) -> str: - """Remove backslash escapes for markdown characters in non-code sections.""" + """Remove backslash escapes for Markdown characters in non-code sections.""" parts: list[str] = [] last_index = 0 for match in CODE_FENCE_RE.finditer(text_content): From c0b32c62113acdac21407c629252f35c2ed2bbf2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C6=B0u=20Quang=20V=C5=A9?= Date: Fri, 30 Jan 2026 17:50:02 +0700 Subject: [PATCH 057/291] Enable real-time streaming responses and completely solve the issue with reusable sessions. - Ensure that PR https://github.com/HanaokaYuzu/Gemini-API/pull/220 is merged before proceeding with this PR. - Introducing a new feature for real-time streaming responses. - Fully resolve the problem with reusable sessions. - Break down similar flow logic into helper functions. - All endpoints now support inline Markdown images. - Switch large prompts to use BytesIO to avoid reading and writing to disk. - Remove duplicate images when saving and responding. --- app/server/chat.py | 86 +++++++++++++++++++++++++++++++++++++--------- 1 file changed, 70 insertions(+), 16 deletions(-) diff --git a/app/server/chat.py b/app/server/chat.py index ae1533e..4c64390 100644 --- a/app/server/chat.py +++ b/app/server/chat.py @@ -1,4 +1,5 @@ import base64 +import hashlib import io import reprlib import uuid @@ -8,7 +9,7 @@ from typing import Any, AsyncGenerator import orjson -from fastapi import APIRouter, Depends, HTTPException, status +from fastapi import APIRouter, Depends, HTTPException, Request, status from fastapi.responses import StreamingResponse from gemini_webapi import ModelOutput from gemini_webapi.client import ChatSession @@ -72,8 +73,10 @@ class StructuredOutputRequirement: # --- Helper Functions --- -async def _image_to_base64(image: Image, temp_dir: Path) -> tuple[str, int | None, int | None, str]: - """Persist an image provided by gemini_webapi and return base64 plus dimensions and filename.""" +async def _image_to_base64( + image: Image, temp_dir: Path +) -> tuple[str, int | None, int | None, str, str]: + """Persist an image provided by gemini_webapi and return base64 plus dimensions, filename, and hash.""" if isinstance(image, GeneratedImage): try: saved_path = await image.save(path=str(temp_dir), full_size=True) @@ -96,7 +99,8 @@ async def _image_to_base64(image: Image, temp_dir: Path) -> tuple[str, int | Non data = new_path.read_bytes() width, height = extract_image_dimensions(data) filename = random_name - return base64.b64encode(data).decode("ascii"), width, height, filename + file_hash = hashlib.sha256(data).hexdigest() + return base64.b64encode(data).decode("ascii"), width, height, filename, file_hash def _calculate_usage( @@ -925,6 +929,7 @@ def _create_real_streaming_response( model: Model, client_wrapper: GeminiClientWrapper, session: ChatSession, + base_url: str, structured_requirement: StructuredOutputRequirement | None = None, ) -> StreamingResponse: """ @@ -1024,16 +1029,30 @@ async def generate_stream(): ) images = [] + seen_urls = set() for out in all_outputs: if out.images: - images.extend(out.images) + for img in out.images: + # Use the image URL as a stable identifier across chunks + if img.url not in seen_urls: + images.append(img) + seen_urls.add(img.url) image_markdown = "" + seen_hashes = set() for image in images: try: image_store = get_image_store_dir() - _, _, _, filename = await _image_to_base64(image, image_store) - img_url = f"![{filename}](images/{filename}?token={get_image_token(filename)})" + _, _, _, filename, file_hash = await _image_to_base64(image, image_store) + if file_hash in seen_hashes: + # Duplicate content, delete the file and skip + (image_store / filename).unlink(missing_ok=True) + continue + seen_hashes.add(file_hash) + + img_url = ( + f"![{filename}]({base_url}images/{filename}?token={get_image_token(filename)})" + ) image_markdown += f"\n\n{img_url}" except Exception as exc: logger.warning(f"Failed to process image in OpenAI stream: {exc}") @@ -1108,6 +1127,7 @@ def _create_responses_real_streaming_response( session: ChatSession, request: ResponseCreateRequest, image_store: Path, + base_url: str, structured_requirement: StructuredOutputRequirement | None = None, ) -> StreamingResponse: """ @@ -1172,16 +1192,30 @@ async def generate_stream(): ) images = [] + seen_urls = set() for out in all_outputs: if out.images: - images.extend(out.images) + for img in out.images: + if img.url not in seen_urls: + images.append(img) + seen_urls.add(img.url) response_contents, image_call_items = [], [] + seen_hashes = set() for image in images: try: - image_base64, width, height, filename = await _image_to_base64(image, image_store) + image_base64, width, height, filename, file_hash = await _image_to_base64( + image, image_store + ) + if file_hash in seen_hashes: + (image_store / filename).unlink(missing_ok=True) + continue + seen_hashes.add(file_hash) + img_format = "png" if isinstance(image, GeneratedImage) else "jpeg" - image_url = f"![{filename}](images/{filename}?token={get_image_token(filename)})" + image_url = ( + f"![{filename}]({base_url}images/{filename}?token={get_image_token(filename)})" + ) image_call_items.append( ResponseImageGenerationCall( id=filename.rsplit(".", 1)[0], @@ -1203,7 +1237,7 @@ async def generate_stream(): image_markdown = "" for img_call in image_call_items: fname = f"{img_call.id}.{img_call.output_format}" - img_url = f"![{fname}](images/{fname}?token={get_image_token(fname)})" + img_url = f"![{fname}]({base_url}images/{fname}?token={get_image_token(fname)})" image_markdown += f"\n\n{img_url}" if image_markdown: @@ -1262,10 +1296,12 @@ async def list_models(api_key: str = Depends(verify_api_key)): @router.post("/v1/chat/completions") async def create_chat_completion( request: ChatCompletionRequest, + raw_request: Request, api_key: str = Depends(verify_api_key), tmp_dir: Path = Depends(get_temp_dir), image_store: Path = Depends(get_image_store_dir), ): + base_url = str(raw_request.base_url) pool, db = GeminiClientPool(), LMDBConversationStore() try: model = _get_model_by_name(request.model) @@ -1339,6 +1375,7 @@ async def create_chat_completion( model, client, session, + base_url, structured_requirement, ) @@ -1358,10 +1395,18 @@ async def create_chat_completion( # Process images for OpenAI non-streaming flow images = resp_or_stream.images or [] image_markdown = "" + seen_hashes = set() for image in images: try: - _, _, _, filename = await _image_to_base64(image, image_store) - img_url = f"![{filename}](images/{filename}?token={get_image_token(filename)})" + _, _, _, filename, file_hash = await _image_to_base64(image, image_store) + if file_hash in seen_hashes: + (image_store / filename).unlink(missing_ok=True) + continue + seen_hashes.add(file_hash) + + img_url = ( + f"![{filename}]({base_url}images/{filename}?token={get_image_token(filename)})" + ) image_markdown += f"\n\n{img_url}" except Exception as exc: logger.warning(f"Failed to process image in OpenAI response: {exc}") @@ -1400,10 +1445,12 @@ async def create_chat_completion( @router.post("/v1/responses") async def create_response( request: ResponseCreateRequest, + raw_request: Request, api_key: str = Depends(verify_api_key), tmp_dir: Path = Depends(get_temp_dir), image_store: Path = Depends(get_image_store_dir), ): + base_url = str(raw_request.base_url) base_messages, norm_input = _response_items_to_messages(request.input) struct_req = _build_structured_requirement(request.response_format) extra_instr = [struct_req.instruction] if struct_req else [] @@ -1492,6 +1539,7 @@ async def create_response( session, request, image_store, + base_url, struct_req, ) @@ -1512,13 +1560,19 @@ async def create_response( raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail="No images returned.") contents, img_calls = [], [] + seen_hashes = set() for img in images: try: - b64, w, h, fname = await _image_to_base64(img, image_store) + b64, w, h, fname, fhash = await _image_to_base64(img, image_store) + if fhash in seen_hashes: + (image_store / fname).unlink(missing_ok=True) + continue + seen_hashes.add(fhash) + contents.append( ResponseOutputContent( type="output_text", - text=f"![{fname}](images/{fname}?token={get_image_token(fname)})", + text=f"![{fname}]({base_url}images/{fname}?token={get_image_token(fname)})", ) ) img_calls.append( @@ -1541,7 +1595,7 @@ async def create_response( image_markdown = "" for img_call in img_calls: fname = f"{img_call.id}.{img_call.output_format}" - img_url = f"![{fname}](images/{fname}?token={get_image_token(fname)})" + img_url = f"![{fname}]({base_url}images/{fname}?token={get_image_token(fname)})" image_markdown += f"\n\n{img_url}" if image_markdown: From 4d51a5fc8d19431712f2e13dd2d1a0395150e252 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C6=B0u=20Quang=20V=C5=A9?= Date: Mon, 2 Feb 2026 18:33:27 +0700 Subject: [PATCH 058/291] Enable real-time streaming responses and completely solve the issue with reusable sessions. - Ensure that PR https://github.com/HanaokaYuzu/Gemini-API/pull/220 is merged before proceeding with this PR. - Introducing a new feature for real-time streaming responses. - Fully resolve the problem with reusable sessions. - Break down similar flow logic into helper functions. - All endpoints now support inline Markdown images. - Switch large prompts to use BytesIO to avoid reading and writing to disk. - Remove duplicate images when saving and responding. --- app/server/chat.py | 11 +++++++++++ app/services/client.py | 22 ++++++++++++++++------ app/utils/helper.py | 6 +++--- 3 files changed, 30 insertions(+), 9 deletions(-) diff --git a/app/server/chat.py b/app/server/chat.py index 4c64390..b8f611d 100644 --- a/app/server/chat.py +++ b/app/server/chat.py @@ -481,6 +481,17 @@ def _prepare_messages_for_model( """Return a copy of messages enriched with tool instructions when needed.""" prepared = [msg.model_copy(deep=True) for msg in source_messages] + # Resolve tool names for 'tool' messages by looking back at previous assistant tool calls + tool_id_to_name = {} + for msg in prepared: + if msg.role == "assistant" and msg.tool_calls: + for tc in msg.tool_calls: + tool_id_to_name[tc.id] = tc.function.name + + for msg in prepared: + if msg.role == "tool" and not msg.name and msg.tool_call_id: + msg.name = tool_id_to_name.get(msg.tool_call_id) + instructions: list[str] = [] if inject_system_defaults: if tools: diff --git a/app/services/client.py b/app/services/client.py index dd1d74f..803bc23 100644 --- a/app/services/client.py +++ b/app/services/client.py @@ -86,15 +86,15 @@ async def process_message( if isinstance(message.content, str): # Pure text content - if message.content: - text_fragments.append(message.content) + if message.content or message.role == "tool": + text_fragments.append(message.content or "") elif isinstance(message.content, list): # Mixed content (text, image_url, or file) for item in message.content: if item.type == "text": # Append multiple text fragments - if item.text: - text_fragments.append(item.text) + if item.text or message.role == "tool": + text_fragments.append(item.text or "") elif item.type == "image_url": if not item.image_url: @@ -114,9 +114,19 @@ async def process_message( files.append(await save_url_to_tempfile(url, tempdir)) else: raise ValueError("File must contain 'file_data' or 'url' key") + elif message.content is None and message.role == "tool": + text_fragments.append("") elif message.content is not None: raise ValueError("Unsupported message content type.") + # Special handling for tool response format + if message.role == "tool": + tool_name = message.name or "unknown" + combined_content = "\n".join(text_fragments) + text_fragments = [ + f'```xml\n{combined_content}\n```' + ] + if message.tool_calls: tool_blocks: list[str] = [] for call in message.tool_calls: @@ -135,10 +145,10 @@ async def process_message( 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) + model_input = "\n".join(fragment for fragment in text_fragments if fragment is not None) # Add role tag if needed - if model_input: + if model_input or message.role == "tool": if tagged: model_input = add_tag(message.role, model_input) diff --git a/app/utils/helper.py b/app/utils/helper.py index 7606dd3..38b6400 100644 --- a/app/utils/helper.py +++ b/app/utils/helper.py @@ -110,9 +110,9 @@ def strip_code_fence(text: str) -> str: def strip_tagged_blocks(text: str) -> str: - """Remove <|im_start|>role ... <|im_end|> sections, dropping tool blocks entirely. - - tool blocks are removed entirely (if missing end marker, drop to EOF). - - other roles: remove markers and role, keep inner content (if missing end marker, keep to EOF). + """Remove <|im_start|>role ... <|im_end|> sections. + - tool blocks are removed entirely (including content). + - other roles: remove markers and role, keep inner content. """ if not text: return text From d69aaf02f2b6b7ff331564b526178a447c3b49e1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C6=B0u=20Quang=20V=C5=A9?= Date: Mon, 2 Feb 2026 18:52:51 +0700 Subject: [PATCH 059/291] Enable real-time streaming responses and completely solve the issue with reusable sessions. - Ensure that PR https://github.com/HanaokaYuzu/Gemini-API/pull/220 is merged before proceeding with this PR. - Introducing a new feature for real-time streaming responses. - Fully resolve the problem with reusable sessions. - Break down similar flow logic into helper functions. - All endpoints now support inline Markdown images. - Switch large prompts to use BytesIO to avoid reading and writing to disk. - Remove duplicate images when saving and responding. --- app/services/client.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/app/services/client.py b/app/services/client.py index 803bc23..4146b7e 100644 --- a/app/services/client.py +++ b/app/services/client.py @@ -122,7 +122,10 @@ async def process_message( # Special handling for tool response format if message.role == "tool": tool_name = message.name or "unknown" - combined_content = "\n".join(text_fragments) + combined_content = "\n".join(text_fragments).strip() + # If the tool result is literally empty, provide a clear indicator like empty JSON + if not combined_content: + combined_content = "{}" text_fragments = [ f'```xml\n{combined_content}\n```' ] From 8e15a8698d4a3df53a7bc3f676c63c0a492c9a01 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C6=B0u=20Quang=20V=C5=A9?= Date: Mon, 2 Feb 2026 19:27:08 +0700 Subject: [PATCH 060/291] Enable real-time streaming responses and completely solve the issue with reusable sessions. - Ensure that PR https://github.com/HanaokaYuzu/Gemini-API/pull/220 is merged before proceeding with this PR. - Introducing a new feature for real-time streaming responses. - Fully resolve the problem with reusable sessions. - Break down similar flow logic into helper functions. - All endpoints now support inline Markdown images. - Switch large prompts to use BytesIO to avoid reading and writing to disk. - Remove duplicate images when saving and responding. --- app/services/client.py | 43 +++++------------------------------------- 1 file changed, 5 insertions(+), 38 deletions(-) diff --git a/app/services/client.py b/app/services/client.py index 4146b7e..a35146f 100644 --- a/app/services/client.py +++ b/app/services/client.py @@ -85,17 +85,13 @@ async def process_message( text_fragments: list[str] = [] if isinstance(message.content, str): - # Pure text content if message.content or message.role == "tool": - text_fragments.append(message.content or "") + text_fragments.append(message.content or "{}") elif isinstance(message.content, list): - # Mixed content (text, image_url, or file) for item in message.content: if item.type == "text": - # Append multiple text fragments if item.text or message.role == "tool": - text_fragments.append(item.text or "") - + text_fragments.append(item.text or "{}") elif item.type == "image_url": if not item.image_url: raise ValueError("Image URL cannot be empty") @@ -103,7 +99,6 @@ async def process_message( files.append(await save_url_to_tempfile(url, tempdir)) else: raise ValueError("Image URL must contain 'url' key") - elif item.type == "file": if not item.file: raise ValueError("File cannot be empty") @@ -115,19 +110,15 @@ async def process_message( else: raise ValueError("File must contain 'file_data' or 'url' key") elif message.content is None and message.role == "tool": - text_fragments.append("") + text_fragments.append("{}") elif message.content is not None: raise ValueError("Unsupported message content type.") - # Special handling for tool response format if message.role == "tool": tool_name = message.name or "unknown" - combined_content = "\n".join(text_fragments).strip() - # If the tool result is literally empty, provide a clear indicator like empty JSON - if not combined_content: - combined_content = "{}" + combined_content = "\n".join(text_fragments).strip() or "{}" text_fragments = [ - f'```xml\n{combined_content}\n```' + f'{combined_content}' ] if message.tool_calls: @@ -138,7 +129,6 @@ async def process_message( 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( f'{args_text}' @@ -150,7 +140,6 @@ async def process_message( model_input = "\n".join(fragment for fragment in text_fragments if fragment is not None) - # Add role tag if needed if model_input or message.role == "tool": if tagged: model_input = add_tag(message.role, model_input) @@ -161,51 +150,30 @@ async def process_message( async def process_conversation( messages: list[Message], tempdir: Path | None = None ) -> tuple[str, list[Path | str]]: - """ - Process the entire conversation and return a formatted string and list of - files. The last message is assumed to be the assistant's response. - """ - # Determine once whether we need to wrap messages with role tags: only required - # if the history already contains assistant/system messages. When every message - # so far is from the user, we can skip tagging entirely. need_tag = any(m.role != "user" for m in messages) - conversation: list[str] = [] files: list[Path | str] = [] - for msg in messages: input_part, files_part = await GeminiClientWrapper.process_message( msg, tempdir, tagged=need_tag ) conversation.append(input_part) files.extend(files_part) - - # Append an opening assistant tag only when we used tags above so that Gemini - # knows where to start its reply. if need_tag: conversation.append(add_tag("assistant", "", unclose=True)) - return "\n".join(conversation), files @staticmethod def extract_output(response: ModelOutput, include_thoughts: bool = True) -> str: - """ - Extract and format the output text from a ModelOutput. - Includes reasoning thoughts (wrapped in tags) and unescapes content. - """ text = "" - if include_thoughts and response.thoughts: text += f"{response.thoughts}\n" - if response.text: text += response.text else: text += str(response) - # Fix some escaped characters def _unescape_html(text_content: str) -> str: - """Unescape HTML entities only in non-code sections of the text.""" parts: list[str] = [] last_index = 0 for match in CODE_FENCE_RE.finditer(text_content): @@ -220,7 +188,6 @@ def _unescape_html(text_content: str) -> str: return "".join(parts) def _unescape_markdown(text_content: str) -> str: - """Remove backslash escapes for Markdown characters in non-code sections.""" parts: list[str] = [] last_index = 0 for match in CODE_FENCE_RE.finditer(text_content): From 7716c62a8df23b6557841e5e4cdd571b025d5e4e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C6=B0u=20Quang=20V=C5=A9?= Date: Tue, 3 Feb 2026 09:33:18 +0700 Subject: [PATCH 061/291] build: update dependencies --- pyproject.toml | 4 +-- uv.lock | 82 ++++++++++++++++++++++++++++++++++++-------------- 2 files changed, 61 insertions(+), 25 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 1c30f8e..dc08571 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,10 +6,10 @@ readme = "README.md" requires-python = "==3.12.*" dependencies = [ "fastapi>=0.128.0", - "gemini-webapi>=1.17.3", + "gemini-webapi>=1.18.0", "lmdb>=1.7.5", "loguru>=0.7.3", - "orjson>=3.11.5", + "orjson>=3.11.7", "pydantic-settings[yaml]>=2.12.0", "uvicorn>=0.40.0", "uvloop>=0.22.1; sys_platform != 'win32'", diff --git a/uv.lock b/uv.lock index 50a73be..34a949c 100644 --- a/uv.lock +++ b/uv.lock @@ -106,10 +106,10 @@ dev = [ [package.metadata] requires-dist = [ { name = "fastapi", specifier = ">=0.128.0" }, - { name = "gemini-webapi", specifier = ">=1.17.3" }, + { name = "gemini-webapi", specifier = ">=1.18.0" }, { name = "lmdb", specifier = ">=1.7.5" }, { name = "loguru", specifier = ">=0.7.3" }, - { name = "orjson", specifier = ">=3.11.5" }, + { name = "orjson", specifier = ">=3.11.7" }, { name = "pydantic-settings", extras = ["yaml"], specifier = ">=2.12.0" }, { name = "ruff", marker = "extra == 'dev'", specifier = ">=0.14.14" }, { name = "uvicorn", specifier = ">=0.40.0" }, @@ -122,17 +122,17 @@ dev = [{ name = "ruff", specifier = ">=0.14.14" }] [[package]] name = "gemini-webapi" -version = "1.17.3" +version = "1.18.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "httpx" }, + { name = "httpx", extra = ["http2"] }, { name = "loguru" }, { name = "orjson" }, { name = "pydantic" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/aa/74/1a31f3605250eb5cbcbfb15559c43b0d71734c8d286cfa9a7833841306e3/gemini_webapi-1.17.3.tar.gz", hash = "sha256:6201f9eaf5f562c5dc589d71c0edbba9e2eb8f780febbcf35307697bf474d577", size = 259418, upload-time = "2025-12-05T22:38:44.426Z" } +sdist = { url = "https://files.pythonhosted.org/packages/c6/03/eb06536f287a8b7fb4808b00a60d9a9a3694f8a4079b77730325c639fbbe/gemini_webapi-1.18.0.tar.gz", hash = "sha256:0688a080fc3c95be55e723a66b2b69ec3ffcd58b07c50cf627d85d59d1181a86", size = 264630, upload-time = "2026-02-03T01:18:39.794Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/4c/a3/a88ff45197dce68a81d92c8d40368e4c26f67faf3af3273357f3f71f5c3d/gemini_webapi-1.17.3-py3-none-any.whl", hash = "sha256:d83969b1fa3236f3010d856d191b35264c936ece81f1be4c1de53ec1cf0855c8", size = 56659, upload-time = "2025-12-05T22:38:42.93Z" }, + { url = "https://files.pythonhosted.org/packages/40/33/85f520f56faddd68442c7efe7086ff5593b213bd8fc3768835dbe610fd9b/gemini_webapi-1.18.0-py3-none-any.whl", hash = "sha256:2fe25b5f8185aba1ca109e1280ef3eb79e5bd8a81fba16e01fbc4a177b72362c", size = 61523, upload-time = "2026-02-03T01:18:38.322Z" }, ] [[package]] @@ -144,6 +144,28 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, ] +[[package]] +name = "h2" +version = "4.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "hpack" }, + { name = "hyperframe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1d/17/afa56379f94ad0fe8defd37d6eb3f89a25404ffc71d4d848893d270325fc/h2-4.3.0.tar.gz", hash = "sha256:6c59efe4323fa18b47a632221a1888bd7fde6249819beda254aeca909f221bf1", size = 2152026, upload-time = "2025-08-23T18:12:19.778Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/69/b2/119f6e6dcbd96f9069ce9a2665e0146588dc9f88f29549711853645e736a/h2-4.3.0-py3-none-any.whl", hash = "sha256:c438f029a25f7945c69e0ccf0fb951dc3f73a5f6412981daee861431b70e2bdd", size = 61779, upload-time = "2025-08-23T18:12:17.779Z" }, +] + +[[package]] +name = "hpack" +version = "4.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2c/48/71de9ed269fdae9c8057e5a4c0aa7402e8bb16f2c6e90b3aa53327b113f8/hpack-4.1.0.tar.gz", hash = "sha256:ec5eca154f7056aa06f196a557655c5b009b382873ac8d1e66e79e87535f1dca", size = 51276, upload-time = "2025-01-22T21:44:58.347Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/07/c6/80c95b1b2b94682a72cbdbfb85b81ae2daffa4291fbfa1b1464502ede10d/hpack-4.1.0-py3-none-any.whl", hash = "sha256:157ac792668d995c657d93111f46b4535ed114f0c9c8d672271bbec7eae1b496", size = 34357, upload-time = "2025-01-22T21:44:56.92Z" }, +] + [[package]] name = "httpcore" version = "1.0.9" @@ -172,6 +194,20 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, ] +[package.optional-dependencies] +http2 = [ + { name = "h2" }, +] + +[[package]] +name = "hyperframe" +version = "6.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/02/e7/94f8232d4a74cc99514c13a9f995811485a6903d48e5d952771ef6322e30/hyperframe-6.1.0.tar.gz", hash = "sha256:f630908a00854a7adeabd6382b43923a4c4cd4b821fcb527e6ab9e15382a3b08", size = 26566, upload-time = "2025-01-22T21:41:49.302Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/48/30/47d0bf6072f7252e6521f3447ccfa40b421b6824517f82854703d0f5a98b/hyperframe-6.1.0-py3-none-any.whl", hash = "sha256:b03380493a519fce58ea5af42e4a42317bf9bd425596f7a0835ffce80f1a42e5", size = 13007, upload-time = "2025-01-22T21:41:47.295Z" }, +] + [[package]] name = "idna" version = "3.11" @@ -211,25 +247,25 @@ wheels = [ [[package]] name = "orjson" -version = "3.11.5" +version = "3.11.7" source = { registry = "https://pypi.org/simple" } -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" } +sdist = { url = "https://files.pythonhosted.org/packages/53/45/b268004f745ede84e5798b48ee12b05129d19235d0e15267aa57dcdb400b/orjson-3.11.7.tar.gz", hash = "sha256:9b1a67243945819ce55d24a30b59d6a168e86220452d2c96f4d1f093e71c0c49", size = 6144992, upload-time = "2026-02-02T15:38:49.29Z" } wheels = [ - { 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" }, + { url = "https://files.pythonhosted.org/packages/80/bf/76f4f1665f6983385938f0e2a5d7efa12a58171b8456c252f3bae8a4cf75/orjson-3.11.7-cp312-cp312-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:bd03ea7606833655048dab1a00734a2875e3e86c276e1d772b2a02556f0d895f", size = 228545, upload-time = "2026-02-02T15:37:46.376Z" }, + { url = "https://files.pythonhosted.org/packages/79/53/6c72c002cb13b5a978a068add59b25a8bdf2800ac1c9c8ecdb26d6d97064/orjson-3.11.7-cp312-cp312-macosx_15_0_arm64.whl", hash = "sha256:89e440ebc74ce8ab5c7bc4ce6757b4a6b1041becb127df818f6997b5c71aa60b", size = 125224, upload-time = "2026-02-02T15:37:47.697Z" }, + { url = "https://files.pythonhosted.org/packages/2c/83/10e48852865e5dd151bdfe652c06f7da484578ed02c5fca938e3632cb0b8/orjson-3.11.7-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5ede977b5fe5ac91b1dffc0a517ca4542d2ec8a6a4ff7b2652d94f640796342a", size = 128154, upload-time = "2026-02-02T15:37:48.954Z" }, + { url = "https://files.pythonhosted.org/packages/6e/52/a66e22a2b9abaa374b4a081d410edab6d1e30024707b87eab7c734afe28d/orjson-3.11.7-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b7b1dae39230a393df353827c855a5f176271c23434cfd2db74e0e424e693e10", size = 123548, upload-time = "2026-02-02T15:37:50.187Z" }, + { url = "https://files.pythonhosted.org/packages/de/38/605d371417021359f4910c496f764c48ceb8997605f8c25bf1dfe58c0ebe/orjson-3.11.7-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ed46f17096e28fb28d2975834836a639af7278aa87c84f68ab08fbe5b8bd75fa", size = 129000, upload-time = "2026-02-02T15:37:51.426Z" }, + { url = "https://files.pythonhosted.org/packages/44/98/af32e842b0ffd2335c89714d48ca4e3917b42f5d6ee5537832e069a4b3ac/orjson-3.11.7-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3726be79e36e526e3d9c1aceaadbfb4a04ee80a72ab47b3f3c17fefb9812e7b8", size = 141686, upload-time = "2026-02-02T15:37:52.607Z" }, + { url = "https://files.pythonhosted.org/packages/96/0b/fc793858dfa54be6feee940c1463370ece34b3c39c1ca0aa3845f5ba9892/orjson-3.11.7-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0724e265bc548af1dedebd9cb3d24b4e1c1e685a343be43e87ba922a5c5fff2f", size = 130812, upload-time = "2026-02-02T15:37:53.944Z" }, + { url = "https://files.pythonhosted.org/packages/dc/91/98a52415059db3f374757d0b7f0f16e3b5cd5976c90d1c2b56acaea039e6/orjson-3.11.7-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e7745312efa9e11c17fbd3cb3097262d079da26930ae9ae7ba28fb738367cbad", size = 133440, upload-time = "2026-02-02T15:37:55.615Z" }, + { url = "https://files.pythonhosted.org/packages/dc/b6/cb540117bda61791f46381f8c26c8f93e802892830a6055748d3bb1925ab/orjson-3.11.7-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:f904c24bdeabd4298f7a977ef14ca2a022ca921ed670b92ecd16ab6f3d01f867", size = 138386, upload-time = "2026-02-02T15:37:56.814Z" }, + { url = "https://files.pythonhosted.org/packages/63/1a/50a3201c334a7f17c231eee5f841342190723794e3b06293f26e7cf87d31/orjson-3.11.7-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:b9fc4d0f81f394689e0814617aadc4f2ea0e8025f38c226cbf22d3b5ddbf025d", size = 408853, upload-time = "2026-02-02T15:37:58.291Z" }, + { url = "https://files.pythonhosted.org/packages/87/cd/8de1c67d0be44fdc22701e5989c0d015a2adf391498ad42c4dc589cd3013/orjson-3.11.7-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:849e38203e5be40b776ed2718e587faf204d184fc9a008ae441f9442320c0cab", size = 144130, upload-time = "2026-02-02T15:38:00.163Z" }, + { url = "https://files.pythonhosted.org/packages/0f/fe/d605d700c35dd55f51710d159fc54516a280923cd1b7e47508982fbb387d/orjson-3.11.7-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:4682d1db3bcebd2b64757e0ddf9e87ae5f00d29d16c5cdf3a62f561d08cc3dd2", size = 134818, upload-time = "2026-02-02T15:38:01.507Z" }, + { url = "https://files.pythonhosted.org/packages/e4/e4/15ecc67edb3ddb3e2f46ae04475f2d294e8b60c1825fbe28a428b93b3fbd/orjson-3.11.7-cp312-cp312-win32.whl", hash = "sha256:f4f7c956b5215d949a1f65334cf9d7612dde38f20a95f2315deef167def91a6f", size = 127923, upload-time = "2026-02-02T15:38:02.75Z" }, + { url = "https://files.pythonhosted.org/packages/34/70/2e0855361f76198a3965273048c8e50a9695d88cd75811a5b46444895845/orjson-3.11.7-cp312-cp312-win_amd64.whl", hash = "sha256:bf742e149121dc5648ba0a08ea0871e87b660467ef168a3a5e53bc1fbd64bb74", size = 125007, upload-time = "2026-02-02T15:38:04.032Z" }, + { url = "https://files.pythonhosted.org/packages/68/40/c2051bd19fc467610fed469dc29e43ac65891571138f476834ca192bc290/orjson-3.11.7-cp312-cp312-win_arm64.whl", hash = "sha256:26c3b9132f783b7d7903bf1efb095fed8d4a3a85ec0d334ee8beff3d7a4749d5", size = 126089, upload-time = "2026-02-02T15:38:05.297Z" }, ] [[package]] From 61672cc46948a501a0f2af3761eb231e40ec6831 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C6=B0u=20Quang=20V=C5=A9?= Date: Tue, 3 Feb 2026 11:44:54 +0700 Subject: [PATCH 062/291] Refactor: Use `strip_system_hints` to standardize the content. --- app/services/client.py | 4 +++- app/services/lmdb.py | 22 +++------------------- 2 files changed, 6 insertions(+), 20 deletions(-) diff --git a/app/services/client.py b/app/services/client.py index a35146f..89ad3ba 100644 --- a/app/services/client.py +++ b/app/services/client.py @@ -127,7 +127,9 @@ async def process_message( args_text = call.function.arguments.strip() try: parsed_args = orjson.loads(args_text) - args_text = orjson.dumps(parsed_args).decode("utf-8") + args_text = orjson.dumps(parsed_args, option=orjson.OPT_SORT_KEYS).decode( + "utf-8" + ) except orjson.JSONDecodeError: pass tool_blocks.append( diff --git a/app/services/lmdb.py b/app/services/lmdb.py index 6ab2302..d5424e0 100644 --- a/app/services/lmdb.py +++ b/app/services/lmdb.py @@ -12,12 +12,9 @@ from ..models import ContentItem, ConversationInStore, Message from ..utils import g_config from ..utils.helper import ( - CODE_BLOCK_HINT, - CODE_HINT_STRIPPED, - XML_HINT_STRIPPED, - XML_WRAP_HINT, extract_tool_calls, remove_tool_call_blocks, + strip_system_hints, ) from ..utils.singleton import Singleton @@ -41,14 +38,7 @@ def _hash_message(message: Message) -> str: normalized = content.replace("\r\n", "\n") normalized = LMDBConversationStore.remove_think_tags(normalized) - - for hint in [ - XML_WRAP_HINT, - XML_HINT_STRIPPED, - CODE_BLOCK_HINT, - CODE_HINT_STRIPPED, - ]: - normalized = normalized.replace(hint, "") + normalized = strip_system_hints(normalized) if message.tool_calls: normalized = remove_tool_call_blocks(normalized) @@ -70,13 +60,7 @@ def _hash_message(message: Message) -> str: if text_val: text_val = text_val.replace("\r\n", "\n") text_val = LMDBConversationStore.remove_think_tags(text_val) - for hint in [ - XML_WRAP_HINT, - XML_HINT_STRIPPED, - CODE_BLOCK_HINT, - CODE_HINT_STRIPPED, - ]: - text_val = text_val.replace(hint, "") + text_val = strip_system_hints(text_val) text_val = remove_tool_call_blocks(text_val).strip() if text_val: text_parts.append(text_val) From cc0b13f40b0ed875ba9f1274101c7c9be49e8e52 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C6=B0u=20Quang=20V=C5=A9?= Date: Tue, 3 Feb 2026 19:04:01 +0700 Subject: [PATCH 063/291] Refactor: Only inject code block hint if NOT a structured response request --- app/server/chat.py | 32 +++++++++++++++++++++++++++----- 1 file changed, 27 insertions(+), 5 deletions(-) diff --git a/app/server/chat.py b/app/server/chat.py index b8f611d..608b52f 100644 --- a/app/server/chat.py +++ b/app/server/chat.py @@ -477,6 +477,7 @@ def _prepare_messages_for_model( tool_choice: str | ToolChoiceFunction | None, extra_instructions: list[str] | None = None, inject_system_defaults: bool = True, + is_structured: bool = False, ) -> list[Message]: """Return a copy of messages enriched with tool instructions when needed.""" prepared = [msg.model_copy(deep=True) for msg in source_messages] @@ -505,7 +506,8 @@ def _prepare_messages_for_model( f"Applied {len(extra_instructions)} extra instructions for tool/structured output." ) - if not _conversation_has_code_hint(prepared): + # Only inject code block hint if NOT a structured response request + if not is_structured and not _conversation_has_code_hint(prepared): instructions.append(CODE_BLOCK_HINT) logger.debug("Injected default code block hint for Gemini conversation.") @@ -1326,7 +1328,11 @@ async def create_chat_completion( # This ensures that server-injected system instructions are part of the history msgs = _prepare_messages_for_model( - request.messages, request.tools, request.tool_choice, extra_instr + request.messages, + request.tools, + request.tool_choice, + extra_instr, + is_structured=structured_requirement is not None, ) session, client, remain = await _find_reusable_session(db, pool, model, msgs) @@ -1338,7 +1344,12 @@ async def create_chat_completion( # For reused sessions, we only need to process the remaining messages. # We don't re-inject system defaults to avoid duplicating instructions already in history. input_msgs = _prepare_messages_for_model( - remain, request.tools, request.tool_choice, extra_instr, False + remain, + request.tools, + request.tool_choice, + extra_instr, + False, + is_structured=structured_requirement is not None, ) if len(input_msgs) == 1: m_input, files = await GeminiClientWrapper.process_message( @@ -1492,7 +1503,11 @@ async def create_response( ) messages = _prepare_messages_for_model( - conv_messages, standard_tools or None, model_tool_choice, extra_instr or None + conv_messages, + standard_tools or None, + model_tool_choice, + extra_instr or None, + is_structured=struct_req is not None, ) pool, db = GeminiClientPool(), LMDBConversationStore() try: @@ -1502,7 +1517,14 @@ async def create_response( session, client, remain = await _find_reusable_session(db, pool, model, messages) if session: - msgs = _prepare_messages_for_model(remain, request.tools, request.tool_choice, None, False) + msgs = _prepare_messages_for_model( + remain, + request.tools, + request.tool_choice, + None, + False, + is_structured=struct_req is not None, + ) if not msgs: raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="No new messages.") m_input, files = ( From 6b90e5d15d942c96ccfd272f9cb9ef23e4f7ac31 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C6=B0u=20Quang=20V=C5=A9?= Date: Tue, 3 Feb 2026 20:43:18 +0700 Subject: [PATCH 064/291] Refactor: Remove the code block hint entirely --- app/server/chat.py | 56 +++++---------------------------------------- app/utils/helper.py | 16 ------------- 2 files changed, 6 insertions(+), 66 deletions(-) diff --git a/app/server/chat.py b/app/server/chat.py index 608b52f..43f5e12 100644 --- a/app/server/chat.py +++ b/app/server/chat.py @@ -41,15 +41,12 @@ from ..services import GeminiClientPool, GeminiClientWrapper, LMDBConversationStore from ..utils import g_config from ..utils.helper import ( - CODE_BLOCK_HINT, - CODE_HINT_STRIPPED, - CONTROL_TOKEN_RE, XML_HINT_STRIPPED, XML_WRAP_HINT, estimate_tokens, extract_image_dimensions, extract_tool_calls, - strip_code_fence, + strip_system_hints, text_from_message, ) from .middleware import get_image_store_dir, get_image_token, get_temp_dir, verify_api_key @@ -225,10 +222,9 @@ def _process_llm_output( if structured_requirement: cleaned_for_json = LMDBConversationStore.remove_think_tags(visible_output) - json_text = strip_code_fence(cleaned_for_json or "") - if json_text: + if cleaned_for_json: try: - structured_payload = orjson.loads(json_text) + structured_payload = orjson.loads(cleaned_for_json) canonical_output = orjson.dumps(structured_payload).decode("utf-8") visible_output = canonical_output storage_output = canonical_output @@ -450,27 +446,6 @@ def _append_xml_hint_to_last_user_message(messages: list[Message]) -> None: return -def _conversation_has_code_hint(messages: list[Message]) -> bool: - """Return True if any system message already includes the code block hint.""" - for msg in messages: - if msg.role != "system" or msg.content is None: - continue - - if isinstance(msg.content, str): - if CODE_HINT_STRIPPED in msg.content: - return True - continue - - if isinstance(msg.content, list): - for part in msg.content: - if getattr(part, "type", None) != "text": - continue - if part.text and CODE_HINT_STRIPPED in part.text: - return True - - return False - - def _prepare_messages_for_model( source_messages: list[Message], tools: list[Tool] | None, @@ -506,11 +481,6 @@ def _prepare_messages_for_model( f"Applied {len(extra_instructions)} extra instructions for tool/structured output." ) - # Only inject code block hint if NOT a structured response request - if not is_structured and not _conversation_has_code_hint(prepared): - instructions.append(CODE_BLOCK_HINT) - logger.debug("Injected default code block hint for Gemini conversation.") - if not instructions: if tools and tool_choice != "none": _append_xml_hint_to_last_user_message(prepared) @@ -791,7 +761,7 @@ class StreamingOutputFilter: 2. ChatML tool blocks: <|im_start|>tool\n...<|im_end|> 3. ChatML role headers: <|im_start|>role\n (only suppresses the header, keeps content) 4. Control tokens: <|im_start|>, <|im_end|> - 5. System instructions/hints: XML_WRAP_HINT, CODE_BLOCK_HINT, etc. + 5. System instructions/hints. """ def __init__(self): @@ -805,12 +775,6 @@ def __init__(self): self.XML_END = "```" self.TAG_START = "<|im_start|>" self.TAG_END = "<|im_end|>" - self.SYSTEM_HINTS = [ - XML_WRAP_HINT, - XML_HINT_STRIPPED, - CODE_BLOCK_HINT, - CODE_HINT_STRIPPED, - ] def process(self, chunk: str) -> str: self.buffer += chunk @@ -906,11 +870,7 @@ def process(self, chunk: str) -> str: break # Final pass: filter out system hints from the text to be yielded - for hint in self.SYSTEM_HINTS: - if hint in to_yield: - to_yield = to_yield.replace(hint, "") - - return to_yield + return strip_system_hints(to_yield) def flush(self) -> str: # If we are stuck in a tool block or role header at the end, @@ -922,11 +882,7 @@ def flush(self) -> str: self.buffer = "" # Filter out any orphaned/partial control tokens or hints - final_text = CONTROL_TOKEN_RE.sub("", final_text) - for hint in self.SYSTEM_HINTS: - final_text = final_text.replace(hint, "") - - return final_text.strip() + return strip_system_hints(final_text) # --- Response Builders & Streaming --- diff --git a/app/utils/helper.py b/app/utils/helper.py index 38b6400..1281f9b 100644 --- a/app/utils/helper.py +++ b/app/utils/helper.py @@ -19,19 +19,12 @@ '```xml\n{"arg": "value"}\n```\n' "Do not surround the fence with any other text or whitespace; otherwise the call will be ignored.\n" ) -CODE_BLOCK_HINT = ( - "\nWhenever you include code, markup, or shell snippets, wrap each snippet in a Markdown fenced " - "block and supply the correct language label (for example, ```python ... ``` or ```html ... ```).\n" - "Fence ONLY the actual code/markup; keep all narrative or explanatory text outside the fences.\n" -) TOOL_BLOCK_RE = re.compile(r"```xml\s*(.*?)\s*```", re.DOTALL | re.IGNORECASE) TOOL_CALL_RE = re.compile( r"(.*?)", re.DOTALL | re.IGNORECASE ) -JSON_FENCE_RE = re.compile(r"^```(?:json)?\s*(.*?)\s*```$", re.DOTALL | re.IGNORECASE) CONTROL_TOKEN_RE = re.compile(r"<\|im_(?:start|end)\|>") XML_HINT_STRIPPED = XML_WRAP_HINT.strip() -CODE_HINT_STRIPPED = CODE_BLOCK_HINT.strip() def add_tag(role: str, content: str, unclose: bool = False) -> str: @@ -101,14 +94,6 @@ async def save_url_to_tempfile(url: str, tempdir: Path | None = None) -> Path: return path -def strip_code_fence(text: str) -> str: - """Remove surrounding ```json fences if present.""" - match = JSON_FENCE_RE.match(text.strip()) - if match: - return match.group(1).strip() - return text.strip() - - def strip_tagged_blocks(text: str) -> str: """Remove <|im_start|>role ... <|im_end|> sections. - tool blocks are removed entirely (including content). @@ -166,7 +151,6 @@ def strip_system_hints(text: str) -> str: return text cleaned = strip_tagged_blocks(text) cleaned = cleaned.replace(XML_WRAP_HINT, "").replace(XML_HINT_STRIPPED, "") - cleaned = cleaned.replace(CODE_BLOCK_HINT, "").replace(CODE_HINT_STRIPPED, "") cleaned = CONTROL_TOKEN_RE.sub("", cleaned) return cleaned.strip() From 553bd94b4631832694de40fe7246063ab359fc46 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C6=B0u=20Quang=20V=C5=A9?= Date: Tue, 3 Feb 2026 20:52:54 +0700 Subject: [PATCH 065/291] Refactor: Remove the code block hint entirely --- app/server/chat.py | 5 ----- 1 file changed, 5 deletions(-) diff --git a/app/server/chat.py b/app/server/chat.py index 43f5e12..0bb2722 100644 --- a/app/server/chat.py +++ b/app/server/chat.py @@ -452,7 +452,6 @@ def _prepare_messages_for_model( tool_choice: str | ToolChoiceFunction | None, extra_instructions: list[str] | None = None, inject_system_defaults: bool = True, - is_structured: bool = False, ) -> list[Message]: """Return a copy of messages enriched with tool instructions when needed.""" prepared = [msg.model_copy(deep=True) for msg in source_messages] @@ -1288,7 +1287,6 @@ async def create_chat_completion( request.tools, request.tool_choice, extra_instr, - is_structured=structured_requirement is not None, ) session, client, remain = await _find_reusable_session(db, pool, model, msgs) @@ -1305,7 +1303,6 @@ async def create_chat_completion( request.tool_choice, extra_instr, False, - is_structured=structured_requirement is not None, ) if len(input_msgs) == 1: m_input, files = await GeminiClientWrapper.process_message( @@ -1463,7 +1460,6 @@ async def create_response( standard_tools or None, model_tool_choice, extra_instr or None, - is_structured=struct_req is not None, ) pool, db = GeminiClientPool(), LMDBConversationStore() try: @@ -1479,7 +1475,6 @@ async def create_response( request.tool_choice, None, False, - is_structured=struct_req is not None, ) if not msgs: raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="No new messages.") From fd767dad79266486f14bd5610054220881bc9a73 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C6=B0u=20Quang=20V=C5=A9?= Date: Tue, 3 Feb 2026 22:40:38 +0700 Subject: [PATCH 066/291] Refactor: fix missing whitespace in the streaming response. --- app/utils/helper.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/utils/helper.py b/app/utils/helper.py index 1281f9b..a8b40aa 100644 --- a/app/utils/helper.py +++ b/app/utils/helper.py @@ -152,7 +152,7 @@ def strip_system_hints(text: str) -> str: cleaned = strip_tagged_blocks(text) cleaned = cleaned.replace(XML_WRAP_HINT, "").replace(XML_HINT_STRIPPED, "") cleaned = CONTROL_TOKEN_RE.sub("", cleaned) - return cleaned.strip() + return cleaned def _process_tools_internal(text: str, extract: bool = True) -> tuple[str, list[ToolCall]]: From 4beb33bb5a27368f0a14c769c213aea44103c100 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C6=B0u=20Quang=20V=C5=A9?= Date: Wed, 4 Feb 2026 07:24:13 +0700 Subject: [PATCH 067/291] Refactor: remove unnecessary code --- app/services/lmdb.py | 13 +------------ app/utils/helper.py | 2 +- 2 files changed, 2 insertions(+), 13 deletions(-) diff --git a/app/services/lmdb.py b/app/services/lmdb.py index d5424e0..8dc3722 100644 --- a/app/services/lmdb.py +++ b/app/services/lmdb.py @@ -14,7 +14,6 @@ from ..utils.helper import ( extract_tool_calls, remove_tool_call_blocks, - strip_system_hints, ) from ..utils.singleton import Singleton @@ -36,17 +35,8 @@ def _hash_message(message: Message) -> str: core_data["content"] = None elif isinstance(content, str): normalized = content.replace("\r\n", "\n") - normalized = LMDBConversationStore.remove_think_tags(normalized) - normalized = strip_system_hints(normalized) - - if message.tool_calls: - normalized = remove_tool_call_blocks(normalized) - else: - temp_text, _extracted = extract_tool_calls(normalized) - normalized = temp_text - - normalized = normalized.strip() + normalized = remove_tool_call_blocks(normalized).strip() core_data["content"] = normalized if normalized else None elif isinstance(content, list): text_parts = [] @@ -60,7 +50,6 @@ def _hash_message(message: Message) -> str: if text_val: text_val = text_val.replace("\r\n", "\n") text_val = LMDBConversationStore.remove_think_tags(text_val) - text_val = strip_system_hints(text_val) text_val = remove_tool_call_blocks(text_val).strip() if text_val: text_parts.append(text_val) diff --git a/app/utils/helper.py b/app/utils/helper.py index a8b40aa..b6bb5cb 100644 --- a/app/utils/helper.py +++ b/app/utils/helper.py @@ -16,7 +16,7 @@ VALID_TAG_ROLES = {"user", "assistant", "system", "tool"} XML_WRAP_HINT = ( "\nYou MUST wrap every tool call response inside a single fenced block exactly like:\n" - '```xml\n{"arg": "value"}\n```\n' + '```xml\n{"argument": "value"}\n```\n' "Do not surround the fence with any other text or whitespace; otherwise the call will be ignored.\n" ) TOOL_BLOCK_RE = re.compile(r"```xml\s*(.*?)\s*```", re.DOTALL | re.IGNORECASE) From 6b8dd4e5b893e689521efe93261825e95f1a1a84 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C6=B0u=20Quang=20V=C5=A9?= Date: Wed, 4 Feb 2026 09:35:21 +0700 Subject: [PATCH 068/291] Refactor: Update `StreamingOutputFilter` logic to improve handling of streaming responses --- app/server/chat.py | 187 ++++++++++++++++++++++++-------------------- app/utils/helper.py | 16 +++- 2 files changed, 115 insertions(+), 88 deletions(-) diff --git a/app/server/chat.py b/app/server/chat.py index 0bb2722..87f29a6 100644 --- a/app/server/chat.py +++ b/app/server/chat.py @@ -41,6 +41,8 @@ from ..services import GeminiClientPool, GeminiClientWrapper, LMDBConversationStore from ..utils import g_config from ..utils.helper import ( + XML_HINT_LINE_END, + XML_HINT_LINE_START, XML_HINT_STRIPPED, XML_WRAP_HINT, estimate_tokens, @@ -755,133 +757,146 @@ async def _send_with_split( class StreamingOutputFilter: """ - Enhanced streaming filter that suppresses: - 1. XML tool call blocks: ```xml ... ``` - 2. ChatML tool blocks: <|im_start|>tool\n...<|im_end|> - 3. ChatML role headers: <|im_start|>role\n (only suppresses the header, keeps content) - 4. Control tokens: <|im_start|>, <|im_end|> - 5. System instructions/hints. + Simplified State Machine filter to suppress technical markers, tool calls, and system hints. + States: NORMAL, IN_XML, IN_TAG, IN_BLOCK, IN_HINT """ def __init__(self): self.buffer = "" - self.in_xml_tool = False - self.in_tagged_block = False - self.in_role_header = False + self.state = "NORMAL" self.current_role = "" + self.block_buffer = "" self.XML_START = "```xml" self.XML_END = "```" self.TAG_START = "<|im_start|>" self.TAG_END = "<|im_end|>" + self.HINT_START = f"\n{XML_HINT_LINE_START}" if XML_HINT_LINE_START else "" + self.HINT_END = XML_HINT_LINE_END + + self.WATCH_PREFIXES = [self.XML_START, self.TAG_START, self.TAG_END] + if self.HINT_START: + self.WATCH_PREFIXES.append(self.HINT_START) def process(self, chunk: str) -> str: self.buffer += chunk - to_yield = "" + output = [] while self.buffer: - if self.in_xml_tool: + if self.state == "NORMAL": + xml_idx = self.buffer.find(self.XML_START) + tag_idx = self.buffer.find(self.TAG_START) + end_idx = self.buffer.find(self.TAG_END) + hint_idx = self.buffer.find(self.HINT_START) + + indices = [ + (i, t) + for i, t in [ + (xml_idx, "XML"), + (tag_idx, "TAG"), + (end_idx, "END"), + (hint_idx, "HINT"), + ] + if i != -1 + ] + + if not indices: + keep_len = 0 + for p in self.WATCH_PREFIXES: + for i in range(len(p) - 1, 0, -1): + if self.buffer.endswith(p[:i]): + keep_len = max(keep_len, i) + break + + yield_len = len(self.buffer) - keep_len + if yield_len > 0: + output.append(self.buffer[:yield_len]) + self.buffer = self.buffer[yield_len:] + break + + indices.sort() + idx, m_type = indices[0] + output.append(self.buffer[:idx]) + self.buffer = self.buffer[idx:] + + if m_type == "XML": + self.state = "IN_XML" + self.block_buffer = "" + self.buffer = self.buffer[len(self.XML_START) :] + elif m_type == "TAG": + self.state = "IN_TAG" + self.buffer = self.buffer[len(self.TAG_START) :] + elif m_type == "END": + self.buffer = self.buffer[len(self.TAG_END) :] + elif m_type == "HINT": + self.state = "IN_HINT" + self.buffer = self.buffer[len(self.HINT_START) :] + + elif self.state == "IN_HINT": + end_idx = self.buffer.find(self.HINT_END) + if end_idx != -1: + self.buffer = self.buffer[end_idx + len(self.HINT_END) :] + self.state = "NORMAL" + else: + self.buffer = "" + break + + elif self.state == "IN_XML": end_idx = self.buffer.find(self.XML_END) if end_idx != -1: + content = self.block_buffer + self.buffer[:end_idx] + if " 0: - to_yield += self.buffer[:yield_len] + output.append(self.buffer[:yield_len]) self.buffer = self.buffer[yield_len:] - break - else: - # Outside any special block. Look for starts. - earliest_idx = -1 - match_type = "" - - xml_idx = self.buffer.find(self.XML_START) - if xml_idx != -1: - earliest_idx = xml_idx - match_type = "xml" - - tag_s_idx = self.buffer.find(self.TAG_START) - if tag_s_idx != -1: - if earliest_idx == -1 or tag_s_idx < earliest_idx: - earliest_idx = tag_s_idx - match_type = "tag_start" - - tag_e_idx = self.buffer.find(self.TAG_END) - if tag_e_idx != -1: - if earliest_idx == -1 or tag_e_idx < earliest_idx: - earliest_idx = tag_e_idx - match_type = "tag_end" - - if earliest_idx != -1: - # Yield text before the match - to_yield += self.buffer[:earliest_idx] - self.buffer = self.buffer[earliest_idx:] - - if match_type == "xml": - self.in_xml_tool = True - self.buffer = self.buffer[len(self.XML_START) :] - elif match_type == "tag_start": - self.in_role_header = True - self.buffer = self.buffer[len(self.TAG_START) :] - elif match_type == "tag_end": - # Orphaned end tag, just skip it - self.buffer = self.buffer[len(self.TAG_END) :] - continue - else: - # Check for prefixes - prefixes = [self.XML_START, self.TAG_START, self.TAG_END] - max_keep = 0 - for p in prefixes: - for i in range(len(p) - 1, 0, -1): - if self.buffer.endswith(p[:i]): - max_keep = max(max_keep, i) - break - - yield_len = len(self.buffer) - max_keep - if yield_len > 0: - to_yield += self.buffer[:yield_len] - self.buffer = self.buffer[yield_len:] + else: + self.buffer = "" break - # Final pass: filter out system hints from the text to be yielded - return strip_system_hints(to_yield) + return "".join(output) def flush(self) -> str: - # If we are stuck in a tool block or role header at the end, - # it usually means malformed output. - if self.in_xml_tool or (self.in_tagged_block and self.current_role == "tool"): - return "" + res = "" + if self.state == "IN_XML": + if "") XML_HINT_STRIPPED = XML_WRAP_HINT.strip() +_hint_lines = [line.strip() for line in XML_WRAP_HINT.split("\n") if line.strip()] +XML_HINT_LINE_START = _hint_lines[0] if _hint_lines else "" +XML_HINT_LINE_END = _hint_lines[-1] if _hint_lines else "" def add_tag(role: str, content: str, unclose: bool = False) -> str: @@ -149,8 +152,17 @@ def strip_system_hints(text: str) -> str: """Remove system-level hint text from a given string.""" if not text: return text - cleaned = strip_tagged_blocks(text) - cleaned = cleaned.replace(XML_WRAP_HINT, "").replace(XML_HINT_STRIPPED, "") + + # Remove the full hints first + cleaned = text.replace(XML_WRAP_HINT, "").replace(XML_HINT_STRIPPED, "") + + # Remove fragments using derived constants + if XML_HINT_LINE_START: + cleaned = re.sub(rf"\n?{re.escape(XML_HINT_LINE_START)}:?\s*", "", cleaned) + if XML_HINT_LINE_END: + cleaned = re.sub(rf"\s*{re.escape(XML_HINT_LINE_END)}\.?\n?", "", cleaned) + + cleaned = strip_tagged_blocks(cleaned) cleaned = CONTROL_TOKEN_RE.sub("", cleaned) return cleaned From d86ae59e5f1037bdbc79c9a84624116f23f6a302 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C6=B0u=20Quang=20V=C5=A9?= Date: Wed, 4 Feb 2026 20:17:32 +0700 Subject: [PATCH 069/291] Refactor: Adjust function call format to prevent streaming issues Caused by Gemini Web's post-processing mechanism. --- app/server/chat.py | 62 +++++++++++++++++++++++------------------- app/services/client.py | 16 +++++------ app/utils/helper.py | 12 ++++---- 3 files changed, 47 insertions(+), 43 deletions(-) diff --git a/app/server/chat.py b/app/server/chat.py index 87f29a6..ed0d731 100644 --- a/app/server/chat.py +++ b/app/server/chat.py @@ -367,19 +367,17 @@ def _build_tool_prompt( ) lines.append( - "When you decide to call a tool you MUST respond with nothing except a single fenced block exactly like the template below." + "When you decide to call a tool you MUST respond with nothing except a single [function_calls] block exactly like the template below." ) + lines.append("Do not add text before or after it.") + lines.append("[function_calls]") + lines.append('[call:tool_name]{"argument": "value"}[/call]') + lines.append("[/function_calls]") lines.append( - "The fenced block MUST use ```xml as the opening fence and ``` as the closing fence. Do not add text before or after it." + "Use double quotes for JSON keys and values. If you omit the block or include any extra text, the system will assume you are NOT calling a tool and your request will fail." ) - lines.append("```xml") - lines.append('{"argument": "value"}') - lines.append("```") lines.append( - "Use double quotes for JSON keys and values. If you omit the fenced block or include any extra text, the system will assume you are NOT calling a tool and your request will fail." - ) - lines.append( - "If multiple tool calls are required, include multiple entries inside the same fenced block. Without a tool call, reply normally and do NOT emit any ```xml fence." + "If multiple tool calls are required, include multiple [call:...]...[/call] entries inside the same [function_calls] block. Without a tool call, reply normally and do NOT emit any [function_calls] tag." ) return "\n".join(lines) @@ -757,8 +755,8 @@ async def _send_with_split( class StreamingOutputFilter: """ - Simplified State Machine filter to suppress technical markers, tool calls, and system hints. - States: NORMAL, IN_XML, IN_TAG, IN_BLOCK, IN_HINT + State Machine filter to suppress technical markers, tool calls, and system hints. + Handles fragmentation where markers are split across multiple chunks. """ def __init__(self): @@ -767,12 +765,13 @@ def __init__(self): self.current_role = "" self.block_buffer = "" - self.XML_START = "```xml" - self.XML_END = "```" + self.XML_START = "[function_calls]" + self.XML_END = "[/function_calls]" self.TAG_START = "<|im_start|>" self.TAG_END = "<|im_end|>" self.HINT_START = f"\n{XML_HINT_LINE_START}" if XML_HINT_LINE_START else "" self.HINT_END = XML_HINT_LINE_END + self.TOOL_START = "[call:" self.WATCH_PREFIXES = [self.XML_START, self.TAG_START, self.TAG_END] if self.HINT_START: @@ -787,7 +786,7 @@ def process(self, chunk: str) -> str: xml_idx = self.buffer.find(self.XML_START) tag_idx = self.buffer.find(self.TAG_START) end_idx = self.buffer.find(self.TAG_END) - hint_idx = self.buffer.find(self.HINT_START) + hint_idx = self.buffer.find(self.HINT_START) if self.HINT_START else -1 indices = [ (i, t) @@ -801,13 +800,13 @@ def process(self, chunk: str) -> str: ] if not indices: + # Guard against split start markers keep_len = 0 for p in self.WATCH_PREFIXES: for i in range(len(p) - 1, 0, -1): if self.buffer.endswith(p[:i]): keep_len = max(keep_len, i) break - yield_len = len(self.buffer) - keep_len if yield_len > 0: output.append(self.buffer[:yield_len]) @@ -838,20 +837,24 @@ def process(self, chunk: str) -> str: self.buffer = self.buffer[end_idx + len(self.HINT_END) :] self.state = "NORMAL" else: - self.buffer = "" + # Keep end of buffer to avoid missing split HINT_END + keep_len = len(self.HINT_END) - 1 + if len(self.buffer) > keep_len: + self.buffer = self.buffer[-keep_len:] break elif self.state == "IN_XML": end_idx = self.buffer.find(self.XML_END) if end_idx != -1: - content = self.block_buffer + self.buffer[:end_idx] - if " keep_len: + self.block_buffer += self.buffer[:-keep_len] + self.buffer = self.buffer[-keep_len:] break elif self.state == "IN_TAG": @@ -873,21 +876,24 @@ def process(self, chunk: str) -> str: self.state = "NORMAL" self.current_role = "" else: + # Yield safe part and keep potential split TAG_END + keep_len = len(self.TAG_END) - 1 if self.current_role != "tool": - yield_len = len(self.buffer) - (len(self.TAG_END) - 1) - if yield_len > 0: - output.append(self.buffer[:yield_len]) - self.buffer = self.buffer[yield_len:] + if len(self.buffer) > keep_len: + output.append(self.buffer[:-keep_len]) + self.buffer = self.buffer[-keep_len:] + break else: - self.buffer = "" - break + if len(self.buffer) > keep_len: + self.buffer = self.buffer[-keep_len:] + break return "".join(output) def flush(self) -> str: res = "" if self.state == "IN_XML": - if "])") +ESC_SYMBOLS_RE = re.compile(r"\\(?=[\\\[\]{}()<>`*_#~+.:!&^$|-])") CODE_FENCE_RE = re.compile(r"(```.*?```|`[^`\n]+?`)", re.DOTALL) FILE_PATH_PATTERN = re.compile( r"^(?=.*[./\\]|.*:\d+|^(?:Dockerfile|Makefile|Jenkinsfile|Procfile|Rakefile|Gemfile|Vagrantfile|Caddyfile|Justfile|LICENSE|README|CONTRIBUTING|CODEOWNERS|AUTHORS|NOTICE|CHANGELOG)$)([a-zA-Z0-9_./\\-]+(?::\d+)?)$", @@ -132,12 +132,10 @@ async def process_message( ) except orjson.JSONDecodeError: pass - tool_blocks.append( - f'{args_text}' - ) + tool_blocks.append(f"[call:{call.function.name}]{args_text}[/call]") if tool_blocks: - tool_section = "```xml\n" + "".join(tool_blocks) + "\n```" + tool_section = "[function_calls]\n" + "".join(tool_blocks) + "\n[/function_calls]" text_fragments.append(tool_section) model_input = "\n".join(fragment for fragment in text_fragments if fragment is not None) @@ -189,22 +187,22 @@ def _unescape_html(text_content: str) -> str: parts.append(HTML_ESCAPE_RE.sub(lambda m: html.unescape(m.group(0)), tail)) return "".join(parts) - def _unescape_markdown(text_content: str) -> str: + def _unescape_symbols(text_content: str) -> str: parts: list[str] = [] last_index = 0 for match in CODE_FENCE_RE.finditer(text_content): non_code = text_content[last_index : match.start()] if non_code: - parts.append(MARKDOWN_ESCAPE_RE.sub("", non_code)) + parts.append(ESC_SYMBOLS_RE.sub("", non_code)) parts.append(match.group(0)) last_index = match.end() tail = text_content[last_index:] if tail: - parts.append(MARKDOWN_ESCAPE_RE.sub("", tail)) + parts.append(ESC_SYMBOLS_RE.sub("", tail)) return "".join(parts) text = _unescape_html(text) - text = _unescape_markdown(text) + text = _unescape_symbols(text) def extract_file_path_from_display_text(text_content: str) -> str | None: match = re.match(FILE_PATH_PATTERN, text_content) diff --git a/app/utils/helper.py b/app/utils/helper.py index 78494a3..5ca812c 100644 --- a/app/utils/helper.py +++ b/app/utils/helper.py @@ -15,14 +15,14 @@ VALID_TAG_ROLES = {"user", "assistant", "system", "tool"} XML_WRAP_HINT = ( - "\nYou MUST wrap every tool call response inside a single fenced block exactly like:\n" - '```xml\n{"argument": "value"}\n```\n' - "Do not surround the fence with any other text or whitespace; otherwise the call will be ignored.\n" + "\nYou MUST wrap every tool call response inside a single [function_calls] block exactly like:\n" + '[function_calls]\n[call:tool_name]{"argument": "value"}[/call]\n[/function_calls]\n' + "Do not surround the block with any other text or whitespace; otherwise the call will be ignored.\n" ) -TOOL_BLOCK_RE = re.compile(r"```xml\s*(.*?)\s*```", re.DOTALL | re.IGNORECASE) -TOOL_CALL_RE = re.compile( - r"(.*?)", re.DOTALL | re.IGNORECASE +TOOL_BLOCK_RE = re.compile( + r"\[function_calls]\s*(.*?)\s*\[/function_calls]", re.DOTALL | re.IGNORECASE ) +TOOL_CALL_RE = re.compile(r"\[call:([^]]+)]\s*(.*?)\s*\[/call]", re.DOTALL | re.IGNORECASE) CONTROL_TOKEN_RE = re.compile(r"<\|im_(?:start|end)\|>") XML_HINT_STRIPPED = XML_WRAP_HINT.strip() _hint_lines = [line.strip() for line in XML_WRAP_HINT.split("\n") if line.strip()] From db39ad10637754ca4f109614e499e34827429b0f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C6=B0u=20Quang=20V=C5=A9?= Date: Wed, 4 Feb 2026 20:27:40 +0700 Subject: [PATCH 070/291] Refactor: Adjust function call format to prevent streaming issues Caused by Gemini Web's post-processing mechanism. --- app/server/chat.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/app/server/chat.py b/app/server/chat.py index ed0d731..15f59aa 100644 --- a/app/server/chat.py +++ b/app/server/chat.py @@ -377,7 +377,10 @@ def _build_tool_prompt( "Use double quotes for JSON keys and values. If you omit the block or include any extra text, the system will assume you are NOT calling a tool and your request will fail." ) lines.append( - "If multiple tool calls are required, include multiple [call:...]...[/call] entries inside the same [function_calls] block. Without a tool call, reply normally and do NOT emit any [function_calls] tag." + "To call multiple tools, list each [call:tool_name]...[/call] entry sequentially within a single [function_calls] block." + ) + lines.append( + "If no tool call is needed, provide a normal response and DO NOT use the [function_calls] tag." ) return "\n".join(lines) From 556a638fc1d34fc377a593fc2e98acd4d9a0ea6b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C6=B0u=20Quang=20V=C5=A9?= Date: Wed, 4 Feb 2026 20:52:44 +0700 Subject: [PATCH 071/291] Refactor: Adjust function call format to prevent streaming issues Caused by Gemini Web's post-processing mechanism. --- app/services/client.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/app/services/client.py b/app/services/client.py index bc6c297..21814e5 100644 --- a/app/services/client.py +++ b/app/services/client.py @@ -117,9 +117,7 @@ async def process_message( if message.role == "tool": tool_name = message.name or "unknown" combined_content = "\n".join(text_fragments).strip() or "{}" - text_fragments = [ - f'{combined_content}' - ] + text_fragments = [f"[response:{tool_name}]{combined_content}[/response]"] if message.tool_calls: tool_blocks: list[str] = [] From d5fec7a04119ca6b668a4e98d9c6463f11a92eb5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C6=B0u=20Quang=20V=C5=A9?= Date: Thu, 5 Feb 2026 07:12:34 +0700 Subject: [PATCH 072/291] Refactor: Adjust function call format to prevent streaming issues Caused by Gemini Web's post-processing mechanism. --- app/server/chat.py | 67 +++++++++++++++++++++++---------------------- app/utils/helper.py | 34 ++++++++++++++--------- 2 files changed, 55 insertions(+), 46 deletions(-) diff --git a/app/server/chat.py b/app/server/chat.py index 15f59aa..e56c926 100644 --- a/app/server/chat.py +++ b/app/server/chat.py @@ -41,10 +41,10 @@ from ..services import GeminiClientPool, GeminiClientWrapper, LMDBConversationStore from ..utils import g_config from ..utils.helper import ( - XML_HINT_LINE_END, - XML_HINT_LINE_START, - XML_HINT_STRIPPED, - XML_WRAP_HINT, + TOOL_HINT_LINE_END, + TOOL_HINT_LINE_START, + TOOL_HINT_STRIPPED, + TOOL_WRAP_HINT, estimate_tokens, extract_image_dimensions, extract_tool_calls, @@ -423,15 +423,15 @@ def _build_image_generation_instruction( return "\n\n".join(instructions) -def _append_xml_hint_to_last_user_message(messages: list[Message]) -> None: - """Ensure the last user message carries the XML wrap hint.""" +def _append_tool_hint_to_last_user_message(messages: list[Message]) -> None: + """Ensure the last user message carries the tool wrap hint.""" for msg in reversed(messages): if msg.role != "user" or msg.content is None: continue if isinstance(msg.content, str): - if XML_HINT_STRIPPED not in msg.content: - msg.content = f"{msg.content}\n{XML_WRAP_HINT}" + if TOOL_HINT_STRIPPED not in msg.content: + msg.content = f"{msg.content}\n{TOOL_WRAP_HINT}" return if isinstance(msg.content, list): @@ -439,12 +439,12 @@ def _append_xml_hint_to_last_user_message(messages: list[Message]) -> None: if getattr(part, "type", None) != "text": continue text_value = part.text or "" - if XML_HINT_STRIPPED in text_value: + if TOOL_HINT_STRIPPED in text_value: return - part.text = f"{text_value}\n{XML_WRAP_HINT}" + part.text = f"{text_value}\n{TOOL_WRAP_HINT}" return - messages_text = XML_WRAP_HINT.strip() + messages_text = TOOL_WRAP_HINT.strip() msg.content.append(ContentItem(type="text", text=messages_text)) return @@ -485,19 +485,20 @@ def _prepare_messages_for_model( if not instructions: if tools and tool_choice != "none": - _append_xml_hint_to_last_user_message(prepared) + _append_tool_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 "" - prepared[0].content = f"{existing}{separator}{combined_instructions}" + if combined_instructions not in existing: + separator = "\n\n" if existing else "" + prepared[0].content = f"{existing}{separator}{combined_instructions}" else: prepared.insert(0, Message(role="system", content=combined_instructions)) if tools and tool_choice != "none": - _append_xml_hint_to_last_user_message(prepared) + _append_tool_hint_to_last_user_message(prepared) return prepared @@ -768,15 +769,15 @@ def __init__(self): self.current_role = "" self.block_buffer = "" - self.XML_START = "[function_calls]" - self.XML_END = "[/function_calls]" + self.TOOL_START = "[function_calls]" + self.TOOL_END = "[/function_calls]" self.TAG_START = "<|im_start|>" self.TAG_END = "<|im_end|>" - self.HINT_START = f"\n{XML_HINT_LINE_START}" if XML_HINT_LINE_START else "" - self.HINT_END = XML_HINT_LINE_END - self.TOOL_START = "[call:" + self.HINT_START = f"\n{TOOL_HINT_LINE_START}" if TOOL_HINT_LINE_START else "" + self.HINT_END = TOOL_HINT_LINE_END + self.TOOL_PREFIX = "[call:" - self.WATCH_PREFIXES = [self.XML_START, self.TAG_START, self.TAG_END] + self.WATCH_PREFIXES = [self.TOOL_START, self.TAG_START, self.TAG_END] if self.HINT_START: self.WATCH_PREFIXES.append(self.HINT_START) @@ -786,7 +787,7 @@ def process(self, chunk: str) -> str: while self.buffer: if self.state == "NORMAL": - xml_idx = self.buffer.find(self.XML_START) + tool_idx = self.buffer.find(self.TOOL_START) tag_idx = self.buffer.find(self.TAG_START) end_idx = self.buffer.find(self.TAG_END) hint_idx = self.buffer.find(self.HINT_START) if self.HINT_START else -1 @@ -794,7 +795,7 @@ def process(self, chunk: str) -> str: indices = [ (i, t) for i, t in [ - (xml_idx, "XML"), + (tool_idx, "TOOL"), (tag_idx, "TAG"), (end_idx, "END"), (hint_idx, "HINT"), @@ -821,10 +822,10 @@ def process(self, chunk: str) -> str: output.append(self.buffer[:idx]) self.buffer = self.buffer[idx:] - if m_type == "XML": - self.state = "IN_XML" + if m_type == "TOOL": + self.state = "IN_TOOL" self.block_buffer = "" - self.buffer = self.buffer[len(self.XML_START) :] + self.buffer = self.buffer[len(self.TOOL_START) :] elif m_type == "TAG": self.state = "IN_TAG" self.buffer = self.buffer[len(self.TAG_START) :] @@ -846,15 +847,15 @@ def process(self, chunk: str) -> str: self.buffer = self.buffer[-keep_len:] break - elif self.state == "IN_XML": - end_idx = self.buffer.find(self.XML_END) + elif self.state == "IN_TOOL": + end_idx = self.buffer.find(self.TOOL_END) if end_idx != -1: self.block_buffer += self.buffer[:end_idx] - self.buffer = self.buffer[end_idx + len(self.XML_END) :] + self.buffer = self.buffer[end_idx + len(self.TOOL_END) :] self.state = "NORMAL" else: # Accumulate and keep potential split end marker - keep_len = len(self.XML_END) - 1 + keep_len = len(self.TOOL_END) - 1 if len(self.buffer) > keep_len: self.block_buffer += self.buffer[:-keep_len] self.buffer = self.buffer[-keep_len:] @@ -895,9 +896,9 @@ def process(self, chunk: str) -> str: def flush(self) -> str: res = "" - if self.state == "IN_XML": - if self.TOOL_START not in self.block_buffer.lower(): - res = f"{self.XML_START}{self.block_buffer}" + if self.state == "IN_TOOL": + if self.TOOL_PREFIX not in self.block_buffer.lower(): + res = f"{self.TOOL_START}{self.block_buffer}" elif self.state == "IN_BLOCK" and self.current_role != "tool": res = self.buffer elif self.state == "NORMAL": diff --git a/app/utils/helper.py b/app/utils/helper.py index 5ca812c..99c3d84 100644 --- a/app/utils/helper.py +++ b/app/utils/helper.py @@ -14,7 +14,7 @@ from ..models import FunctionCall, Message, ToolCall VALID_TAG_ROLES = {"user", "assistant", "system", "tool"} -XML_WRAP_HINT = ( +TOOL_WRAP_HINT = ( "\nYou MUST wrap every tool call response inside a single [function_calls] block exactly like:\n" '[function_calls]\n[call:tool_name]{"argument": "value"}[/call]\n[/function_calls]\n' "Do not surround the block with any other text or whitespace; otherwise the call will be ignored.\n" @@ -24,10 +24,10 @@ ) TOOL_CALL_RE = re.compile(r"\[call:([^]]+)]\s*(.*?)\s*\[/call]", re.DOTALL | re.IGNORECASE) CONTROL_TOKEN_RE = re.compile(r"<\|im_(?:start|end)\|>") -XML_HINT_STRIPPED = XML_WRAP_HINT.strip() -_hint_lines = [line.strip() for line in XML_WRAP_HINT.split("\n") if line.strip()] -XML_HINT_LINE_START = _hint_lines[0] if _hint_lines else "" -XML_HINT_LINE_END = _hint_lines[-1] if _hint_lines else "" +TOOL_HINT_STRIPPED = TOOL_WRAP_HINT.strip() +_hint_lines = [line.strip() for line in TOOL_WRAP_HINT.split("\n") if line.strip()] +TOOL_HINT_LINE_START = _hint_lines[0] if _hint_lines else "" +TOOL_HINT_LINE_END = _hint_lines[-1] if _hint_lines else "" def add_tag(role: str, content: str, unclose: bool = False) -> str: @@ -154,13 +154,18 @@ def strip_system_hints(text: str) -> str: return text # Remove the full hints first - cleaned = text.replace(XML_WRAP_HINT, "").replace(XML_HINT_STRIPPED, "") + cleaned = text.replace(TOOL_WRAP_HINT, "").replace(TOOL_HINT_STRIPPED, "") - # Remove fragments using derived constants - if XML_HINT_LINE_START: - cleaned = re.sub(rf"\n?{re.escape(XML_HINT_LINE_START)}:?\s*", "", cleaned) - if XML_HINT_LINE_END: - cleaned = re.sub(rf"\s*{re.escape(XML_HINT_LINE_END)}\.?\n?", "", cleaned) + # Remove fragments or multi-line blocks using derived constants + if TOOL_HINT_LINE_START and TOOL_HINT_LINE_END: + # Match from the start line to the end line, inclusive, handling internal modifications + pattern = rf"\n?{re.escape(TOOL_HINT_LINE_START)}.*?{re.escape(TOOL_HINT_LINE_END)}\.?\n?" + cleaned = re.sub(pattern, "", cleaned, flags=re.DOTALL) + + if TOOL_HINT_LINE_START: + cleaned = re.sub(rf"\n?{re.escape(TOOL_HINT_LINE_START)}:?\s*", "", cleaned) + if TOOL_HINT_LINE_END: + cleaned = re.sub(rf"\s*{re.escape(TOOL_HINT_LINE_END)}\.?\n?", "", cleaned) cleaned = strip_tagged_blocks(cleaned) cleaned = CONTROL_TOKEN_RE.sub("", cleaned) @@ -175,6 +180,9 @@ def _process_tools_internal(text: str, extract: bool = True) -> tuple[str, list[ if not text: return text, [] + # Clean hints FIRST so they don't interfere with tool call regexes (e.g. example calls in hint) + cleaned = strip_system_hints(text) + tool_calls: list[ToolCall] = [] def _create_tool_call(name: str, raw_args: str) -> None: @@ -220,7 +228,7 @@ def _replace_block(match: re.Match[str]) -> str: else: return match.group(0) - cleaned = TOOL_BLOCK_RE.sub(_replace_block, text) + cleaned = TOOL_BLOCK_RE.sub(_replace_block, cleaned) def _replace_orphan(match: re.Match[str]) -> str: if extract: @@ -230,7 +238,7 @@ def _replace_orphan(match: re.Match[str]) -> str: return "" cleaned = TOOL_CALL_RE.sub(_replace_orphan, cleaned) - cleaned = strip_system_hints(cleaned) + return cleaned, tool_calls From dbc553d7dbcb3949e5ee807b58b360a488672b6c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C6=B0u=20Quang=20V=C5=A9?= Date: Thu, 5 Feb 2026 08:23:08 +0700 Subject: [PATCH 073/291] Refactor: Enhance prompt to prevent issues with parsing tool call arguments --- app/server/chat.py | 4 ++-- app/utils/helper.py | 20 ++++++++++++++++++-- 2 files changed, 20 insertions(+), 4 deletions(-) diff --git a/app/server/chat.py b/app/server/chat.py index e56c926..f47471c 100644 --- a/app/server/chat.py +++ b/app/server/chat.py @@ -369,12 +369,12 @@ def _build_tool_prompt( lines.append( "When you decide to call a tool you MUST respond with nothing except a single [function_calls] block exactly like the template below." ) - lines.append("Do not add text before or after it.") + lines.append("Do not add text before or after the block.") lines.append("[function_calls]") lines.append('[call:tool_name]{"argument": "value"}[/call]') lines.append("[/function_calls]") lines.append( - "Use double quotes for JSON keys and values. If you omit the block or include any extra text, the system will assume you are NOT calling a tool and your request will fail." + "Use double quotes for JSON keys and values. CRITICAL: The content inside [call:...]...[/call] MUST be a raw JSON object. Do not wrap it in ```json blocks or add any conversational text inside the tag." ) lines.append( "To call multiple tools, list each [call:tool_name]...[/call] entry sequentially within a single [function_calls] block." diff --git a/app/utils/helper.py b/app/utils/helper.py index 99c3d84..65c49f0 100644 --- a/app/utils/helper.py +++ b/app/utils/helper.py @@ -2,6 +2,7 @@ import hashlib import mimetypes import re +import reprlib import struct import tempfile from pathlib import Path @@ -17,7 +18,7 @@ TOOL_WRAP_HINT = ( "\nYou MUST wrap every tool call response inside a single [function_calls] block exactly like:\n" '[function_calls]\n[call:tool_name]{"argument": "value"}[/call]\n[/function_calls]\n' - "Do not surround the block with any other text or whitespace; otherwise the call will be ignored.\n" + "IMPORTANT: Arguments MUST be a valid JSON object. Do not include markdown code blocks (```json) or any conversational text inside the [call] tag.\n" ) TOOL_BLOCK_RE = re.compile( r"\[function_calls]\s*(.*?)\s*\[/function_calls]", re.DOTALL | re.IGNORECASE @@ -197,7 +198,22 @@ def _create_tool_call(name: str, raw_args: str) -> None: 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.") + json_match = re.search(r"({.*})", raw_args, re.DOTALL) + if json_match: + try: + potential_json = json_match.group(1) + parsed_args = orjson.loads(potential_json) + arguments = orjson.dumps(parsed_args, option=orjson.OPT_SORT_KEYS).decode( + "utf-8" + ) + except orjson.JSONDecodeError: + logger.warning( + f"Failed to parse extracted JSON arguments for '{name}': {reprlib.repr(json_match)}" + ) + else: + logger.warning( + f"Failed to parse tool call arguments for '{name}'. Passing raw string: {reprlib.repr(raw_args)}" + ) index = len(tool_calls) seed = f"{name}:{arguments}:{index}".encode("utf-8") From ca721cfcaad261a1cbcc401dd8090cdd6b8613aa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C6=B0u=20Quang=20V=C5=A9?= Date: Thu, 5 Feb 2026 08:32:57 +0700 Subject: [PATCH 074/291] Refactor: Enhance prompt to prevent issues with parsing tool call arguments --- app/utils/helper.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/utils/helper.py b/app/utils/helper.py index 65c49f0..230622b 100644 --- a/app/utils/helper.py +++ b/app/utils/helper.py @@ -200,15 +200,15 @@ def _create_tool_call(name: str, raw_args: str) -> None: except orjson.JSONDecodeError: json_match = re.search(r"({.*})", raw_args, re.DOTALL) if json_match: + potential_json = json_match.group(1) try: - potential_json = json_match.group(1) parsed_args = orjson.loads(potential_json) arguments = orjson.dumps(parsed_args, option=orjson.OPT_SORT_KEYS).decode( "utf-8" ) except orjson.JSONDecodeError: logger.warning( - f"Failed to parse extracted JSON arguments for '{name}': {reprlib.repr(json_match)}" + f"Failed to parse extracted JSON arguments for '{name}': {reprlib.repr(potential_json)}" ) else: logger.warning( From 263158e3825b765139e98cce8fa555054782b951 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C6=B0u=20Quang=20V=C5=A9?= Date: Fri, 6 Feb 2026 20:55:39 +0700 Subject: [PATCH 075/291] Refactor: enhance system prompts --- app/server/chat.py | 37 ++++++++++++++----------------------- app/utils/helper.py | 10 +++++++--- 2 files changed, 21 insertions(+), 26 deletions(-) diff --git a/app/server/chat.py b/app/server/chat.py index f47471c..06aefc2 100644 --- a/app/server/chat.py +++ b/app/server/chat.py @@ -336,7 +336,7 @@ def _build_tool_prompt( return "" lines: list[str] = [ - "You can invoke the following developer tools. Call a tool only when it is required and follow the JSON schema exactly when providing arguments." + "SYSTEM INTERFACE: You have access to the following technical tools. You MUST invoke them when necessary to fulfill the request, strictly adhering to the provided JSON schemas." ] for tool in tools: @@ -367,20 +367,21 @@ def _build_tool_prompt( ) lines.append( - "When you decide to call a tool you MUST respond with nothing except a single [function_calls] block exactly like the template below." + "When you decide to call tools, you MUST respond ONLY with a single [function_calls] block using this EXACT syntax:" ) - lines.append("Do not add text before or after the block.") lines.append("[function_calls]") - lines.append('[call:tool_name]{"argument": "value"}[/call]') + lines.append("[call:tool_name]") + lines.append('{"argument": "value"}') + lines.append("[/call]") lines.append("[/function_calls]") lines.append( - "Use double quotes for JSON keys and values. CRITICAL: The content inside [call:...]...[/call] MUST be a raw JSON object. Do not wrap it in ```json blocks or add any conversational text inside the tag." + "CRITICAL: Every [call:...] MUST have a raw JSON object followed by a mandatory [/call] closing tag. DO NOT use markdown blocks or add text inside the block." ) lines.append( - "To call multiple tools, list each [call:tool_name]...[/call] entry sequentially within a single [function_calls] block." + "If multiple tools are needed, list them sequentially within the same [function_calls] block." ) lines.append( - "If no tool call is needed, provide a normal response and DO NOT use the [function_calls] tag." + "If no tool call is needed, provide a normal response and NEVER use the [function_calls] tag." ) return "\n".join(lines) @@ -398,26 +399,16 @@ def _build_image_generation_instruction( return None instructions: list[str] = [ - "Image generation is enabled. When the user requests an image, you must return an actual generated image, not a text description.", - "For new image requests, generate at least one new image matching the description.", - "If the user provides an image and asks for edits or variations, return a newly generated image with the requested changes.", - "Avoid all text replies unless a short caption is explicitly requested. Do not explain, apologize, or describe image creation steps.", - "Never send placeholder text like 'Here is your image' or any other response without an actual image attachment.", + "IMAGE GENERATION ENABLED: When an image is requested, you MUST return a real generated image directly.", + "1. For new requests, generate new images matching the description immediately.", + "2. For edits to existing images, apply changes and return a new generated version.", + "3. CRITICAL: Provide ZERO text explanation, prologue, or apologies. Do not describe the creation process.", + "4. NEVER send placeholder text or descriptions like 'Generating image...' without an actual image attachment.", ] - if primary: - if primary.model: - instructions.append( - f"Where styles differ, favor the `{primary.model}` image model when rendering the scene." - ) - if primary.output_format: - instructions.append( - f"Encode the image using the `{primary.output_format}` format whenever possible." - ) - if has_forced_choice: instructions.append( - "Image generation was explicitly requested. You must return at least one generated image. Any response without an image will be treated as a failure." + "Image generation was explicitly requested. You MUST return at least one generated image. Any response without an image will be treated as a failure." ) return "\n\n".join(instructions) diff --git a/app/utils/helper.py b/app/utils/helper.py index 230622b..2a3f841 100644 --- a/app/utils/helper.py +++ b/app/utils/helper.py @@ -16,9 +16,13 @@ VALID_TAG_ROLES = {"user", "assistant", "system", "tool"} TOOL_WRAP_HINT = ( - "\nYou MUST wrap every tool call response inside a single [function_calls] block exactly like:\n" - '[function_calls]\n[call:tool_name]{"argument": "value"}[/call]\n[/function_calls]\n' - "IMPORTANT: Arguments MUST be a valid JSON object. Do not include markdown code blocks (```json) or any conversational text inside the [call] tag.\n" + "\nWhen you decide to call tools, you MUST respond ONLY with a single [function_calls] block using this EXACT syntax:\n" + "[function_calls]\n" + "[call:tool_name]\n" + '{"argument": "value"}\n' + "[/call]\n" + "[/function_calls]\n" + "CRITICAL: Every [call:...] MUST have a raw JSON object followed by a mandatory [/call] closing tag. DO NOT use markdown blocks or add text inside the block.\n" ) TOOL_BLOCK_RE = re.compile( r"\[function_calls]\s*(.*?)\s*\[/function_calls]", re.DOTALL | re.IGNORECASE From 68ce2df5c2d46f529630b9cfb08550cdd4d46a76 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C6=B0u=20Quang=20V=C5=A9?= Date: Fri, 6 Feb 2026 21:41:21 +0700 Subject: [PATCH 076/291] Refactor: Enhance system prompts --- app/server/chat.py | 37 ++++++++++++++++++++++++++++++++++--- app/services/client.py | 4 +++- app/utils/config.py | 2 +- app/utils/helper.py | 12 ++++++++++-- config/config.yaml | 2 +- 5 files changed, 49 insertions(+), 8 deletions(-) diff --git a/app/server/chat.py b/app/server/chat.py index 06aefc2..cf03f6b 100644 --- a/app/server/chat.py +++ b/app/server/chat.py @@ -383,6 +383,9 @@ def _build_tool_prompt( lines.append( "If no tool call is needed, provide a normal response and NEVER use the [function_calls] tag." ) + lines.append( + "After you call a tool, the system will provide the output in a `[function_responses]` block with the same tool name." + ) return "\n".join(lines) @@ -462,11 +465,13 @@ def _prepare_messages_for_model( msg.name = tool_id_to_name.get(msg.tool_call_id) instructions: list[str] = [] + tool_prompt_injected = False if inject_system_defaults: if tools: tool_prompt = _build_tool_prompt(tools, tool_choice) if tool_prompt: instructions.append(tool_prompt) + tool_prompt_injected = True if extra_instructions: instructions.extend(instr for instr in extra_instructions if instr) @@ -475,7 +480,7 @@ def _prepare_messages_for_model( ) if not instructions: - if tools and tool_choice != "none": + if tools and tool_choice != "none" and not tool_prompt_injected: _append_tool_hint_to_last_user_message(prepared) return prepared @@ -488,7 +493,7 @@ def _prepare_messages_for_model( else: prepared.insert(0, Message(role="system", content=combined_instructions)) - if tools and tool_choice != "none": + if tools and tool_choice != "none" and not tool_prompt_injected: _append_tool_hint_to_last_user_message(prepared) return prepared @@ -762,13 +767,20 @@ def __init__(self): self.TOOL_START = "[function_calls]" self.TOOL_END = "[/function_calls]" + self.RESPONSE_START = "[function_responses]" + self.RESPONSE_END = "[/function_responses]" self.TAG_START = "<|im_start|>" self.TAG_END = "<|im_end|>" self.HINT_START = f"\n{TOOL_HINT_LINE_START}" if TOOL_HINT_LINE_START else "" self.HINT_END = TOOL_HINT_LINE_END self.TOOL_PREFIX = "[call:" - self.WATCH_PREFIXES = [self.TOOL_START, self.TAG_START, self.TAG_END] + self.WATCH_PREFIXES = [ + self.TOOL_START, + self.RESPONSE_START, + self.TAG_START, + self.TAG_END, + ] if self.HINT_START: self.WATCH_PREFIXES.append(self.HINT_START) @@ -779,6 +791,7 @@ def process(self, chunk: str) -> str: while self.buffer: if self.state == "NORMAL": tool_idx = self.buffer.find(self.TOOL_START) + resp_idx = self.buffer.find(self.RESPONSE_START) tag_idx = self.buffer.find(self.TAG_START) end_idx = self.buffer.find(self.TAG_END) hint_idx = self.buffer.find(self.HINT_START) if self.HINT_START else -1 @@ -787,6 +800,7 @@ def process(self, chunk: str) -> str: (i, t) for i, t in [ (tool_idx, "TOOL"), + (resp_idx, "RESP"), (tag_idx, "TAG"), (end_idx, "END"), (hint_idx, "HINT"), @@ -817,6 +831,9 @@ def process(self, chunk: str) -> str: self.state = "IN_TOOL" self.block_buffer = "" self.buffer = self.buffer[len(self.TOOL_START) :] + elif m_type == "RESP": + self.state = "IN_RESP" + self.buffer = self.buffer[len(self.RESPONSE_START) :] elif m_type == "TAG": self.state = "IN_TAG" self.buffer = self.buffer[len(self.TAG_START) :] @@ -838,6 +855,18 @@ def process(self, chunk: str) -> str: self.buffer = self.buffer[-keep_len:] break + elif self.state == "IN_RESP": + end_idx = self.buffer.find(self.RESPONSE_END) + if end_idx != -1: + self.buffer = self.buffer[end_idx + len(self.RESPONSE_END) :] + self.state = "NORMAL" + else: + # Keep end of buffer to avoid missing split RESPONSE_END + keep_len = len(self.RESPONSE_END) - 1 + if len(self.buffer) > keep_len: + self.buffer = self.buffer[-keep_len:] + break + elif self.state == "IN_TOOL": end_idx = self.buffer.find(self.TOOL_END) if end_idx != -1: @@ -892,6 +921,8 @@ def flush(self) -> str: res = f"{self.TOOL_START}{self.block_buffer}" elif self.state == "IN_BLOCK" and self.current_role != "tool": res = self.buffer + elif self.state in ("IN_RESP", "IN_HINT"): + res = "" elif self.state == "NORMAL": res = self.buffer diff --git a/app/services/client.py b/app/services/client.py index 21814e5..5473b06 100644 --- a/app/services/client.py +++ b/app/services/client.py @@ -117,7 +117,9 @@ async def process_message( if message.role == "tool": tool_name = message.name or "unknown" combined_content = "\n".join(text_fragments).strip() or "{}" - text_fragments = [f"[response:{tool_name}]{combined_content}[/response]"] + text_fragments = [ + f"[function_responses]\n[response:{tool_name}]{combined_content}[/response]\n[/function_responses]" + ] if message.tool_calls: tool_blocks: list[str] = [] diff --git a/app/utils/config.py b/app/utils/config.py index 708462d..bbb6054 100644 --- a/app/utils/config.py +++ b/app/utils/config.py @@ -83,7 +83,7 @@ class GeminiConfig(BaseModel): default="append", description="Strategy for loading models: 'append' merges custom with default, 'overwrite' uses only custom", ) - timeout: int = Field(default=120, ge=1, description="Init timeout") + timeout: int = Field(default=300, ge=1, description="Init timeout") auto_refresh: bool = Field(True, description="Enable auto-refresh for Gemini cookies") refresh_interval: int = Field( default=540, ge=1, description="Interval in seconds to refresh Gemini cookies" diff --git a/app/utils/helper.py b/app/utils/helper.py index 2a3f841..9f5cfef 100644 --- a/app/utils/helper.py +++ b/app/utils/helper.py @@ -28,6 +28,12 @@ r"\[function_calls]\s*(.*?)\s*\[/function_calls]", re.DOTALL | re.IGNORECASE ) TOOL_CALL_RE = re.compile(r"\[call:([^]]+)]\s*(.*?)\s*\[/call]", re.DOTALL | re.IGNORECASE) +RESPONSE_BLOCK_RE = re.compile( + r"\[function_responses]\s*(.*?)\s*\[/function_responses]", re.DOTALL | re.IGNORECASE +) +RESPONSE_ITEM_RE = re.compile( + r"\[response:([^]]+)]\s*(.*?)\s*\[/response]", re.DOTALL | re.IGNORECASE +) CONTROL_TOKEN_RE = re.compile(r"<\|im_(?:start|end)\|>") TOOL_HINT_STRIPPED = TOOL_WRAP_HINT.strip() _hint_lines = [line.strip() for line in TOOL_WRAP_HINT.split("\n") if line.strip()] @@ -248,8 +254,6 @@ def _replace_block(match: re.Match[str]) -> str: else: return match.group(0) - cleaned = TOOL_BLOCK_RE.sub(_replace_block, cleaned) - def _replace_orphan(match: re.Match[str]) -> str: if extract: name = (match.group(1) or "").strip() @@ -257,8 +261,12 @@ def _replace_orphan(match: re.Match[str]) -> str: _create_tool_call(name, raw_args) return "" + cleaned = TOOL_BLOCK_RE.sub(_replace_block, cleaned) cleaned = TOOL_CALL_RE.sub(_replace_orphan, cleaned) + cleaned = RESPONSE_BLOCK_RE.sub("", cleaned) + cleaned = RESPONSE_ITEM_RE.sub("", cleaned) + return cleaned, tool_calls diff --git a/config/config.yaml b/config/config.yaml index f2b17fb..ed581f7 100644 --- a/config/config.yaml +++ b/config/config.yaml @@ -22,7 +22,7 @@ gemini: secure_1psid: "YOUR_SECURE_1PSID_HERE" secure_1psidts: "YOUR_SECURE_1PSIDTS_HERE" proxy: null # Optional proxy URL (null/empty means direct connection) - timeout: 120 # Init timeout in seconds + timeout: 300 # Init timeout in seconds auto_refresh: true # Auto-refresh session cookies refresh_interval: 540 # Refresh interval in seconds verbose: false # Enable verbose logging for Gemini requests From 77f72105b4a6942d5adaf098d5f2e133dc7e5ced Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C6=B0u=20Quang=20V=C5=A9?= Date: Fri, 6 Feb 2026 22:23:35 +0700 Subject: [PATCH 077/291] Refactor: Enhance system prompts --- app/server/chat.py | 2 +- app/services/client.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/app/server/chat.py b/app/server/chat.py index cf03f6b..ffa37cd 100644 --- a/app/server/chat.py +++ b/app/server/chat.py @@ -384,7 +384,7 @@ def _build_tool_prompt( "If no tool call is needed, provide a normal response and NEVER use the [function_calls] tag." ) lines.append( - "After you call a tool, the system will provide the output in a `[function_responses]` block with the same tool name." + "Note: Tool results are returned in a `[function_responses]` block." ) return "\n".join(lines) diff --git a/app/services/client.py b/app/services/client.py index 5473b06..3dae6a1 100644 --- a/app/services/client.py +++ b/app/services/client.py @@ -118,7 +118,7 @@ async def process_message( tool_name = message.name or "unknown" combined_content = "\n".join(text_fragments).strip() or "{}" text_fragments = [ - f"[function_responses]\n[response:{tool_name}]{combined_content}[/response]\n[/function_responses]" + f"[function_responses]\n[response:{tool_name}]\n{combined_content}\n[/response]\n[/function_responses]" ] if message.tool_calls: From 3addb2b495c7772ddb1a7d4256c348a702cabb28 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C6=B0u=20Quang=20V=C5=A9?= Date: Sat, 7 Feb 2026 09:46:26 +0700 Subject: [PATCH 078/291] Refactor: Enhance system prompts --- app/server/chat.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/server/chat.py b/app/server/chat.py index ffa37cd..ac96cf5 100644 --- a/app/server/chat.py +++ b/app/server/chat.py @@ -384,7 +384,7 @@ def _build_tool_prompt( "If no tool call is needed, provide a normal response and NEVER use the [function_calls] tag." ) lines.append( - "Note: Tool results are returned in a `[function_responses]` block." + "Note: Tool results are returned in a [function_responses] block." ) return "\n".join(lines) From 2a53eed83af901a12c95cba233a693ab3890eae2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C6=B0u=20Quang=20V=C5=A9?= Date: Sun, 8 Feb 2026 09:54:51 +0700 Subject: [PATCH 079/291] fix: missing image extension --- app/server/chat.py | 29 ++++++++++++++++++++++------- app/utils/helper.py | 13 +++++++++++++ 2 files changed, 35 insertions(+), 7 deletions(-) diff --git a/app/server/chat.py b/app/server/chat.py index ac96cf5..bf34fbf 100644 --- a/app/server/chat.py +++ b/app/server/chat.py @@ -45,6 +45,7 @@ TOOL_HINT_LINE_START, TOOL_HINT_STRIPPED, TOOL_WRAP_HINT, + detect_image_extension, estimate_tokens, extract_image_dimensions, extract_tool_calls, @@ -91,11 +92,21 @@ async def _image_to_base64( raise ValueError("Failed to save generated image") original_path = Path(saved_path) - random_name = f"img_{uuid.uuid4().hex}{original_path.suffix}" + data = original_path.read_bytes() + suffix = original_path.suffix + + if not suffix: + detected_ext = detect_image_extension(data) + if detected_ext: + suffix = detected_ext + else: + # Fallback if detection fails + suffix = ".png" if isinstance(image, GeneratedImage) else ".jpg" + + random_name = f"img_{uuid.uuid4().hex}{suffix}" new_path = temp_dir / random_name original_path.rename(new_path) - data = new_path.read_bytes() width, height = extract_image_dimensions(data) filename = random_name file_hash = hashlib.sha256(data).hexdigest() @@ -383,9 +394,7 @@ def _build_tool_prompt( lines.append( "If no tool call is needed, provide a normal response and NEVER use the [function_calls] tag." ) - lines.append( - "Note: Tool results are returned in a [function_responses] block." - ) + lines.append("Note: Tool results are returned in a [function_responses] block.") return "\n".join(lines) @@ -1227,7 +1236,11 @@ async def generate_stream(): continue seen_hashes.add(file_hash) - img_format = "png" if isinstance(image, GeneratedImage) else "jpeg" + img_format = ( + filename.rsplit(".", 1)[-1] + if "." in filename + else ("png" if isinstance(image, GeneratedImage) else "jpeg") + ) image_url = ( f"![{filename}]({base_url}images/{filename}?token={get_image_token(filename)})" ) @@ -1610,7 +1623,9 @@ async def create_response( ResponseImageGenerationCall( id=fname.rsplit(".", 1)[0], result=b64, - output_format="png" if isinstance(img, GeneratedImage) else "jpeg", + output_format=fname.rsplit(".", 1)[-1] + if "." in fname + else ("png" if isinstance(img, GeneratedImage) else "jpeg"), size=f"{w}x{h}" if w and h else None, ) ) diff --git a/app/utils/helper.py b/app/utils/helper.py index 9f5cfef..384f5cd 100644 --- a/app/utils/helper.py +++ b/app/utils/helper.py @@ -362,3 +362,16 @@ def extract_image_dimensions(data: bytes) -> tuple[int | None, int | None]: idx += segment_length - 2 return None, None + + +def detect_image_extension(data: bytes) -> str | None: + """Detect image extension from magic bytes.""" + if data.startswith(b"\x89PNG\r\n\x1a\n"): + return ".png" + if data.startswith(b"\xff\xd8"): + return ".jpg" + if data.startswith(b"GIF8"): + return ".gif" + if data.startswith(b"RIFF") and data[8:12] == b"WEBP": + return ".webp" + return None From 26d39c75825c16bb118af7143317d51a12aa6c4b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C6=B0u=20Quang=20V=C5=A9?= Date: Sun, 8 Feb 2026 10:25:30 +0700 Subject: [PATCH 080/291] fix: missing image extension --- app/server/chat.py | 67 ++++++++++++++++++++++------------------------ 1 file changed, 32 insertions(+), 35 deletions(-) diff --git a/app/server/chat.py b/app/server/chat.py index bf34fbf..dfcf930 100644 --- a/app/server/chat.py +++ b/app/server/chat.py @@ -1067,16 +1067,14 @@ async def generate_stream(): for image in images: try: image_store = get_image_store_dir() - _, _, _, filename, file_hash = await _image_to_base64(image, image_store) - if file_hash in seen_hashes: + _, _, _, fname, fhash = await _image_to_base64(image, image_store) + if fhash in seen_hashes: # Duplicate content, delete the file and skip - (image_store / filename).unlink(missing_ok=True) + (image_store / fname).unlink(missing_ok=True) continue - seen_hashes.add(file_hash) + seen_hashes.add(fhash) - img_url = ( - f"![{filename}]({base_url}images/{filename}?token={get_image_token(filename)})" - ) + img_url = f"![{fname}]({base_url}images/{fname}?token={get_image_token(fname)})" image_markdown += f"\n\n{img_url}" except Exception as exc: logger.warning(f"Failed to process image in OpenAI stream: {exc}") @@ -1228,28 +1226,25 @@ async def generate_stream(): seen_hashes = set() for image in images: try: - image_base64, width, height, filename, file_hash = await _image_to_base64( - image, image_store - ) - if file_hash in seen_hashes: - (image_store / filename).unlink(missing_ok=True) + b64, w, h, fname, fhash = await _image_to_base64(image, image_store) + if fhash in seen_hashes: + (image_store / fname).unlink(missing_ok=True) continue - seen_hashes.add(file_hash) + seen_hashes.add(fhash) - img_format = ( - filename.rsplit(".", 1)[-1] - if "." in filename - else ("png" if isinstance(image, GeneratedImage) else "jpeg") - ) - image_url = ( - f"![{filename}]({base_url}images/{filename}?token={get_image_token(filename)})" - ) + if "." in fname: + img_id, img_format = fname.rsplit(".", 1) + else: + img_id = fname + img_format = "png" if isinstance(image, GeneratedImage) else "jpeg" + + image_url = f"![{fname}]({base_url}images/{fname}?token={get_image_token(fname)})" image_call_items.append( ResponseImageGenerationCall( - id=filename.rsplit(".", 1)[0], - result=image_base64, + id=img_id, + result=b64, output_format=img_format, - size=f"{width}x{height}" if width and height else None, + size=f"{w}x{h}" if w and h else None, ) ) response_contents.append(ResponseOutputContent(type="output_text", text=image_url)) @@ -1433,15 +1428,13 @@ async def create_chat_completion( seen_hashes = set() for image in images: try: - _, _, _, filename, file_hash = await _image_to_base64(image, image_store) - if file_hash in seen_hashes: - (image_store / filename).unlink(missing_ok=True) + _, _, _, fname, fhash = await _image_to_base64(image, image_store) + if fhash in seen_hashes: + (image_store / fname).unlink(missing_ok=True) continue - seen_hashes.add(file_hash) + seen_hashes.add(fhash) - img_url = ( - f"![{filename}]({base_url}images/{filename}?token={get_image_token(filename)})" - ) + img_url = f"![{fname}]({base_url}images/{fname}?token={get_image_token(fname)})" image_markdown += f"\n\n{img_url}" except Exception as exc: logger.warning(f"Failed to process image in OpenAI response: {exc}") @@ -1613,6 +1606,12 @@ async def create_response( continue seen_hashes.add(fhash) + if "." in fname: + img_id, img_format = fname.rsplit(".", 1) + else: + img_id = fname + img_format = "png" if isinstance(img, GeneratedImage) else "jpeg" + contents.append( ResponseOutputContent( type="output_text", @@ -1621,11 +1620,9 @@ async def create_response( ) img_calls.append( ResponseImageGenerationCall( - id=fname.rsplit(".", 1)[0], + id=img_id, result=b64, - output_format=fname.rsplit(".", 1)[-1] - if "." in fname - else ("png" if isinstance(img, GeneratedImage) else "jpeg"), + output_format=img_format, size=f"{w}x{h}" if w and h else None, ) ) From 598b56335277366d591d81a25c2ba8654afcb92c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C6=B0u=20Quang=20V=C5=A9?= Date: Sun, 8 Feb 2026 17:03:20 +0700 Subject: [PATCH 081/291] fix: missing or duplicate ChatML tags. --- app/server/chat.py | 13 ++----------- app/services/client.py | 44 ++++++++++++++++++++++++++++++++---------- 2 files changed, 36 insertions(+), 21 deletions(-) diff --git a/app/server/chat.py b/app/server/chat.py index dfcf930..701c1f6 100644 --- a/app/server/chat.py +++ b/app/server/chat.py @@ -1359,12 +1359,7 @@ async def create_chat_completion( extra_instr, False, ) - if len(input_msgs) == 1: - m_input, files = await GeminiClientWrapper.process_message( - input_msgs[0], tmp_dir, tagged=False - ) - else: - m_input, files = await GeminiClientWrapper.process_conversation(input_msgs, tmp_dir) + m_input, files = await GeminiClientWrapper.process_conversation(input_msgs, tmp_dir) logger.debug( f"Reused session {reprlib.repr(session.metadata)} - sending {len(input_msgs)} prepared messages." @@ -1531,11 +1526,7 @@ async def create_response( ) if not msgs: raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="No new messages.") - m_input, files = ( - await GeminiClientWrapper.process_message(msgs[0], tmp_dir, tagged=False) - if len(msgs) == 1 - else await GeminiClientWrapper.process_conversation(msgs, tmp_dir) - ) + m_input, files = await GeminiClientWrapper.process_conversation(msgs, tmp_dir) logger.debug( f"Reused session {reprlib.repr(session.metadata)} - sending {len(msgs)} prepared messages." ) diff --git a/app/services/client.py b/app/services/client.py index 3dae6a1..2a00ce6 100644 --- a/app/services/client.py +++ b/app/services/client.py @@ -75,7 +75,7 @@ def running(self) -> bool: @staticmethod async def process_message( - message: Message, tempdir: Path | None = None, tagged: bool = True + message: Message, tempdir: Path | None = None, tagged: bool = True, wrap_tool: bool = True ) -> tuple[str, list[Path | str]]: """ Process a single Message object into a format suitable for the Gemini API. @@ -117,9 +117,11 @@ async def process_message( if message.role == "tool": tool_name = message.name or "unknown" combined_content = "\n".join(text_fragments).strip() or "{}" - text_fragments = [ - f"[function_responses]\n[response:{tool_name}]\n{combined_content}\n[/response]\n[/function_responses]" - ] + res_block = f"[response:{tool_name}]\n{combined_content}\n[/response]" + if wrap_tool: + text_fragments = [f"[function_responses]\n{res_block}\n[/function_responses]"] + else: + text_fragments = [res_block] if message.tool_calls: tool_blocks: list[str] = [] @@ -153,12 +155,34 @@ async def process_conversation( need_tag = any(m.role != "user" for m in messages) conversation: list[str] = [] files: list[Path | str] = [] - for msg in messages: - input_part, files_part = await GeminiClientWrapper.process_message( - msg, tempdir, tagged=need_tag - ) - conversation.append(input_part) - files.extend(files_part) + + i = 0 + while i < len(messages): + msg = messages[i] + if msg.role == "tool" and need_tag: + # Group consecutive tool messages + tool_blocks: list[str] = [] + while i < len(messages) and messages[i].role == "tool": + part, part_files = await GeminiClientWrapper.process_message( + messages[i], tempdir, tagged=False, wrap_tool=False + ) + tool_blocks.append(part) + files.extend(part_files) + i += 1 + + combined_tool_content = "\n".join(tool_blocks) + wrapped_content = ( + f"[function_responses]\n{combined_tool_content}\n[/function_responses]" + ) + conversation.append(add_tag("tool", wrapped_content)) + else: + input_part, files_part = await GeminiClientWrapper.process_message( + msg, tempdir, tagged=need_tag + ) + conversation.append(input_part) + files.extend(files_part) + i += 1 + if need_tag: conversation.append(add_tag("assistant", "", unclose=True)) return "\n".join(conversation), files From 6d563c512d3e6b1448f3442c231c53ca8afb2aa9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C6=B0u=20Quang=20V=C5=A9?= Date: Sun, 8 Feb 2026 17:50:06 +0700 Subject: [PATCH 082/291] Refactor: Consistently use ChatML tags throughout. --- app/services/client.py | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/app/services/client.py b/app/services/client.py index 2a00ce6..78edddd 100644 --- a/app/services/client.py +++ b/app/services/client.py @@ -152,14 +152,13 @@ async def process_message( async def process_conversation( messages: list[Message], tempdir: Path | None = None ) -> tuple[str, list[Path | str]]: - need_tag = any(m.role != "user" for m in messages) conversation: list[str] = [] files: list[Path | str] = [] i = 0 while i < len(messages): msg = messages[i] - if msg.role == "tool" and need_tag: + if msg.role == "tool": # Group consecutive tool messages tool_blocks: list[str] = [] while i < len(messages) and messages[i].role == "tool": @@ -177,14 +176,13 @@ async def process_conversation( conversation.append(add_tag("tool", wrapped_content)) else: input_part, files_part = await GeminiClientWrapper.process_message( - msg, tempdir, tagged=need_tag + msg, tempdir, tagged=True ) conversation.append(input_part) files.extend(files_part) i += 1 - if need_tag: - conversation.append(add_tag("assistant", "", unclose=True)) + conversation.append(add_tag("assistant", "", unclose=True)) return "\n".join(conversation), files @staticmethod From 58db419c15cf347083e49b487d8ae99071256ef0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C6=B0u=20Quang=20V=C5=A9?= Date: Mon, 9 Feb 2026 17:17:33 +0700 Subject: [PATCH 083/291] Refactor: normalize text before calculating message hash --- app/services/lmdb.py | 81 ++++++++++++++++++++++++-------------------- 1 file changed, 44 insertions(+), 37 deletions(-) diff --git a/app/services/lmdb.py b/app/services/lmdb.py index 8dc3722..59f01bc 100644 --- a/app/services/lmdb.py +++ b/app/services/lmdb.py @@ -1,5 +1,6 @@ import hashlib import re +import unicodedata from contextlib import contextmanager from datetime import datetime, timedelta from pathlib import Path @@ -18,63 +19,70 @@ from ..utils.singleton import Singleton +def _normalize_text(text: str | None) -> str | None: + """ + Perform semantic normalization for hashing. + """ + if text is None: + return None + + # Unicode normalization + text = unicodedata.normalize("NFC", text) + + # Basic cleaning + text = text.replace("\r\n", "\n").replace("\r", "\n") + text = LMDBConversationStore.remove_think_tags(text) + text = remove_tool_call_blocks(text) + + return text if text else None + + def _hash_message(message: Message) -> str: """ Generate a stable, canonical hash for a single message. - Strips system hints, thoughts, and tool call blocks to ensure - identical logical content produces the same hash regardless of format. """ - core_data = { + core_data: dict[str, Any] = { "role": message.role, - "name": message.name, - "tool_call_id": message.tool_call_id, + "name": message.name or None, + "tool_call_id": message.tool_call_id or None, } content = message.content - if not content: + if content is None: core_data["content"] = None elif isinstance(content, str): - normalized = content.replace("\r\n", "\n") - normalized = LMDBConversationStore.remove_think_tags(normalized) - normalized = remove_tool_call_blocks(normalized).strip() - core_data["content"] = normalized if normalized else None + core_data["content"] = _normalize_text(content) elif isinstance(content, list): text_parts = [] for item in content: text_val = "" if isinstance(item, ContentItem) and item.type == "text": - text_val = item.text or "" + text_val = item.text elif isinstance(item, dict) and item.get("type") == "text": - text_val = item.get("text") or "" + text_val = item.get("text") if text_val: - text_val = text_val.replace("\r\n", "\n") - text_val = LMDBConversationStore.remove_think_tags(text_val) - text_val = remove_tool_call_blocks(text_val).strip() - if text_val: - text_parts.append(text_val) - elif isinstance(item, ContentItem) and item.type in ("image_url", "file"): - # For non-text items, include their unique markers to distinguish them - if item.type == "image_url": - text_parts.append( - f"[image_url:{item.image_url.get('url') if item.image_url else ''}]" - ) - elif item.type == "file": - text_parts.append( - f"[file:{item.file.get('url') or item.file.get('filename') if item.file else ''}]" + normalized_part = _normalize_text(text_val) + if normalized_part: + text_parts.append(normalized_part) + elif isinstance(item, (ContentItem, dict)): + item_type = item.type if isinstance(item, ContentItem) else item.get("type") + if item_type == "image_url": + url = ( + item.image_url.get("url") + if isinstance(item, ContentItem) and item.image_url + else item.get("image_url", {}).get("url") ) - else: - # Fallback for other dict-based content parts - part_type = item.get("type") if isinstance(item, dict) else None - if part_type == "image_url": - url = item.get("image_url", {}).get("url") text_parts.append(f"[image_url:{url}]") - elif part_type == "file": - url = item.get("file", {}).get("url") or item.get("file", {}).get("filename") + elif item_type == "file": + url = ( + item.file.get("url") or item.file.get("filename") + if isinstance(item, ContentItem) and item.file + else item.get("file", {}).get("url") or item.get("file", {}).get("filename") + ) text_parts.append(f"[file:{url}]") - combined_text = "\n".join(text_parts).replace("\r\n", "\n").strip() - core_data["content"] = combined_text if combined_text else None + core_data["content"] = "\n".join(text_parts) if text_parts else None if message.tool_calls: calls_data = [] @@ -98,8 +106,7 @@ def _hash_message(message: Message) -> str: core_data["tool_calls"] = None message_bytes = orjson.dumps(core_data, option=orjson.OPT_SORT_KEYS) - digest = hashlib.sha256(message_bytes).hexdigest() - return digest + return hashlib.sha256(message_bytes).hexdigest() def _hash_conversation(client_id: str, model: str, messages: List[Message]) -> str: From d5d1c5a48e1f1d68fde53f8c5fd5da3358dfc938 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C6=B0u=20Quang=20V=C5=A9?= Date: Mon, 9 Feb 2026 17:33:39 +0700 Subject: [PATCH 084/291] Refactor: remove unescape helpers to avoid side effects --- app/services/client.py | 35 ----------------------------------- 1 file changed, 35 deletions(-) diff --git a/app/services/client.py b/app/services/client.py index 78edddd..16d7a33 100644 --- a/app/services/client.py +++ b/app/services/client.py @@ -1,4 +1,3 @@ -import html import re from pathlib import Path from typing import Any, cast @@ -15,9 +14,6 @@ save_url_to_tempfile, ) -HTML_ESCAPE_RE = re.compile(r"&(?:lt|gt|amp|quot|apos|#[0-9]+|#x[0-9a-fA-F]+);") -ESC_SYMBOLS_RE = re.compile(r"\\(?=[\\\[\]{}()<>`*_#~+.:!&^$|-])") -CODE_FENCE_RE = re.compile(r"(```.*?```|`[^`\n]+?`)", re.DOTALL) FILE_PATH_PATTERN = re.compile( r"^(?=.*[./\\]|.*:\d+|^(?:Dockerfile|Makefile|Jenkinsfile|Procfile|Rakefile|Gemfile|Vagrantfile|Caddyfile|Justfile|LICENSE|README|CONTRIBUTING|CODEOWNERS|AUTHORS|NOTICE|CHANGELOG)$)([a-zA-Z0-9_./\\-]+(?::\d+)?)$", re.IGNORECASE, @@ -195,37 +191,6 @@ def extract_output(response: ModelOutput, include_thoughts: bool = True) -> str: else: text += str(response) - def _unescape_html(text_content: str) -> str: - parts: list[str] = [] - last_index = 0 - for match in CODE_FENCE_RE.finditer(text_content): - non_code = text_content[last_index : match.start()] - if non_code: - parts.append(HTML_ESCAPE_RE.sub(lambda m: html.unescape(m.group(0)), non_code)) - parts.append(match.group(0)) - last_index = match.end() - tail = text_content[last_index:] - if tail: - parts.append(HTML_ESCAPE_RE.sub(lambda m: html.unescape(m.group(0)), tail)) - return "".join(parts) - - def _unescape_symbols(text_content: str) -> str: - parts: list[str] = [] - last_index = 0 - for match in CODE_FENCE_RE.finditer(text_content): - non_code = text_content[last_index : match.start()] - if non_code: - parts.append(ESC_SYMBOLS_RE.sub("", non_code)) - parts.append(match.group(0)) - last_index = match.end() - tail = text_content[last_index:] - if tail: - parts.append(ESC_SYMBOLS_RE.sub("", tail)) - return "".join(parts) - - text = _unescape_html(text) - text = _unescape_symbols(text) - def extract_file_path_from_display_text(text_content: str) -> str | None: match = re.match(FILE_PATH_PATTERN, text_content) if match: From a4a987cdd3a0f95bc718eb76c781b2c7b39e655e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C6=B0u=20Quang=20V=C5=A9?= Date: Mon, 9 Feb 2026 20:18:42 +0700 Subject: [PATCH 085/291] Refactor: Implement fuzzy matching to better handle complex data formats like Markdown. --- app/services/lmdb.py | 67 ++++++++++++++++++++++++++++++++++++-------- 1 file changed, 56 insertions(+), 11 deletions(-) diff --git a/app/services/lmdb.py b/app/services/lmdb.py index 59f01bc..b08a325 100644 --- a/app/services/lmdb.py +++ b/app/services/lmdb.py @@ -1,5 +1,6 @@ import hashlib import re +import string import unicodedata from contextlib import contextmanager from datetime import datetime, timedelta @@ -18,8 +19,19 @@ ) from ..utils.singleton import Singleton +_VOLATILE_SYMBOLS = string.whitespace + string.punctuation -def _normalize_text(text: str | None) -> str | None: + +def _fuzzy_normalize(text: str | None) -> str | None: + """ + Lowercase and remove all whitespace and punctuation. + """ + if text is None: + return None + return text.lower().translate(str.maketrans("", "", _VOLATILE_SYMBOLS)) + + +def _normalize_text(text: str | None, fuzzy: bool = False) -> str | None: """ Perform semantic normalization for hashing. """ @@ -34,10 +46,13 @@ def _normalize_text(text: str | None) -> str | None: text = LMDBConversationStore.remove_think_tags(text) text = remove_tool_call_blocks(text) + if fuzzy: + return _fuzzy_normalize(text) + return text if text else None -def _hash_message(message: Message) -> str: +def _hash_message(message: Message, fuzzy: bool = False) -> str: """ Generate a stable, canonical hash for a single message. """ @@ -51,7 +66,7 @@ def _hash_message(message: Message) -> str: if content is None: core_data["content"] = None elif isinstance(content, str): - core_data["content"] = _normalize_text(content) + core_data["content"] = _normalize_text(content, fuzzy=fuzzy) elif isinstance(content, list): text_parts = [] for item in content: @@ -62,7 +77,7 @@ def _hash_message(message: Message) -> str: text_val = item.get("text") if text_val: - normalized_part = _normalize_text(text_val) + normalized_part = _normalize_text(text_val, fuzzy=fuzzy) if normalized_part: text_parts.append(normalized_part) elif isinstance(item, (ContentItem, dict)): @@ -109,13 +124,15 @@ def _hash_message(message: Message) -> str: return hashlib.sha256(message_bytes).hexdigest() -def _hash_conversation(client_id: str, model: str, messages: List[Message]) -> str: +def _hash_conversation( + client_id: str, model: str, messages: List[Message], fuzzy: bool = False +) -> str: """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 or "").encode("utf-8")) combined_hash.update((model or "").encode("utf-8")) for message in messages: - message_hash = _hash_message(message) + message_hash = _hash_message(message, fuzzy=fuzzy) combined_hash.update(message_hash.encode("utf-8")) return combined_hash.hexdigest() @@ -124,6 +141,7 @@ class LMDBConversationStore(metaclass=Singleton): """LMDB-based storage for Message lists with hash-based key-value operations.""" HASH_LOOKUP_PREFIX = "hash:" + FUZZY_LOOKUP_PREFIX = "fuzzy:" def __init__( self, @@ -215,6 +233,7 @@ def store( # Generate hash for the message list message_hash = _hash_conversation(conv.client_id, conv.model, conv.messages) + fuzzy_hash = _hash_conversation(conv.client_id, conv.model, conv.messages, fuzzy=True) storage_key = custom_key or message_hash now = datetime.now() @@ -233,6 +252,11 @@ def store( storage_key.encode("utf-8"), ) + txn.put( + f"{self.FUZZY_LOOKUP_PREFIX}{fuzzy_hash}".encode("utf-8"), + storage_key.encode("utf-8"), + ) + logger.debug(f"Stored {len(conv.messages)} messages with key: {storage_key[:12]}") return storage_key @@ -287,6 +311,11 @@ def find(self, model: str, messages: List[Message]) -> Optional[ConversationInSt ) return conv + # --- Find with fuzzy matching --- + if conv := self._find_by_message_list(model, messages, fuzzy=True): + logger.debug(f"Session found for '{model}' with fuzzy matching.") + return conv + logger.debug(f"No session found for '{model}' with {len(messages)} messages.") return None @@ -294,11 +323,13 @@ def _find_by_message_list( self, model: str, messages: List[Message], + fuzzy: bool = False, ) -> Optional[ConversationInStore]: """Internal find implementation based on a message list.""" + prefix = self.FUZZY_LOOKUP_PREFIX if fuzzy else self.HASH_LOOKUP_PREFIX for c in g_config.gemini.clients: - message_hash = _hash_conversation(c.id, model, messages) - key = f"{self.HASH_LOOKUP_PREFIX}{message_hash}" + message_hash = _hash_conversation(c.id, model, messages, fuzzy=fuzzy) + key = f"{prefix}{message_hash}" try: with self._get_transaction(write=False) as txn: if mapped := txn.get(key.encode("utf-8")): # type: ignore @@ -350,6 +381,9 @@ def delete(self, key: str) -> Optional[ConversationInStore]: storage_data = orjson.loads(data) # type: ignore conv = ConversationInStore.model_validate(storage_data) message_hash = _hash_conversation(conv.client_id, conv.model, conv.messages) + fuzzy_hash = _hash_conversation( + conv.client_id, conv.model, conv.messages, fuzzy=True + ) # Delete main data txn.delete(key.encode("utf-8")) @@ -358,6 +392,9 @@ def delete(self, key: str) -> Optional[ConversationInStore]: if message_hash and key != message_hash: txn.delete(f"{self.HASH_LOOKUP_PREFIX}{message_hash}".encode("utf-8")) + # Always clean up fuzzy mapping + txn.delete(f"{self.FUZZY_LOOKUP_PREFIX}{fuzzy_hash}".encode("utf-8")) + logger.debug(f"Deleted messages with key: {key[:12]}") return conv @@ -386,7 +423,9 @@ def keys(self, prefix: str = "", limit: Optional[int] = None) -> List[str]: for key, _ in cursor: key_str = key.decode("utf-8") # Skip internal hash mappings - if key_str.startswith(self.HASH_LOOKUP_PREFIX): + if key_str.startswith(self.HASH_LOOKUP_PREFIX) or key_str.startswith( + self.FUZZY_LOOKUP_PREFIX + ): continue if not prefix or key_str.startswith(prefix): @@ -459,8 +498,14 @@ def cleanup_expired(self, retention_days: Optional[int] = None) -> int: continue message_hash = _hash_conversation(conv.client_id, conv.model, conv.messages) - if message_hash and key_str != message_hash: - txn.delete(f"{self.HASH_LOOKUP_PREFIX}{message_hash}".encode("utf-8")) + if message_hash: + if key_str != message_hash: + txn.delete(f"{self.HASH_LOOKUP_PREFIX}{message_hash}".encode("utf-8")) + + fuzzy_hash = _hash_conversation( + conv.client_id, conv.model, conv.messages, fuzzy=True + ) + txn.delete(f"{self.FUZZY_LOOKUP_PREFIX}{fuzzy_hash}".encode("utf-8")) removed += 1 except Exception as exc: logger.error(f"Failed to delete expired conversations: {exc}") From 551eb8775e03e24219436446d30f8997c268d7eb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C6=B0u=20Quang=20V=C5=A9?= Date: Mon, 9 Feb 2026 21:08:06 +0700 Subject: [PATCH 086/291] Refactor: Implement fuzzy matching to better handle complex data formats like Markdown. --- app/services/lmdb.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/app/services/lmdb.py b/app/services/lmdb.py index b08a325..b8861dc 100644 --- a/app/services/lmdb.py +++ b/app/services/lmdb.py @@ -466,7 +466,9 @@ def cleanup_expired(self, retention_days: Optional[int] = None) -> int: for key_bytes, value_bytes in cursor: key_str = key_bytes.decode("utf-8") - if key_str.startswith(self.HASH_LOOKUP_PREFIX): + if key_str.startswith(self.HASH_LOOKUP_PREFIX) or key_str.startswith( + self.FUZZY_LOOKUP_PREFIX + ): continue try: From b2dbb087cfe4b553e690b1d52b4d12b5c3b07296 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C6=B0u=20Quang=20V=C5=A9?= Date: Tue, 10 Feb 2026 07:56:22 +0700 Subject: [PATCH 087/291] Feat: Add watchdog_timeout parameter --- app/services/client.py | 3 +++ app/services/pool.py | 2 ++ app/utils/config.py | 1 + config/config.yaml | 1 + 4 files changed, 7 insertions(+) diff --git a/app/services/client.py b/app/services/client.py index 16d7a33..3cdd839 100644 --- a/app/services/client.py +++ b/app/services/client.py @@ -38,6 +38,7 @@ def __init__(self, client_id: str, **kwargs): async def init( self, timeout: float = cast(float, _UNSET), + watchdog_timeout: float = cast(float, _UNSET), auto_close: bool = False, close_delay: float = 300, auto_refresh: bool = cast(bool, _UNSET), @@ -49,6 +50,7 @@ async def init( """ config = g_config.gemini timeout = cast(float, _resolve(timeout, config.timeout)) + watchdog_timeout = cast(float, _resolve(watchdog_timeout, config.watchdog_timeout)) auto_refresh = cast(bool, _resolve(auto_refresh, config.auto_refresh)) refresh_interval = cast(float, _resolve(refresh_interval, config.refresh_interval)) verbose = cast(bool, _resolve(verbose, config.verbose)) @@ -56,6 +58,7 @@ async def init( try: await super().init( timeout=timeout, + watchdog_timeout=watchdog_timeout, auto_close=auto_close, close_delay=close_delay, auto_refresh=auto_refresh, diff --git a/app/services/pool.py b/app/services/pool.py index 0f95203..decc21a 100644 --- a/app/services/pool.py +++ b/app/services/pool.py @@ -41,6 +41,7 @@ async def init(self) -> None: try: await client.init( timeout=g_config.gemini.timeout, + watchdog_timeout=g_config.gemini.watchdog_timeout, auto_refresh=g_config.gemini.auto_refresh, verbose=g_config.gemini.verbose, refresh_interval=g_config.gemini.refresh_interval, @@ -93,6 +94,7 @@ async def _ensure_client_ready(self, client: GeminiClientWrapper) -> bool: try: await client.init( timeout=g_config.gemini.timeout, + watchdog_timeout=g_config.gemini.watchdog_timeout, auto_refresh=g_config.gemini.auto_refresh, verbose=g_config.gemini.verbose, refresh_interval=g_config.gemini.refresh_interval, diff --git a/app/utils/config.py b/app/utils/config.py index bbb6054..e62832d 100644 --- a/app/utils/config.py +++ b/app/utils/config.py @@ -84,6 +84,7 @@ class GeminiConfig(BaseModel): description="Strategy for loading models: 'append' merges custom with default, 'overwrite' uses only custom", ) timeout: int = Field(default=300, ge=1, description="Init timeout") + watchdog_timeout: int = Field(default=60, ge=1, description="Watchdog timeout") auto_refresh: bool = Field(True, description="Enable auto-refresh for Gemini cookies") refresh_interval: int = Field( default=540, ge=1, description="Interval in seconds to refresh Gemini cookies" diff --git a/config/config.yaml b/config/config.yaml index ed581f7..2873d48 100644 --- a/config/config.yaml +++ b/config/config.yaml @@ -23,6 +23,7 @@ gemini: secure_1psidts: "YOUR_SECURE_1PSIDTS_HERE" proxy: null # Optional proxy URL (null/empty means direct connection) timeout: 300 # Init timeout in seconds + watchdog_timeout: 60 # Watchdog timeout in seconds (No longer than 75 seconds) auto_refresh: true # Auto-refresh session cookies refresh_interval: 540 # Refresh interval in seconds verbose: false # Enable verbose logging for Gemini requests From 969cd4a1e37a0915915962fe8c7ae691c8defa65 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C6=B0u=20Quang=20V=C5=A9?= Date: Tue, 10 Feb 2026 07:57:53 +0700 Subject: [PATCH 088/291] Update required dependencies --- pyproject.toml | 8 ++++---- uv.lock | 27 ++++++++++++++------------- 2 files changed, 18 insertions(+), 17 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index dc08571..47cd86f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,8 +5,8 @@ description = "FastAPI Server built on Gemini Web API" readme = "README.md" requires-python = "==3.12.*" dependencies = [ - "fastapi>=0.128.0", - "gemini-webapi>=1.18.0", + "fastapi>=0.128.6", + "gemini-webapi>=1.19.0", "lmdb>=1.7.5", "loguru>=0.7.3", "orjson>=3.11.7", @@ -17,7 +17,7 @@ dependencies = [ [project.optional-dependencies] dev = [ - "ruff>=0.14.14", + "ruff>=0.15.0", ] [tool.ruff] @@ -31,5 +31,5 @@ indent-style = "space" [dependency-groups] dev = [ - "ruff>=0.14.14", + "ruff>=0.15.0", ] diff --git a/uv.lock b/uv.lock index 2a29c98..ea28c0e 100644 --- a/uv.lock +++ b/uv.lock @@ -65,17 +65,18 @@ wheels = [ [[package]] name = "fastapi" -version = "0.128.1" +version = "0.128.6" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "annotated-doc" }, { name = "pydantic" }, { name = "starlette" }, { name = "typing-extensions" }, + { name = "typing-inspection" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/f6/59/28bde150415783ff084334e3de106eb7461a57864cf69f343950ad5a5ddd/fastapi-0.128.1.tar.gz", hash = "sha256:ce5be4fa26d4ce6f54debcc873d1fb8e0e248f5c48d7502ba6c61457ab2dc766", size = 374260, upload-time = "2026-02-04T17:35:10.542Z" } +sdist = { url = "https://files.pythonhosted.org/packages/83/d1/195005b5e45b443e305136df47ee7df4493d782e0c039dd0d97065580324/fastapi-0.128.6.tar.gz", hash = "sha256:0cb3946557e792d731b26a42b04912f16367e3c3135ea8290f620e234f2b604f", size = 374757, upload-time = "2026-02-09T17:27:03.541Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/1a/08/3953db1979ea131c68279b997c6465080118b407f0800445b843f8e164b3/fastapi-0.128.1-py3-none-any.whl", hash = "sha256:ee82146bbf91ea5bbf2bb8629e4c6e056c4fbd997ea6068501b11b15260b50fb", size = 103810, upload-time = "2026-02-04T17:35:08.02Z" }, + { url = "https://files.pythonhosted.org/packages/24/58/a2c4f6b240eeb148fb88cdac48f50a194aba760c1ca4988c6031c66a20ee/fastapi-0.128.6-py3-none-any.whl", hash = "sha256:bb1c1ef87d6086a7132d0ab60869d6f1ee67283b20fbf84ec0003bd335099509", size = 103674, upload-time = "2026-02-09T17:27:02.355Z" }, ] [[package]] @@ -105,24 +106,24 @@ dev = [ [package.metadata] requires-dist = [ - { name = "fastapi", specifier = ">=0.128.0" }, - { name = "gemini-webapi", specifier = ">=1.18.0" }, + { name = "fastapi", specifier = ">=0.128.6" }, + { name = "gemini-webapi", specifier = ">=1.19.0" }, { name = "lmdb", specifier = ">=1.7.5" }, { name = "loguru", specifier = ">=0.7.3" }, { name = "orjson", specifier = ">=3.11.7" }, { name = "pydantic-settings", extras = ["yaml"], specifier = ">=2.12.0" }, - { name = "ruff", marker = "extra == 'dev'", specifier = ">=0.14.14" }, + { name = "ruff", marker = "extra == 'dev'", specifier = ">=0.15.0" }, { 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.14.14" }] +dev = [{ name = "ruff", specifier = ">=0.15.0" }] [[package]] name = "gemini-webapi" -version = "1.18.1" +version = "1.19.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "httpx", extra = ["http2"] }, @@ -130,9 +131,9 @@ dependencies = [ { name = "orjson" }, { name = "pydantic" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/64/ae/925abc25d0b5c62170c528511bb8a1ec7bd77a0b7a19aacb9a7885a0afa8/gemini_webapi-1.18.1.tar.gz", hash = "sha256:34c91141e5953e898333e9c6ca01349566d28dbea9ddd8094f8c85e74d72ce47", size = 265100, upload-time = "2026-02-04T22:19:05.724Z" } +sdist = { url = "https://files.pythonhosted.org/packages/5f/1f/8314b620db12855e6aa9c55e05428fa30eb7f00fb61b1de7db42734ef2b2/gemini_webapi-1.19.0.tar.gz", hash = "sha256:1f65e6a2e4a461f4ed4fb01dc76c2de4ed517af549f6ce34b96b9986c11af5dd", size = 266822, upload-time = "2026-02-09T23:16:34.446Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/4f/e5/7ae98d48bfb7283facec804f13c6719b6fa523a6aa240b4acdea736bf60b/gemini_webapi-1.18.1-py3-none-any.whl", hash = "sha256:110f3d191ffdda9d040aab6b1b2f1d8513d1e77dc33d40fac5024de9344ea3ec", size = 61836, upload-time = "2026-02-04T22:19:04.08Z" }, + { url = "https://files.pythonhosted.org/packages/88/3b/71567ce13357d1139dfa0578c4073d6a8c523c4a28f3843194b639bf9d1e/gemini_webapi-1.19.0-py3-none-any.whl", hash = "sha256:47ab49f018cc01bf4b772910f7843af895f5e43d5a18b5ec7063b6f61e535921", size = 63498, upload-time = "2026-02-09T23:16:33.328Z" }, ] [[package]] @@ -385,15 +386,15 @@ wheels = [ [[package]] name = "starlette" -version = "0.50.0" +version = "0.52.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/ba/b8/73a0e6a6e079a9d9cfa64113d771e421640b6f679a52eeb9b32f72d871a1/starlette-0.50.0.tar.gz", hash = "sha256:a2a17b22203254bcbc2e1f926d2d55f3f9497f769416b3190768befe598fa3ca", size = 2646985, upload-time = "2025-11-01T15:25:27.516Z" } +sdist = { url = "https://files.pythonhosted.org/packages/c4/68/79977123bb7be889ad680d79a40f339082c1978b5cfcf62c2d8d196873ac/starlette-0.52.1.tar.gz", hash = "sha256:834edd1b0a23167694292e94f597773bc3f89f362be6effee198165a35d62933", size = 2653702, upload-time = "2026-01-18T13:34:11.062Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d9/52/1064f510b141bd54025f9b55105e26d1fa970b9be67ad766380a3c9b74b0/starlette-0.50.0-py3-none-any.whl", hash = "sha256:9e5391843ec9b6e472eed1365a78c8098cfceb7a74bfd4d6b1c0c0095efb3bca", size = 74033, upload-time = "2025-11-01T15:25:25.461Z" }, + { url = "https://files.pythonhosted.org/packages/81/0d/13d1d239a25cbfb19e740db83143e95c772a1fe10202dda4b76792b114dd/starlette-0.52.1-py3-none-any.whl", hash = "sha256:0029d43eb3d273bc4f83a08720b4912ea4b071087a3b48db01b7c839f7954d74", size = 74272, upload-time = "2026-01-18T13:34:09.188Z" }, ] [[package]] From c258d32d448f62db63ccd4bbcdb7ba29c575b1c0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C6=B0u=20Quang=20V=C5=A9?= Date: Tue, 10 Feb 2026 08:46:53 +0700 Subject: [PATCH 089/291] Move `maketrans` to global variable --- app/services/lmdb.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/app/services/lmdb.py b/app/services/lmdb.py index b8861dc..0ba6c3a 100644 --- a/app/services/lmdb.py +++ b/app/services/lmdb.py @@ -20,6 +20,7 @@ from ..utils.singleton import Singleton _VOLATILE_SYMBOLS = string.whitespace + string.punctuation +_VOLATILE_TRANS_TABLE = str.maketrans("", "", _VOLATILE_SYMBOLS) def _fuzzy_normalize(text: str | None) -> str | None: @@ -28,7 +29,7 @@ def _fuzzy_normalize(text: str | None) -> str | None: """ if text is None: return None - return text.lower().translate(str.maketrans("", "", _VOLATILE_SYMBOLS)) + return text.lower().translate(_VOLATILE_TRANS_TABLE) def _normalize_text(text: str | None, fuzzy: bool = False) -> str | None: From 157028f026950afc90140dae9568869c0ec27400 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C6=B0u=20Quang=20V=C5=A9?= Date: Tue, 10 Feb 2026 08:52:22 +0700 Subject: [PATCH 090/291] Move `maketrans` to global variable --- app/services/lmdb.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/app/services/lmdb.py b/app/services/lmdb.py index 0ba6c3a..a94e090 100644 --- a/app/services/lmdb.py +++ b/app/services/lmdb.py @@ -19,8 +19,7 @@ ) from ..utils.singleton import Singleton -_VOLATILE_SYMBOLS = string.whitespace + string.punctuation -_VOLATILE_TRANS_TABLE = str.maketrans("", "", _VOLATILE_SYMBOLS) +_VOLATILE_TRANS_TABLE = str.maketrans("", "", string.whitespace + string.punctuation) def _fuzzy_normalize(text: str | None) -> str | None: From 5f9a7ece8e6027afddf2e04da60e6ab562a72874 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C6=B0u=20Quang=20V=C5=A9?= Date: Tue, 10 Feb 2026 10:30:53 +0700 Subject: [PATCH 091/291] Refactor: Add a filter to catch orphaned tool calls. --- app/server/chat.py | 63 ++++++++++++++++++++++++++++++---------------- 1 file changed, 42 insertions(+), 21 deletions(-) diff --git a/app/server/chat.py b/app/server/chat.py index 701c1f6..414349a 100644 --- a/app/server/chat.py +++ b/app/server/chat.py @@ -776,39 +776,44 @@ def __init__(self): self.TOOL_START = "[function_calls]" self.TOOL_END = "[/function_calls]" + self.ORPHAN_START = "[call:" + self.ORPHAN_END = "[/call]" self.RESPONSE_START = "[function_responses]" self.RESPONSE_END = "[/function_responses]" self.TAG_START = "<|im_start|>" self.TAG_END = "<|im_end|>" self.HINT_START = f"\n{TOOL_HINT_LINE_START}" if TOOL_HINT_LINE_START else "" self.HINT_END = TOOL_HINT_LINE_END - self.TOOL_PREFIX = "[call:" - self.WATCH_PREFIXES = [ + self.WATCH_MARKERS = [ self.TOOL_START, + self.ORPHAN_START, self.RESPONSE_START, self.TAG_START, self.TAG_END, ] if self.HINT_START: - self.WATCH_PREFIXES.append(self.HINT_START) + self.WATCH_MARKERS.append(self.HINT_START) def process(self, chunk: str) -> str: self.buffer += chunk output = [] while self.buffer: + buf_low = self.buffer.lower() if self.state == "NORMAL": - tool_idx = self.buffer.find(self.TOOL_START) - resp_idx = self.buffer.find(self.RESPONSE_START) - tag_idx = self.buffer.find(self.TAG_START) - end_idx = self.buffer.find(self.TAG_END) - hint_idx = self.buffer.find(self.HINT_START) if self.HINT_START else -1 + tool_idx = buf_low.find(self.TOOL_START) + orphan_idx = buf_low.find(self.ORPHAN_START) + resp_idx = buf_low.find(self.RESPONSE_START) + tag_idx = buf_low.find(self.TAG_START) + end_idx = buf_low.find(self.TAG_END) + hint_idx = buf_low.find(self.HINT_START) if self.HINT_START else -1 indices = [ (i, t) for i, t in [ (tool_idx, "TOOL"), + (orphan_idx, "ORPHAN"), (resp_idx, "RESP"), (tag_idx, "TAG"), (end_idx, "END"), @@ -818,11 +823,12 @@ def process(self, chunk: str) -> str: ] if not indices: - # Guard against split start markers + # Guard against split markers (case-insensitive) keep_len = 0 - for p in self.WATCH_PREFIXES: - for i in range(len(p) - 1, 0, -1): - if self.buffer.endswith(p[:i]): + for marker in self.WATCH_MARKERS: + m_low = marker.lower() + for i in range(len(m_low) - 1, 0, -1): + if buf_low.endswith(m_low[:i]): keep_len = max(keep_len, i) break yield_len = len(self.buffer) - keep_len @@ -840,6 +846,10 @@ def process(self, chunk: str) -> str: self.state = "IN_TOOL" self.block_buffer = "" self.buffer = self.buffer[len(self.TOOL_START) :] + elif m_type == "ORPHAN": + self.state = "IN_ORPHAN" + self.block_buffer = "" + self.buffer = self.buffer[len(self.ORPHAN_START) :] elif m_type == "RESP": self.state = "IN_RESP" self.buffer = self.buffer[len(self.RESPONSE_START) :] @@ -853,43 +863,53 @@ def process(self, chunk: str) -> str: self.buffer = self.buffer[len(self.HINT_START) :] elif self.state == "IN_HINT": - end_idx = self.buffer.find(self.HINT_END) + end_idx = buf_low.find(self.HINT_END.lower()) if end_idx != -1: self.buffer = self.buffer[end_idx + len(self.HINT_END) :] self.state = "NORMAL" else: - # Keep end of buffer to avoid missing split HINT_END keep_len = len(self.HINT_END) - 1 if len(self.buffer) > keep_len: self.buffer = self.buffer[-keep_len:] break elif self.state == "IN_RESP": - end_idx = self.buffer.find(self.RESPONSE_END) + end_idx = buf_low.find(self.RESPONSE_END.lower()) if end_idx != -1: self.buffer = self.buffer[end_idx + len(self.RESPONSE_END) :] self.state = "NORMAL" else: - # Keep end of buffer to avoid missing split RESPONSE_END keep_len = len(self.RESPONSE_END) - 1 if len(self.buffer) > keep_len: self.buffer = self.buffer[-keep_len:] break elif self.state == "IN_TOOL": - end_idx = self.buffer.find(self.TOOL_END) + end_idx = buf_low.find(self.TOOL_END.lower()) if end_idx != -1: self.block_buffer += self.buffer[:end_idx] self.buffer = self.buffer[end_idx + len(self.TOOL_END) :] self.state = "NORMAL" else: - # Accumulate and keep potential split end marker keep_len = len(self.TOOL_END) - 1 if len(self.buffer) > keep_len: self.block_buffer += self.buffer[:-keep_len] self.buffer = self.buffer[-keep_len:] break + elif self.state == "IN_ORPHAN": + end_idx = buf_low.find(self.ORPHAN_END.lower()) + if end_idx != -1: + self.block_buffer += self.buffer[:end_idx] + self.buffer = self.buffer[end_idx + len(self.ORPHAN_END) :] + self.state = "NORMAL" + else: + keep_len = len(self.ORPHAN_END) - 1 + if len(self.buffer) > keep_len: + self.block_buffer += self.buffer[:-keep_len] + self.buffer = self.buffer[-keep_len:] + break + elif self.state == "IN_TAG": nl_idx = self.buffer.find("\n") if nl_idx != -1: @@ -900,7 +920,7 @@ def process(self, chunk: str) -> str: break elif self.state == "IN_BLOCK": - end_idx = self.buffer.find(self.TAG_END) + end_idx = buf_low.find(self.TAG_END.lower()) if end_idx != -1: content = self.buffer[:end_idx] if self.current_role != "tool": @@ -909,7 +929,6 @@ def process(self, chunk: str) -> str: self.state = "NORMAL" self.current_role = "" else: - # Yield safe part and keep potential split TAG_END keep_len = len(self.TAG_END) - 1 if self.current_role != "tool": if len(self.buffer) > keep_len: @@ -926,8 +945,10 @@ def process(self, chunk: str) -> str: def flush(self) -> str: res = "" if self.state == "IN_TOOL": - if self.TOOL_PREFIX not in self.block_buffer.lower(): + if self.ORPHAN_START.lower() not in self.block_buffer.lower(): res = f"{self.TOOL_START}{self.block_buffer}" + elif self.state == "IN_ORPHAN": + res = f"{self.ORPHAN_START}{self.block_buffer}" elif self.state == "IN_BLOCK" and self.current_role != "tool": res = self.buffer elif self.state in ("IN_RESP", "IN_HINT"): From c81c2cefd8bd76e83c0edf84ebd2a0a11ad28ab8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C6=B0u=20Quang=20V=C5=A9?= Date: Tue, 10 Feb 2026 12:48:20 +0700 Subject: [PATCH 092/291] Update required dependencies --- pyproject.toml | 2 +- uv.lock | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 47cd86f..58391ff 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,7 +6,7 @@ readme = "README.md" requires-python = "==3.12.*" dependencies = [ "fastapi>=0.128.6", - "gemini-webapi>=1.19.0", + "gemini-webapi>=1.19.1", "lmdb>=1.7.5", "loguru>=0.7.3", "orjson>=3.11.7", diff --git a/uv.lock b/uv.lock index ea28c0e..34b5cc8 100644 --- a/uv.lock +++ b/uv.lock @@ -123,7 +123,7 @@ dev = [{ name = "ruff", specifier = ">=0.15.0" }] [[package]] name = "gemini-webapi" -version = "1.19.0" +version = "1.19.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "httpx", extra = ["http2"] }, @@ -131,9 +131,9 @@ dependencies = [ { name = "orjson" }, { name = "pydantic" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/5f/1f/8314b620db12855e6aa9c55e05428fa30eb7f00fb61b1de7db42734ef2b2/gemini_webapi-1.19.0.tar.gz", hash = "sha256:1f65e6a2e4a461f4ed4fb01dc76c2de4ed517af549f6ce34b96b9986c11af5dd", size = 266822, upload-time = "2026-02-09T23:16:34.446Z" } +sdist = { url = "https://files.pythonhosted.org/packages/e5/d1/c61ee05ca6e20c70caa25a3f0f12e2a810bbc6b39e588ff937821de43690/gemini_webapi-1.19.1.tar.gz", hash = "sha256:a52afdfc2d9f6e87a6ae8cd926fb2ce5c562a0a99dc75ce97d8d50ffc2a3e133", size = 266761, upload-time = "2026-02-10T05:44:29.195Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/88/3b/71567ce13357d1139dfa0578c4073d6a8c523c4a28f3843194b639bf9d1e/gemini_webapi-1.19.0-py3-none-any.whl", hash = "sha256:47ab49f018cc01bf4b772910f7843af895f5e43d5a18b5ec7063b6f61e535921", size = 63498, upload-time = "2026-02-09T23:16:33.328Z" }, + { url = "https://files.pythonhosted.org/packages/4a/0b/7a73919ee8621f6559ae679a20d754959b989a3f09cf20478d89971f40b4/gemini_webapi-1.19.1-py3-none-any.whl", hash = "sha256:0dc4c7daa58d281722d52d6acf520f2e850c6c3c6020080fdbc5f77736c8be9a", size = 63500, upload-time = "2026-02-10T05:44:27.692Z" }, ] [[package]] From a17082532189d852b61e8b791a49844b4bb922f3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C6=B0u=20Quang=20V=C5=A9?= Date: Tue, 10 Feb 2026 12:53:10 +0700 Subject: [PATCH 093/291] Add dependabot --- .github/dependabot.yaml | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 .github/dependabot.yaml diff --git a/.github/dependabot.yaml b/.github/dependabot.yaml new file mode 100644 index 0000000..5ace460 --- /dev/null +++ b/.github/dependabot.yaml @@ -0,0 +1,6 @@ +version: 2 +updates: + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "weekly" From a0136af06d4e43bd6ac4a9ba7a2b05509358e187 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C6=B0u=20Quang=20V=C5=A9?= Date: Wed, 11 Feb 2026 10:37:50 +0700 Subject: [PATCH 094/291] Refactor: Implement the logic changes recommended by Copilot - Remove orphaned tool calls to prevent leaking internal tool-call information. - Define limits for the `timeout`, `watchdog_timeout`, and `refresh_interval` ranges. - Revise the fuzzy match logic to prevent accidental session reuse and avoid any possible content leakage between requests. --- app/server/chat.py | 4 +- app/services/lmdb.py | 252 ++++++++++++++++++++++++------------------- app/utils/config.py | 10 +- config/config.yaml | 6 +- 4 files changed, 151 insertions(+), 121 deletions(-) diff --git a/app/server/chat.py b/app/server/chat.py index 414349a..30a6b3a 100644 --- a/app/server/chat.py +++ b/app/server/chat.py @@ -947,11 +947,9 @@ def flush(self) -> str: if self.state == "IN_TOOL": if self.ORPHAN_START.lower() not in self.block_buffer.lower(): res = f"{self.TOOL_START}{self.block_buffer}" - elif self.state == "IN_ORPHAN": - res = f"{self.ORPHAN_START}{self.block_buffer}" elif self.state == "IN_BLOCK" and self.current_role != "tool": res = self.buffer - elif self.state in ("IN_RESP", "IN_HINT"): + elif self.state in ("IN_ORPHAN", "IN_RESP", "IN_HINT"): res = "" elif self.state == "NORMAL": res = self.buffer diff --git a/app/services/lmdb.py b/app/services/lmdb.py index a94e090..c90f537 100644 --- a/app/services/lmdb.py +++ b/app/services/lmdb.py @@ -25,6 +25,7 @@ def _fuzzy_normalize(text: str | None) -> str | None: """ Lowercase and remove all whitespace and punctuation. + Used as a fallback for complex/malformed contents matching. """ if text is None: return None @@ -38,7 +39,7 @@ def _normalize_text(text: str | None, fuzzy: bool = False) -> str | None: if text is None: return None - # Unicode normalization + # Unicode normalization to NFC text = unicodedata.normalize("NFC", text) # Basic cleaning @@ -49,7 +50,8 @@ def _normalize_text(text: str | None, fuzzy: bool = False) -> str | None: if fuzzy: return _fuzzy_normalize(text) - return text if text else None + # Always strip to ensure trailing newlines/spaces don't break exact matches + return text.strip() if text.strip() else None def _hash_message(message: Message, fuzzy: bool = False) -> str: @@ -157,7 +159,6 @@ def __init__( max_db_size: Maximum database size in bytes (default: 256 MB) retention_days: Number of days to retain conversations (default: 14, 0 disables cleanup) """ - if db_path is None: db_path = g_config.storage.path if max_db_size is None: @@ -174,9 +175,11 @@ def __init__( self._init_environment() def _ensure_db_path(self) -> None: + """Create database directory if it doesn't exist.""" self.db_path.parent.mkdir(parents=True, exist_ok=True) def _init_environment(self) -> None: + """Initialize LMDB environment.""" try: self._env = lmdb.open( str(self.db_path), @@ -187,12 +190,18 @@ def _init_environment(self) -> None: meminit=False, ) logger.info(f"LMDB environment initialized at {self.db_path}") - except Exception as e: + except lmdb.Error as e: logger.error(f"Failed to initialize LMDB environment: {e}") raise @contextmanager def _get_transaction(self, write: bool = False): + """ + Context manager for LMDB transactions. + + Args: + write: Whether the transaction should be writable. + """ if not self._env: raise RuntimeError("LMDB environment not initialized") @@ -201,12 +210,57 @@ def _get_transaction(self, write: bool = False): yield txn if write: txn.commit() - except Exception: + except lmdb.Error: + if write: + txn.abort() + raise + except Exception as e: + logger.error(f"Unexpected error in LMDB transaction: {e}") if write: txn.abort() raise - finally: - pass # Transaction is automatically cleaned up + + @staticmethod + def _decode_index_value(data: bytes) -> List[str]: + """Decode index value, handling both legacy single-string and new list-of-strings formats.""" + if not data: + return [] + if data.startswith(b"["): + try: + val = orjson.loads(data) + if isinstance(val, list): + return [str(v) for v in val] + except orjson.JSONDecodeError: + pass + try: + return [data.decode("utf-8")] + except UnicodeDecodeError: + return [] + + @staticmethod + def _update_index(txn: lmdb.Transaction, prefix: str, hash_val: str, storage_key: str): + """Add a storage key to the index for a given hash, avoiding duplicates.""" + idx_key = f"{prefix}{hash_val}".encode("utf-8") + existing = txn.get(idx_key) + keys = LMDBConversationStore._decode_index_value(existing) if existing else [] + if storage_key not in keys: + keys.append(storage_key) + txn.put(idx_key, orjson.dumps(keys)) + + @staticmethod + def _remove_from_index(txn: lmdb.Transaction, prefix: str, hash_val: str, storage_key: str): + """Remove a specific storage key from the index for a given hash.""" + idx_key = f"{prefix}{hash_val}".encode("utf-8") + existing = txn.get(idx_key) + if not existing: + return + keys = LMDBConversationStore._decode_index_value(existing) + if storage_key in keys: + keys.remove(storage_key) + if keys: + txn.put(idx_key, orjson.dumps(keys)) + else: + txn.delete(idx_key) def store( self, @@ -226,12 +280,10 @@ def store( if not conv: raise ValueError("Messages list cannot be empty") - # Sanitize messages before computing hash and storing to ensure consistency - # with the search (find) logic, which also sanitizes its prefix. + # Ensure consistent sanitization before hashing and storage sanitized_messages = self.sanitize_assistant_messages(conv.messages) conv.messages = sanitized_messages - # Generate hash for the message list message_hash = _hash_conversation(conv.client_id, conv.model, conv.messages) fuzzy_hash = _hash_conversation(conv.client_id, conv.model, conv.messages, fuzzy=True) storage_key = custom_key or message_hash @@ -247,21 +299,19 @@ def store( with self._get_transaction(write=True) as txn: txn.put(storage_key.encode("utf-8"), value, overwrite=True) - txn.put( - f"{self.HASH_LOOKUP_PREFIX}{message_hash}".encode("utf-8"), - storage_key.encode("utf-8"), - ) - - txn.put( - f"{self.FUZZY_LOOKUP_PREFIX}{fuzzy_hash}".encode("utf-8"), - storage_key.encode("utf-8"), - ) + self._update_index(txn, self.HASH_LOOKUP_PREFIX, message_hash, storage_key) + self._update_index(txn, self.FUZZY_LOOKUP_PREFIX, fuzzy_hash, storage_key) logger.debug(f"Stored {len(conv.messages)} messages with key: {storage_key[:12]}") return storage_key + except lmdb.Error as e: + logger.error(f"LMDB error while storing messages with key {storage_key[:12]}: {e}") + raise except Exception as e: - logger.error(f"Failed to store messages with key {storage_key[:12]}: {e}") + logger.error( + f"Unexpected error while storing messages with key {storage_key[:12]}: {e}" + ) raise def get(self, key: str) -> Optional[ConversationInStore]: @@ -280,29 +330,37 @@ def get(self, key: str) -> Optional[ConversationInStore]: if not data: return None - storage_data = orjson.loads(data) # type: ignore + storage_data = orjson.loads(data) conv = ConversationInStore.model_validate(storage_data) logger.debug(f"Retrieved {len(conv.messages)} messages with key: {key[:12]}") return conv - + except (lmdb.Error, orjson.JSONDecodeError) as e: + logger.error(f"Failed to retrieve/parse messages with key {key[:12]}: {e}") + return None except Exception as e: - logger.error(f"Failed to retrieve messages with key {key[:12]}: {e}") + logger.error(f"Unexpected error retrieving messages with key {key[:12]}: {e}") return None def find(self, model: str, messages: List[Message]) -> Optional[ConversationInStore]: """ Search conversation data by message list. + Tries raw matching, then sanitized matching, and finally fuzzy matching. + + Args: + model: Model name + messages: List of messages to match + + Returns: + ConversationInStore or None if not found """ if not messages: return None - # --- Find with raw messages --- if conv := self._find_by_message_list(model, messages): logger.debug(f"Session found for '{model}' with {len(messages)} raw messages.") return conv - # --- Find with cleaned messages --- cleaned_messages = self.sanitize_assistant_messages(messages) if cleaned_messages != messages: if conv := self._find_by_message_list(model, cleaned_messages): @@ -311,7 +369,6 @@ def find(self, model: str, messages: List[Message]) -> Optional[ConversationInSt ) return conv - # --- Find with fuzzy matching --- if conv := self._find_by_message_list(model, messages, fuzzy=True): logger.debug(f"Session found for '{model}' with fuzzy matching.") return conv @@ -325,18 +382,49 @@ def _find_by_message_list( messages: List[Message], fuzzy: bool = False, ) -> Optional[ConversationInStore]: - """Internal find implementation based on a message list.""" + """ + Internal find implementation based on a message list. + + Args: + model: Model name + messages: Message list to hash + fuzzy: Whether to use fuzzy hashing + + Returns: + ConversationInStore or None if not found + """ prefix = self.FUZZY_LOOKUP_PREFIX if fuzzy else self.HASH_LOOKUP_PREFIX + target_len = len(messages) + for c in g_config.gemini.clients: message_hash = _hash_conversation(c.id, model, messages, fuzzy=fuzzy) key = f"{prefix}{message_hash}" try: with self._get_transaction(write=False) as txn: - if mapped := txn.get(key.encode("utf-8")): # type: ignore - return self.get(mapped.decode("utf-8")) # type: ignore - except Exception as e: + if mapped := txn.get(key.encode("utf-8")): + candidate_keys = self._decode_index_value(mapped) + # Try candidates from newest to oldest + for ck in reversed(candidate_keys): + if conv := self.get(ck): + if len(conv.messages) != target_len: + continue + + if fuzzy: + # For fuzzy matching, verify each message hash individually + # to prevent semantic collisions (e.g., "1.2" vs "12") + match_found = True + for i in range(target_len): + if _hash_message( + conv.messages[i], fuzzy=True + ) != _hash_message(messages[i], fuzzy=True): + match_found = False + break + if not match_found: + continue + return conv + except lmdb.Error as e: logger.error( - f"Failed to retrieve messages by message list for hash {message_hash} and client {c.id}: {e}" + f"LMDB error while searching for hash {message_hash} and client {c.id}: {e}" ) continue @@ -345,74 +433,42 @@ def _find_by_message_list( return None def exists(self, key: str) -> bool: - """ - Check if a key exists in the store. - - Args: - key: Storage key to check - - Returns: - bool: True if key exists, False otherwise - """ + """Check if a key exists in the store.""" try: with self._get_transaction(write=False) as txn: return txn.get(key.encode("utf-8")) is not None - except Exception as e: + except lmdb.Error as e: logger.error(f"Failed to check existence of key {key}: {e}") return False def delete(self, key: str) -> Optional[ConversationInStore]: - """ - Delete conversation model by key. - - Args: - key: Storage key to delete - - Returns: - ConversationInStore: The deleted conversation data, or None if not found - """ + """Delete conversation model by key.""" try: with self._get_transaction(write=True) as txn: - # Get data first to clean up hash mapping data = txn.get(key.encode("utf-8")) if not data: return None - storage_data = orjson.loads(data) # type: ignore + storage_data = orjson.loads(data) conv = ConversationInStore.model_validate(storage_data) message_hash = _hash_conversation(conv.client_id, conv.model, conv.messages) fuzzy_hash = _hash_conversation( conv.client_id, conv.model, conv.messages, fuzzy=True ) - # Delete main data txn.delete(key.encode("utf-8")) - # Clean up hash mapping if it exists - if message_hash and key != message_hash: - txn.delete(f"{self.HASH_LOOKUP_PREFIX}{message_hash}".encode("utf-8")) - - # Always clean up fuzzy mapping - txn.delete(f"{self.FUZZY_LOOKUP_PREFIX}{fuzzy_hash}".encode("utf-8")) + self._remove_from_index(txn, self.HASH_LOOKUP_PREFIX, message_hash, key) + self._remove_from_index(txn, self.FUZZY_LOOKUP_PREFIX, fuzzy_hash, key) logger.debug(f"Deleted messages with key: {key[:12]}") return conv - - except Exception as e: + except (lmdb.Error, orjson.JSONDecodeError) as e: logger.error(f"Failed to delete messages with key {key[:12]}: {e}") return None def keys(self, prefix: str = "", limit: Optional[int] = None) -> List[str]: - """ - List all keys in the store, optionally filtered by prefix. - - Args: - prefix: Optional prefix to filter keys - limit: Optional limit on number of keys returned - - Returns: - List of keys - """ + """List all keys in the store, optionally filtered by prefix.""" keys = [] try: with self._get_transaction(write=False) as txn: @@ -422,7 +478,7 @@ def keys(self, prefix: str = "", limit: Optional[int] = None) -> List[str]: count = 0 for key, _ in cursor: key_str = key.decode("utf-8") - # Skip internal hash mappings + # Skip internal index mappings if key_str.startswith(self.HASH_LOOKUP_PREFIX) or key_str.startswith( self.FUZZY_LOOKUP_PREFIX ): @@ -431,25 +487,14 @@ def keys(self, prefix: str = "", limit: Optional[int] = None) -> List[str]: if not prefix or key_str.startswith(prefix): keys.append(key_str) count += 1 - if limit and count >= limit: break - - except Exception as e: + except lmdb.Error as e: logger.error(f"Failed to list keys: {e}") - return keys def cleanup_expired(self, retention_days: Optional[int] = None) -> int: - """ - Delete conversations older than the given retention period. - - Args: - retention_days: Optional override for retention period in days. - - Returns: - Number of conversations removed. - """ + """Delete conversations older than the given retention period.""" retention_value = ( self.retention_days if retention_days is None else max(0, int(retention_days)) ) @@ -463,7 +508,6 @@ def cleanup_expired(self, retention_days: Optional[int] = None) -> int: try: with self._get_transaction(write=False) as txn: cursor = txn.cursor() - for key_bytes, value_bytes in cursor: key_str = key_bytes.decode("utf-8") if key_str.startswith(self.HASH_LOOKUP_PREFIX) or key_str.startswith( @@ -472,9 +516,9 @@ def cleanup_expired(self, retention_days: Optional[int] = None) -> int: continue try: - storage_data = orjson.loads(value_bytes) # type: ignore[arg-type] + storage_data = orjson.loads(value_bytes) conv = ConversationInStore.model_validate(storage_data) - except Exception as exc: + except (orjson.JSONDecodeError, Exception) as exc: logger.warning(f"Failed to decode record for key {key_str}: {exc}") continue @@ -484,7 +528,7 @@ def cleanup_expired(self, retention_days: Optional[int] = None) -> int: if timestamp < cutoff: expired_entries.append((key_str, conv)) - except Exception as exc: + except lmdb.Error as exc: logger.error(f"Failed to scan LMDB for retention cleanup: {exc}") raise @@ -501,15 +545,13 @@ def cleanup_expired(self, retention_days: Optional[int] = None) -> int: message_hash = _hash_conversation(conv.client_id, conv.model, conv.messages) if message_hash: - if key_str != message_hash: - txn.delete(f"{self.HASH_LOOKUP_PREFIX}{message_hash}".encode("utf-8")) - + self._remove_from_index(txn, self.HASH_LOOKUP_PREFIX, message_hash, key_str) fuzzy_hash = _hash_conversation( conv.client_id, conv.model, conv.messages, fuzzy=True ) - txn.delete(f"{self.FUZZY_LOOKUP_PREFIX}{fuzzy_hash}".encode("utf-8")) + self._remove_from_index(txn, self.FUZZY_LOOKUP_PREFIX, fuzzy_hash, key_str) removed += 1 - except Exception as exc: + except lmdb.Error as exc: logger.error(f"Failed to delete expired conversations: {exc}") raise @@ -521,19 +563,13 @@ def cleanup_expired(self, retention_days: Optional[int] = None) -> int: return removed def stats(self) -> Dict[str, Any]: - """ - Get database statistics. - - Returns: - Dict with database statistics - """ + """Get database statistics.""" if not self._env: logger.error("LMDB environment not initialized") return {} - try: return self._env.stat() - except Exception as e: + except lmdb.Error as e: logger.error(f"Failed to get database stats: {e}") return {} @@ -550,21 +586,15 @@ def __del__(self): @staticmethod def remove_think_tags(text: str) -> str: - """ - Remove all ... tags and strip whitespace. - """ + """Remove all ... tags and strip whitespace.""" if not text: return text - # 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]: - """ - Produce a canonical history where assistant messages are cleaned of - internal markers and tool call blocks are moved to metadata. - """ + """Clean assistant messages of internal markers and move tool calls to metadata.""" cleaned_messages = [] for msg in messages: if msg.role == "assistant": @@ -596,7 +626,6 @@ def sanitize_assistant_messages(messages: list[Message]) -> list[Message]: 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: @@ -625,5 +654,4 @@ def sanitize_assistant_messages(messages: list[Message]) -> list[Message]: cleaned_messages.append(msg) else: cleaned_messages.append(msg) - return cleaned_messages diff --git a/app/utils/config.py b/app/utils/config.py index e62832d..3b24931 100644 --- a/app/utils/config.py +++ b/app/utils/config.py @@ -83,11 +83,15 @@ class GeminiConfig(BaseModel): default="append", description="Strategy for loading models: 'append' merges custom with default, 'overwrite' uses only custom", ) - timeout: int = Field(default=300, ge=1, description="Init timeout") - watchdog_timeout: int = Field(default=60, ge=1, description="Watchdog timeout") + timeout: int = Field(default=300, ge=30, description="Init timeout") + watchdog_timeout: int = Field( + default=60, ge=10, le=75, description="Watchdog timeout in seconds (Not more than 75s)" + ) auto_refresh: bool = Field(True, description="Enable auto-refresh for Gemini cookies") refresh_interval: int = Field( - default=540, ge=1, description="Interval in seconds to refresh Gemini cookies" + default=540, + ge=60, + description="Interval in seconds to refresh Gemini cookies (Not less than 60s)", ) verbose: bool = Field(False, description="Enable verbose logging for Gemini API requests") max_chars_per_request: int = Field( diff --git a/config/config.yaml b/config/config.yaml index 2873d48..3d5e6f4 100644 --- a/config/config.yaml +++ b/config/config.yaml @@ -22,10 +22,10 @@ gemini: secure_1psid: "YOUR_SECURE_1PSID_HERE" secure_1psidts: "YOUR_SECURE_1PSIDTS_HERE" proxy: null # Optional proxy URL (null/empty means direct connection) - timeout: 300 # Init timeout in seconds - watchdog_timeout: 60 # Watchdog timeout in seconds (No longer than 75 seconds) + timeout: 300 # Init timeout in seconds (Not less than 30s) + watchdog_timeout: 60 # Watchdog timeout in seconds (Not more than 75s) auto_refresh: true # Auto-refresh session cookies - refresh_interval: 540 # Refresh interval in seconds + refresh_interval: 540 # Refresh interval in seconds (Not less than 60s) verbose: false # Enable verbose logging for Gemini requests max_chars_per_request: 1000000 # Maximum characters Gemini Web accepts per request. Non-pro users might have a lower limit model_strategy: "append" # Strategy: 'append' (default + custom) or 'overwrite' (custom only) From 5eb9f509d451d76739da1f34b5003b1d7628279b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C6=B0u=20Quang=20V=C5=A9?= Date: Wed, 11 Feb 2026 12:24:06 +0700 Subject: [PATCH 095/291] Refactor: Optimize fuzzy matching logic --- app/services/lmdb.py | 31 ++++++++++++++++--------------- app/utils/config.py | 2 +- scripts/dump_lmdb.py | 2 +- 3 files changed, 18 insertions(+), 17 deletions(-) diff --git a/app/services/lmdb.py b/app/services/lmdb.py index c90f537..4b57f60 100644 --- a/app/services/lmdb.py +++ b/app/services/lmdb.py @@ -370,7 +370,9 @@ def find(self, model: str, messages: List[Message]) -> Optional[ConversationInSt return conv if conv := self._find_by_message_list(model, messages, fuzzy=True): - logger.debug(f"Session found for '{model}' with fuzzy matching.") + logger.debug( + f"Session found for '{model}' with {len(messages)} fuzzy matching messages." + ) return conv logger.debug(f"No session found for '{model}' with {len(messages)} messages.") @@ -396,6 +398,8 @@ def _find_by_message_list( prefix = self.FUZZY_LOOKUP_PREFIX if fuzzy else self.HASH_LOOKUP_PREFIX target_len = len(messages) + target_hashes = [_hash_message(m, fuzzy=fuzzy) for m in messages] + for c in g_config.gemini.clients: message_hash = _hash_conversation(c.id, model, messages, fuzzy=fuzzy) key = f"{prefix}{message_hash}" @@ -403,25 +407,22 @@ def _find_by_message_list( with self._get_transaction(write=False) as txn: if mapped := txn.get(key.encode("utf-8")): candidate_keys = self._decode_index_value(mapped) - # Try candidates from newest to oldest for ck in reversed(candidate_keys): if conv := self.get(ck): if len(conv.messages) != target_len: continue - if fuzzy: - # For fuzzy matching, verify each message hash individually - # to prevent semantic collisions (e.g., "1.2" vs "12") - match_found = True - for i in range(target_len): - if _hash_message( - conv.messages[i], fuzzy=True - ) != _hash_message(messages[i], fuzzy=True): - match_found = False - break - if not match_found: - continue - return conv + match_found = True + for i in range(target_len): + if ( + _hash_message(conv.messages[i], fuzzy=fuzzy) + != target_hashes[i] + ): + match_found = False + break + + if match_found: + return conv except lmdb.Error as e: logger.error( f"LMDB error while searching for hash {message_hash} and client {c.id}: {e}" diff --git a/app/utils/config.py b/app/utils/config.py index 3b24931..4c1709f 100644 --- a/app/utils/config.py +++ b/app/utils/config.py @@ -83,7 +83,7 @@ class GeminiConfig(BaseModel): default="append", description="Strategy for loading models: 'append' merges custom with default, 'overwrite' uses only custom", ) - timeout: int = Field(default=300, ge=30, description="Init timeout") + timeout: int = Field(default=300, ge=30, description="Init timeout in seconds") watchdog_timeout: int = Field( default=60, ge=10, le=75, description="Watchdog timeout in seconds (Not more than 75s)" ) diff --git a/scripts/dump_lmdb.py b/scripts/dump_lmdb.py index b06b1b4..a331325 100644 --- a/scripts/dump_lmdb.py +++ b/scripts/dump_lmdb.py @@ -42,7 +42,7 @@ def dump_lmdb(path: Path, keys: Iterable[str] | None = None) -> None: records = _dump_all(txn) env.close() - print(orjson.dumps(records, option=orjson.OPT_INDENT_2).decode()) + print(orjson.dumps(records, option=orjson.OPT_INDENT_2).decode("utf-8")) def main() -> None: From 971f2c70f81ac82640cb6a9f3c800be0d7c1143a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C6=B0u=20Quang=20V=C5=A9?= Date: Wed, 11 Feb 2026 12:28:11 +0700 Subject: [PATCH 096/291] Update dependencies --- pyproject.toml | 2 +- uv.lock | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 58391ff..d3a1aaf 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,7 +5,7 @@ description = "FastAPI Server built on Gemini Web API" readme = "README.md" requires-python = "==3.12.*" dependencies = [ - "fastapi>=0.128.6", + "fastapi>=0.128.7", "gemini-webapi>=1.19.1", "lmdb>=1.7.5", "loguru>=0.7.3", diff --git a/uv.lock b/uv.lock index 34b5cc8..c038f53 100644 --- a/uv.lock +++ b/uv.lock @@ -65,7 +65,7 @@ wheels = [ [[package]] name = "fastapi" -version = "0.128.6" +version = "0.128.7" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "annotated-doc" }, @@ -74,9 +74,9 @@ dependencies = [ { name = "typing-extensions" }, { name = "typing-inspection" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/83/d1/195005b5e45b443e305136df47ee7df4493d782e0c039dd0d97065580324/fastapi-0.128.6.tar.gz", hash = "sha256:0cb3946557e792d731b26a42b04912f16367e3c3135ea8290f620e234f2b604f", size = 374757, upload-time = "2026-02-09T17:27:03.541Z" } +sdist = { url = "https://files.pythonhosted.org/packages/a0/fc/af386750b3fd8d8828167e4c82b787a8eeca2eca5c5429c9db8bb7c70e04/fastapi-0.128.7.tar.gz", hash = "sha256:783c273416995486c155ad2c0e2b45905dedfaf20b9ef8d9f6a9124670639a24", size = 375325, upload-time = "2026-02-10T12:26:40.968Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/24/58/a2c4f6b240eeb148fb88cdac48f50a194aba760c1ca4988c6031c66a20ee/fastapi-0.128.6-py3-none-any.whl", hash = "sha256:bb1c1ef87d6086a7132d0ab60869d6f1ee67283b20fbf84ec0003bd335099509", size = 103674, upload-time = "2026-02-09T17:27:02.355Z" }, + { url = "https://files.pythonhosted.org/packages/af/1a/f983b45661c79c31be575c570d46c437a5409b67a939c1b3d8d6b3ed7a7f/fastapi-0.128.7-py3-none-any.whl", hash = "sha256:6bd9bd31cb7047465f2d3fa3ba3f33b0870b17d4eaf7cdb36d1576ab060ad662", size = 103630, upload-time = "2026-02-10T12:26:39.414Z" }, ] [[package]] @@ -106,8 +106,8 @@ dev = [ [package.metadata] requires-dist = [ - { name = "fastapi", specifier = ">=0.128.6" }, - { name = "gemini-webapi", specifier = ">=1.19.0" }, + { name = "fastapi", specifier = ">=0.128.7" }, + { name = "gemini-webapi", specifier = ">=1.19.1" }, { name = "lmdb", specifier = ">=1.7.5" }, { name = "loguru", specifier = ">=0.7.3" }, { name = "orjson", specifier = ">=3.11.7" }, From cad23795e41a97fd7cb7e5dd371c03d1bdbec607 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C6=B0u=20Quang=20V=C5=A9?= Date: Wed, 11 Feb 2026 20:46:42 +0700 Subject: [PATCH 097/291] Refactor: Update Markdown unescape helpers to prevent impacting clients like Roo Code --- app/services/client.py | 6 ++++++ app/utils/helper.py | 44 ++++++++++++++++-------------------------- 2 files changed, 23 insertions(+), 27 deletions(-) diff --git a/app/services/client.py b/app/services/client.py index 3cdd839..5d248c2 100644 --- a/app/services/client.py +++ b/app/services/client.py @@ -14,6 +14,10 @@ save_url_to_tempfile, ) +COMMONMARK_UNESCAPE_RE = re.compile( + r"\\([!\"#$%&'()*+,\-./:;<=>?@\[\\\]^_`{|}~])" +) # See: https://spec.commonmark.org/current/#backslash-escapes + FILE_PATH_PATTERN = re.compile( r"^(?=.*[./\\]|.*:\d+|^(?:Dockerfile|Makefile|Jenkinsfile|Procfile|Rakefile|Gemfile|Vagrantfile|Caddyfile|Justfile|LICENSE|README|CONTRIBUTING|CODEOWNERS|AUTHORS|NOTICE|CHANGELOG)$)([a-zA-Z0-9_./\\-]+(?::\d+)?)$", re.IGNORECASE, @@ -194,6 +198,8 @@ def extract_output(response: ModelOutput, include_thoughts: bool = True) -> str: else: text += str(response) + text = COMMONMARK_UNESCAPE_RE.sub(r"\1", text) + def extract_file_path_from_display_text(text_content: str) -> str | None: match = re.match(FILE_PATH_PATTERN, text_content) if match: diff --git a/app/utils/helper.py b/app/utils/helper.py index 384f5cd..67bfa55 100644 --- a/app/utils/helper.py +++ b/app/utils/helper.py @@ -191,7 +191,6 @@ def _process_tools_internal(text: str, extract: bool = True) -> tuple[str, list[ if not text: return text, [] - # Clean hints FIRST so they don't interfere with tool call regexes (e.g. example calls in hint) cleaned = strip_system_hints(text) tool_calls: list[ToolCall] = [] @@ -237,33 +236,24 @@ def _create_tool_call(name: str, raw_args: str) -> None: ) ) - def _replace_block(match: re.Match[str]) -> str: - block_content = match.group(1) - if not block_content: - return match.group(0) - - is_tool_block = bool(TOOL_CALL_RE.search(block_content)) - - if is_tool_block: - if extract: - for call_match in TOOL_CALL_RE.finditer(block_content): - name = (call_match.group(1) or "").strip() - raw_args = (call_match.group(2) or "").strip() - _create_tool_call(name, raw_args) - return "" - else: - return match.group(0) - - def _replace_orphan(match: re.Match[str]) -> str: - if extract: - name = (match.group(1) or "").strip() - raw_args = (match.group(2) or "").strip() - _create_tool_call(name, raw_args) - return "" - - cleaned = TOOL_BLOCK_RE.sub(_replace_block, cleaned) - cleaned = TOOL_CALL_RE.sub(_replace_orphan, cleaned) + all_calls = [] + for match in TOOL_CALL_RE.finditer(cleaned): + all_calls.append( + { + "start": match.start(), + "name": (match.group(1) or "").strip(), + "args": (match.group(2) or "").strip(), + } + ) + + all_calls.sort(key=lambda x: x["start"]) + + if extract: + for call in all_calls: + _create_tool_call(call["name"], call["args"]) + cleaned = TOOL_BLOCK_RE.sub("", cleaned) + cleaned = TOOL_CALL_RE.sub("", cleaned) cleaned = RESPONSE_BLOCK_RE.sub("", cleaned) cleaned = RESPONSE_ITEM_RE.sub("", cleaned) From 795b8d88a3cfb29e46c4e369c277e810308cc8e8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C6=B0u=20Quang=20V=C5=A9?= Date: Wed, 11 Feb 2026 21:20:11 +0700 Subject: [PATCH 098/291] Refactor: Update Markdown unescape helpers to prevent impacting clients like Roo Code --- app/services/client.py | 6 ------ app/utils/helper.py | 37 +++++++++++++++++++++++++++++-------- 2 files changed, 29 insertions(+), 14 deletions(-) diff --git a/app/services/client.py b/app/services/client.py index 5d248c2..3cdd839 100644 --- a/app/services/client.py +++ b/app/services/client.py @@ -14,10 +14,6 @@ save_url_to_tempfile, ) -COMMONMARK_UNESCAPE_RE = re.compile( - r"\\([!\"#$%&'()*+,\-./:;<=>?@\[\\\]^_`{|}~])" -) # See: https://spec.commonmark.org/current/#backslash-escapes - FILE_PATH_PATTERN = re.compile( r"^(?=.*[./\\]|.*:\d+|^(?:Dockerfile|Makefile|Jenkinsfile|Procfile|Rakefile|Gemfile|Vagrantfile|Caddyfile|Justfile|LICENSE|README|CONTRIBUTING|CODEOWNERS|AUTHORS|NOTICE|CHANGELOG)$)([a-zA-Z0-9_./\\-]+(?::\d+)?)$", re.IGNORECASE, @@ -198,8 +194,6 @@ def extract_output(response: ModelOutput, include_thoughts: bool = True) -> str: else: text += str(response) - text = COMMONMARK_UNESCAPE_RE.sub(r"\1", text) - def extract_file_path_from_display_text(text_content: str) -> str | None: match = re.match(FILE_PATH_PATTERN, text_content) if match: diff --git a/app/utils/helper.py b/app/utils/helper.py index 67bfa55..ce781bd 100644 --- a/app/utils/helper.py +++ b/app/utils/helper.py @@ -34,6 +34,9 @@ RESPONSE_ITEM_RE = re.compile( r"\[response:([^]]+)]\s*(.*?)\s*\[/response]", re.DOTALL | re.IGNORECASE ) +COMMONMARK_UNESCAPE_RE = re.compile( + r"\\([!\"#$%&'()*+,\-./:;<=>?@\[\\\]^_`{|}~])" +) # See: https://spec.commonmark.org/current/#backslash-escapes CONTROL_TOKEN_RE = re.compile(r"<\|im_(?:start|end)\|>") TOOL_HINT_STRIPPED = TOOL_WRAP_HINT.strip() _hint_lines = [line.strip() for line in TOOL_WRAP_HINT.split("\n") if line.strip()] @@ -192,9 +195,12 @@ def _process_tools_internal(text: str, extract: bool = True) -> tuple[str, list[ return text, [] cleaned = strip_system_hints(text) - tool_calls: list[ToolCall] = [] + def _unescape_markdown(s: str) -> str: + """Restores characters escaped for Markdown rendering.""" + return COMMONMARK_UNESCAPE_RE.sub(r"\1", s) + def _create_tool_call(name: str, raw_args: str) -> None: if not extract: return @@ -202,20 +208,33 @@ def _create_tool_call(name: str, raw_args: str) -> None: logger.warning("Encountered tool_call without a function name.") return + prev_name = "" + while name != prev_name: + prev_name = name + name = _unescape_markdown(name) + + def _try_parse_json(s: str) -> dict | None: + try: + return orjson.loads(s) + except orjson.JSONDecodeError: + try: + return orjson.loads(_unescape_markdown(s)) + except orjson.JSONDecodeError: + return None + arguments = raw_args - try: - parsed_args = orjson.loads(raw_args) - arguments = orjson.dumps(parsed_args, option=orjson.OPT_SORT_KEYS).decode("utf-8") - except orjson.JSONDecodeError: + parsed_args = _try_parse_json(raw_args) + + if parsed_args is None: json_match = re.search(r"({.*})", raw_args, re.DOTALL) if json_match: potential_json = json_match.group(1) - try: - parsed_args = orjson.loads(potential_json) + parsed_args = _try_parse_json(potential_json) + if parsed_args is not None: arguments = orjson.dumps(parsed_args, option=orjson.OPT_SORT_KEYS).decode( "utf-8" ) - except orjson.JSONDecodeError: + else: logger.warning( f"Failed to parse extracted JSON arguments for '{name}': {reprlib.repr(potential_json)}" ) @@ -223,6 +242,8 @@ def _create_tool_call(name: str, raw_args: str) -> None: logger.warning( f"Failed to parse tool call arguments for '{name}'. Passing raw string: {reprlib.repr(raw_args)}" ) + else: + arguments = orjson.dumps(parsed_args, option=orjson.OPT_SORT_KEYS).decode("utf-8") index = len(tool_calls) seed = f"{name}:{arguments}:{index}".encode("utf-8") From e85252a3c6000af5f5094560ad95aa8a8e78c184 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C6=B0u=20Quang=20V=C5=A9?= Date: Thu, 12 Feb 2026 07:59:28 +0700 Subject: [PATCH 099/291] Revert "Refactor: Update Markdown unescape helpers to prevent impacting clients like Roo Code" This reverts commit 795b8d88a3cfb29e46c4e369c277e810308cc8e8. --- app/services/client.py | 6 ++++++ app/utils/helper.py | 37 ++++++++----------------------------- 2 files changed, 14 insertions(+), 29 deletions(-) diff --git a/app/services/client.py b/app/services/client.py index 3cdd839..5d248c2 100644 --- a/app/services/client.py +++ b/app/services/client.py @@ -14,6 +14,10 @@ save_url_to_tempfile, ) +COMMONMARK_UNESCAPE_RE = re.compile( + r"\\([!\"#$%&'()*+,\-./:;<=>?@\[\\\]^_`{|}~])" +) # See: https://spec.commonmark.org/current/#backslash-escapes + FILE_PATH_PATTERN = re.compile( r"^(?=.*[./\\]|.*:\d+|^(?:Dockerfile|Makefile|Jenkinsfile|Procfile|Rakefile|Gemfile|Vagrantfile|Caddyfile|Justfile|LICENSE|README|CONTRIBUTING|CODEOWNERS|AUTHORS|NOTICE|CHANGELOG)$)([a-zA-Z0-9_./\\-]+(?::\d+)?)$", re.IGNORECASE, @@ -194,6 +198,8 @@ def extract_output(response: ModelOutput, include_thoughts: bool = True) -> str: else: text += str(response) + text = COMMONMARK_UNESCAPE_RE.sub(r"\1", text) + def extract_file_path_from_display_text(text_content: str) -> str | None: match = re.match(FILE_PATH_PATTERN, text_content) if match: diff --git a/app/utils/helper.py b/app/utils/helper.py index ce781bd..67bfa55 100644 --- a/app/utils/helper.py +++ b/app/utils/helper.py @@ -34,9 +34,6 @@ RESPONSE_ITEM_RE = re.compile( r"\[response:([^]]+)]\s*(.*?)\s*\[/response]", re.DOTALL | re.IGNORECASE ) -COMMONMARK_UNESCAPE_RE = re.compile( - r"\\([!\"#$%&'()*+,\-./:;<=>?@\[\\\]^_`{|}~])" -) # See: https://spec.commonmark.org/current/#backslash-escapes CONTROL_TOKEN_RE = re.compile(r"<\|im_(?:start|end)\|>") TOOL_HINT_STRIPPED = TOOL_WRAP_HINT.strip() _hint_lines = [line.strip() for line in TOOL_WRAP_HINT.split("\n") if line.strip()] @@ -195,11 +192,8 @@ def _process_tools_internal(text: str, extract: bool = True) -> tuple[str, list[ return text, [] cleaned = strip_system_hints(text) - tool_calls: list[ToolCall] = [] - def _unescape_markdown(s: str) -> str: - """Restores characters escaped for Markdown rendering.""" - return COMMONMARK_UNESCAPE_RE.sub(r"\1", s) + tool_calls: list[ToolCall] = [] def _create_tool_call(name: str, raw_args: str) -> None: if not extract: @@ -208,33 +202,20 @@ def _create_tool_call(name: str, raw_args: str) -> None: logger.warning("Encountered tool_call without a function name.") return - prev_name = "" - while name != prev_name: - prev_name = name - name = _unescape_markdown(name) - - def _try_parse_json(s: str) -> dict | None: - try: - return orjson.loads(s) - except orjson.JSONDecodeError: - try: - return orjson.loads(_unescape_markdown(s)) - except orjson.JSONDecodeError: - return None - arguments = raw_args - parsed_args = _try_parse_json(raw_args) - - if parsed_args is None: + try: + parsed_args = orjson.loads(raw_args) + arguments = orjson.dumps(parsed_args, option=orjson.OPT_SORT_KEYS).decode("utf-8") + except orjson.JSONDecodeError: json_match = re.search(r"({.*})", raw_args, re.DOTALL) if json_match: potential_json = json_match.group(1) - parsed_args = _try_parse_json(potential_json) - if parsed_args is not None: + try: + parsed_args = orjson.loads(potential_json) arguments = orjson.dumps(parsed_args, option=orjson.OPT_SORT_KEYS).decode( "utf-8" ) - else: + except orjson.JSONDecodeError: logger.warning( f"Failed to parse extracted JSON arguments for '{name}': {reprlib.repr(potential_json)}" ) @@ -242,8 +223,6 @@ def _try_parse_json(s: str) -> dict | None: logger.warning( f"Failed to parse tool call arguments for '{name}'. Passing raw string: {reprlib.repr(raw_args)}" ) - else: - arguments = orjson.dumps(parsed_args, option=orjson.OPT_SORT_KEYS).decode("utf-8") index = len(tool_calls) seed = f"{name}:{arguments}:{index}".encode("utf-8") From 4be41506c95673bf7a747f8ff2629a45c99b8309 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C6=B0u=20Quang=20V=C5=A9?= Date: Thu, 12 Feb 2026 12:17:12 +0700 Subject: [PATCH 100/291] Refactor: Rewrite the function call format to match the client's complex argument structure, such as in Roo Code. --- app/server/chat.py | 259 +++++++++++++++++++++++++---------------- app/services/client.py | 39 ++++--- app/services/lmdb.py | 7 +- app/utils/helper.py | 89 +++++++++----- 4 files changed, 245 insertions(+), 149 deletions(-) diff --git a/app/server/chat.py b/app/server/chat.py index 30a6b3a..080d015 100644 --- a/app/server/chat.py +++ b/app/server/chat.py @@ -382,11 +382,16 @@ def _build_tool_prompt( ) lines.append("[function_calls]") lines.append("[call:tool_name]") - lines.append('{"argument": "value"}') + lines.append("@args") + lines.append("") + lines.append("<<>>") + lines.append("value") + lines.append("<<>>") + lines.append("") lines.append("[/call]") lines.append("[/function_calls]") lines.append( - "CRITICAL: Every [call:...] MUST have a raw JSON object followed by a mandatory [/call] closing tag. DO NOT use markdown blocks or add text inside the block." + "CRITICAL: Arguments MUST use <<>>...<<>> tags. Content inside tags can be any format." ) lines.append( "If multiple tools are needed, list them sequentially within the same [function_calls] block." @@ -394,7 +399,9 @@ def _build_tool_prompt( lines.append( "If no tool call is needed, provide a normal response and NEVER use the [function_calls] tag." ) - lines.append("Note: Tool results are returned in a [function_responses] block.") + lines.append( + "Note: Tool results are returned in a [function_responses] block using @results and <<>> tags." + ) return "\n".join(lines) @@ -774,26 +781,44 @@ def __init__(self): self.current_role = "" self.block_buffer = "" - self.TOOL_START = "[function_calls]" - self.TOOL_END = "[/function_calls]" - self.ORPHAN_START = "[call:" - self.ORPHAN_END = "[/call]" - self.RESPONSE_START = "[function_responses]" - self.RESPONSE_END = "[/function_responses]" - self.TAG_START = "<|im_start|>" - self.TAG_END = "<|im_end|>" - self.HINT_START = f"\n{TOOL_HINT_LINE_START}" if TOOL_HINT_LINE_START else "" - self.HINT_END = TOOL_HINT_LINE_END - - self.WATCH_MARKERS = [ - self.TOOL_START, - self.ORPHAN_START, - self.RESPONSE_START, - self.TAG_START, - self.TAG_END, - ] - if self.HINT_START: - self.WATCH_MARKERS.append(self.HINT_START) + self.STATE_MARKERS = { + "TOOL": { + "starts": ["[function_calls]", "\\[function_calls\\]"], + "ends": ["[/function_calls]", "\\[/function_calls\\]"], + }, + "ORPHAN": { + "starts": ["[call:", "\\[call:"], + "ends": ["[/call]", "\\[/call\\]"], + }, + "RESP": { + "starts": ["[function_responses]", "\\[function_responses\\]"], + "ends": ["[/function_responses]", "\\[/function_responses\\]"], + }, + "ARG": { + "starts": ["<<>>", "\\<\\<\\\\>\\>"], + "ends": ["<<>>", "\\<\\<\\\\>\\>"], + }, + "TAG": { + "starts": ["<|im_start|>", "\\<|im_start|\\>"], + "ends": ["<|im_end|>", "\\<|im_end|\\>"], + }, + } + + hint_start = f"\n{TOOL_HINT_LINE_START}" if TOOL_HINT_LINE_START else "" + if hint_start: + self.STATE_MARKERS["HINT"] = { + "starts": [hint_start], + "ends": [TOOL_HINT_LINE_END], + } + + self.WATCH_MARKERS = [] + for cfg in self.STATE_MARKERS.values(): + self.WATCH_MARKERS.extend(cfg["starts"]) + self.WATCH_MARKERS.extend(cfg.get("ends", [])) def process(self, chunk: str) -> str: self.buffer += chunk @@ -802,25 +827,12 @@ def process(self, chunk: str) -> str: while self.buffer: buf_low = self.buffer.lower() if self.state == "NORMAL": - tool_idx = buf_low.find(self.TOOL_START) - orphan_idx = buf_low.find(self.ORPHAN_START) - resp_idx = buf_low.find(self.RESPONSE_START) - tag_idx = buf_low.find(self.TAG_START) - end_idx = buf_low.find(self.TAG_END) - hint_idx = buf_low.find(self.HINT_START) if self.HINT_START else -1 - - indices = [ - (i, t) - for i, t in [ - (tool_idx, "TOOL"), - (orphan_idx, "ORPHAN"), - (resp_idx, "RESP"), - (tag_idx, "TAG"), - (end_idx, "END"), - (hint_idx, "HINT"), - ] - if i != -1 - ] + indices = [] + for m_type, cfg in self.STATE_MARKERS.items(): + for p in cfg["starts"]: + idx = buf_low.find(p.lower()) + if idx != -1: + indices.append((idx, m_type, len(p))) if not indices: # Guard against split markers (case-insensitive) @@ -838,76 +850,111 @@ def process(self, chunk: str) -> str: break indices.sort() - idx, m_type = indices[0] + idx, m_type, m_len = indices[0] output.append(self.buffer[:idx]) self.buffer = self.buffer[idx:] - if m_type == "TOOL": - self.state = "IN_TOOL" - self.block_buffer = "" - self.buffer = self.buffer[len(self.TOOL_START) :] - elif m_type == "ORPHAN": - self.state = "IN_ORPHAN" + self.state = f"IN_{m_type}" + if m_type in ("TOOL", "ORPHAN"): self.block_buffer = "" - self.buffer = self.buffer[len(self.ORPHAN_START) :] - elif m_type == "RESP": - self.state = "IN_RESP" - self.buffer = self.buffer[len(self.RESPONSE_START) :] - elif m_type == "TAG": - self.state = "IN_TAG" - self.buffer = self.buffer[len(self.TAG_START) :] - elif m_type == "END": - self.buffer = self.buffer[len(self.TAG_END) :] - elif m_type == "HINT": - self.state = "IN_HINT" - self.buffer = self.buffer[len(self.HINT_START) :] + + self.buffer = self.buffer[m_len:] elif self.state == "IN_HINT": - end_idx = buf_low.find(self.HINT_END.lower()) - if end_idx != -1: - self.buffer = self.buffer[end_idx + len(self.HINT_END) :] + cfg = self.STATE_MARKERS["HINT"] + found_idx, found_len = -1, 0 + for p in cfg["ends"]: + idx = buf_low.find(p.lower()) + if idx != -1 and (found_idx == -1 or idx < found_idx): + found_idx, found_len = idx, len(p) + + if found_idx != -1: + self.buffer = self.buffer[found_idx + found_len :] + self.state = "NORMAL" + else: + max_end_len = max(len(p) for p in cfg["ends"]) + if len(self.buffer) > max_end_len: + self.buffer = self.buffer[-max_end_len:] + break + + elif self.state == "IN_ARG": + cfg = self.STATE_MARKERS["ARG"] + found_idx, found_len = -1, 0 + for p in cfg["ends"]: + idx = buf_low.find(p.lower()) + if idx != -1 and (found_idx == -1 or idx < found_idx): + found_idx, found_len = idx, len(p) + + if found_idx != -1: + bracket_idx = self.buffer.find(">", found_idx + found_len) + if bracket_idx != -1: + end_pos = bracket_idx + 1 + while end_pos < len(self.buffer) and self.buffer[end_pos] == ">": + end_pos += 1 + + self.buffer = self.buffer[end_pos:] + self.state = "NORMAL" + else: + break + else: + break + + elif self.state == "IN_RESULT": + cfg = self.STATE_MARKERS["RESULT"] + found_idx, found_len = -1, 0 + for p in cfg["ends"]: + idx = buf_low.find(p.lower()) + if idx != -1 and (found_idx == -1 or idx < found_idx): + found_idx, found_len = idx, len(p) + + if found_idx != -1: + self.buffer = self.buffer[found_idx + found_len :] self.state = "NORMAL" else: - keep_len = len(self.HINT_END) - 1 - if len(self.buffer) > keep_len: - self.buffer = self.buffer[-keep_len:] break elif self.state == "IN_RESP": - end_idx = buf_low.find(self.RESPONSE_END.lower()) - if end_idx != -1: - self.buffer = self.buffer[end_idx + len(self.RESPONSE_END) :] + cfg = self.STATE_MARKERS["RESP"] + found_idx, found_len = -1, 0 + for p in cfg["ends"]: + idx = buf_low.find(p.lower()) + if idx != -1 and (found_idx == -1 or idx < found_idx): + found_idx, found_len = idx, len(p) + + if found_idx != -1: + self.buffer = self.buffer[found_idx + found_len :] self.state = "NORMAL" else: - keep_len = len(self.RESPONSE_END) - 1 - if len(self.buffer) > keep_len: - self.buffer = self.buffer[-keep_len:] break elif self.state == "IN_TOOL": - end_idx = buf_low.find(self.TOOL_END.lower()) - if end_idx != -1: - self.block_buffer += self.buffer[:end_idx] - self.buffer = self.buffer[end_idx + len(self.TOOL_END) :] + cfg = self.STATE_MARKERS["TOOL"] + found_idx, found_len = -1, 0 + for p in cfg["ends"]: + idx = buf_low.find(p.lower()) + if idx != -1 and (found_idx == -1 or idx < found_idx): + found_idx, found_len = idx, len(p) + + if found_idx != -1: + self.block_buffer += self.buffer[:found_idx] + self.buffer = self.buffer[found_idx + found_len :] self.state = "NORMAL" else: - keep_len = len(self.TOOL_END) - 1 - if len(self.buffer) > keep_len: - self.block_buffer += self.buffer[:-keep_len] - self.buffer = self.buffer[-keep_len:] break elif self.state == "IN_ORPHAN": - end_idx = buf_low.find(self.ORPHAN_END.lower()) - if end_idx != -1: - self.block_buffer += self.buffer[:end_idx] - self.buffer = self.buffer[end_idx + len(self.ORPHAN_END) :] + cfg = self.STATE_MARKERS["ORPHAN"] + found_idx, found_len = -1, 0 + for p in cfg["ends"]: + idx = buf_low.find(p.lower()) + if idx != -1 and (found_idx == -1 or idx < found_idx): + found_idx, found_len = idx, len(p) + + if found_idx != -1: + self.block_buffer += self.buffer[:found_idx] + self.buffer = self.buffer[found_idx + found_len :] self.state = "NORMAL" else: - keep_len = len(self.ORPHAN_END) - 1 - if len(self.buffer) > keep_len: - self.block_buffer += self.buffer[:-keep_len] - self.buffer = self.buffer[-keep_len:] break elif self.state == "IN_TAG": @@ -920,24 +967,30 @@ def process(self, chunk: str) -> str: break elif self.state == "IN_BLOCK": - end_idx = buf_low.find(self.TAG_END.lower()) - if end_idx != -1: - content = self.buffer[:end_idx] + cfg = self.STATE_MARKERS["TAG"] + found_idx, found_len = -1, 0 + for p in cfg["ends"]: + idx = buf_low.find(p.lower()) + if idx != -1 and (found_idx == -1 or idx < found_idx): + found_idx, found_len = idx, len(p) + + if found_idx != -1: + content = self.buffer[:found_idx] if self.current_role != "tool": output.append(content) - self.buffer = self.buffer[end_idx + len(self.TAG_END) :] + self.buffer = self.buffer[found_idx + found_len :] self.state = "NORMAL" self.current_role = "" else: - keep_len = len(self.TAG_END) - 1 + max_end_len = max(len(p) for p in cfg["ends"]) if self.current_role != "tool": - if len(self.buffer) > keep_len: - output.append(self.buffer[:-keep_len]) - self.buffer = self.buffer[-keep_len:] + if len(self.buffer) > max_end_len: + output.append(self.buffer[:-max_end_len]) + self.buffer = self.buffer[-max_end_len:] break else: - if len(self.buffer) > keep_len: - self.buffer = self.buffer[-keep_len:] + if len(self.buffer) > max_end_len: + self.buffer = self.buffer[-max_end_len:] break return "".join(output) @@ -945,11 +998,13 @@ def process(self, chunk: str) -> str: def flush(self) -> str: res = "" if self.state == "IN_TOOL": - if self.ORPHAN_START.lower() not in self.block_buffer.lower(): - res = f"{self.TOOL_START}{self.block_buffer}" + orphan_starts = self.STATE_MARKERS["ORPHAN"]["starts"] + is_orphan = any(p.lower() in self.block_buffer.lower() for p in orphan_starts) + if not is_orphan: + res = f"{self.STATE_MARKERS['TOOL']['starts'][0]}{self.block_buffer}" elif self.state == "IN_BLOCK" and self.current_role != "tool": res = self.buffer - elif self.state in ("IN_ORPHAN", "IN_RESP", "IN_HINT"): + elif self.state in ("IN_ORPHAN", "IN_RESP", "IN_HINT", "IN_ARG", "IN_RESULT"): res = "" elif self.state == "NORMAL": res = self.buffer diff --git a/app/services/client.py b/app/services/client.py index 5d248c2..c955456 100644 --- a/app/services/client.py +++ b/app/services/client.py @@ -10,14 +10,11 @@ from ..utils import g_config from ..utils.helper import ( add_tag, + normalize_llm_text, save_file_to_tempfile, save_url_to_tempfile, ) -COMMONMARK_UNESCAPE_RE = re.compile( - r"\\([!\"#$%&'()*+,\-./:;<=>?@\[\\\]^_`{|}~])" -) # See: https://spec.commonmark.org/current/#backslash-escapes - FILE_PATH_PATTERN = re.compile( r"^(?=.*[./\\]|.*:\d+|^(?:Dockerfile|Makefile|Jenkinsfile|Procfile|Rakefile|Gemfile|Vagrantfile|Caddyfile|Justfile|LICENSE|README|CONTRIBUTING|CODEOWNERS|AUTHORS|NOTICE|CHANGELOG)$)([a-zA-Z0-9_./\\-]+(?::\d+)?)$", re.IGNORECASE, @@ -89,12 +86,12 @@ async def process_message( if isinstance(message.content, str): if message.content or message.role == "tool": - text_fragments.append(message.content or "{}") + text_fragments.append(message.content or "") elif isinstance(message.content, list): for item in message.content: if item.type == "text": if item.text or message.role == "tool": - text_fragments.append(item.text or "{}") + text_fragments.append(item.text or "") elif item.type == "image_url": if not item.image_url: raise ValueError("Image URL cannot be empty") @@ -113,14 +110,19 @@ async def process_message( else: raise ValueError("File must contain 'file_data' or 'url' key") elif message.content is None and message.role == "tool": - text_fragments.append("{}") + text_fragments.append("") elif message.content is not None: raise ValueError("Unsupported message content type.") if message.role == "tool": tool_name = message.name or "unknown" - combined_content = "\n".join(text_fragments).strip() or "{}" - res_block = f"[response:{tool_name}]\n{combined_content}\n[/response]" + combined_content = "\n".join(text_fragments).strip() + res_block = ( + f"[response:{tool_name}]\n" + f"@results\n\n" + f"<<>>\n{combined_content}\n<<>>\n\n" + f"[/response]" + ) if wrap_tool: text_fragments = [f"[function_responses]\n{res_block}\n[/function_responses]"] else: @@ -130,17 +132,22 @@ async def process_message( tool_blocks: list[str] = [] for call in message.tool_calls: args_text = call.function.arguments.strip() + formatted_args = "\n@args\n" try: parsed_args = orjson.loads(args_text) - args_text = orjson.dumps(parsed_args, option=orjson.OPT_SORT_KEYS).decode( - "utf-8" - ) + if isinstance(parsed_args, dict): + for k, v in parsed_args.items(): + val_str = v if isinstance(v, str) else orjson.dumps(v).decode("utf-8") + formatted_args += f"\n<<>>\n{val_str}\n<<>>\n" + else: + formatted_args += args_text except orjson.JSONDecodeError: - pass - tool_blocks.append(f"[call:{call.function.name}]{args_text}[/call]") + formatted_args += args_text + + tool_blocks.append(f"[call:{call.function.name}]{formatted_args}\n[/call]") if tool_blocks: - tool_section = "[function_calls]\n" + "".join(tool_blocks) + "\n[/function_calls]" + tool_section = "[function_calls]\n" + "\n".join(tool_blocks) + "\n[/function_calls]" text_fragments.append(tool_section) model_input = "\n".join(fragment for fragment in text_fragments if fragment is not None) @@ -198,7 +205,7 @@ def extract_output(response: ModelOutput, include_thoughts: bool = True) -> str: else: text += str(response) - text = COMMONMARK_UNESCAPE_RE.sub(r"\1", text) + text = normalize_llm_text(text) def extract_file_path_from_display_text(text_content: str) -> str | None: match = re.match(FILE_PATH_PATTERN, text_content) diff --git a/app/services/lmdb.py b/app/services/lmdb.py index 4b57f60..a90c684 100644 --- a/app/services/lmdb.py +++ b/app/services/lmdb.py @@ -1,7 +1,6 @@ import hashlib import re import string -import unicodedata from contextlib import contextmanager from datetime import datetime, timedelta from pathlib import Path @@ -15,6 +14,7 @@ from ..utils import g_config from ..utils.helper import ( extract_tool_calls, + normalize_llm_text, remove_tool_call_blocks, ) from ..utils.singleton import Singleton @@ -39,11 +39,8 @@ def _normalize_text(text: str | None, fuzzy: bool = False) -> str | None: if text is None: return None - # Unicode normalization to NFC - text = unicodedata.normalize("NFC", text) + text = normalize_llm_text(text) - # Basic cleaning - text = text.replace("\r\n", "\n").replace("\r", "\n") text = LMDBConversationStore.remove_think_tags(text) text = remove_tool_call_blocks(text) diff --git a/app/utils/helper.py b/app/utils/helper.py index 67bfa55..dfb4abd 100644 --- a/app/utils/helper.py +++ b/app/utils/helper.py @@ -1,10 +1,12 @@ import base64 import hashlib +import html import mimetypes import re import reprlib import struct import tempfile +import unicodedata from pathlib import Path from urllib.parse import urlparse @@ -19,22 +21,39 @@ "\nWhen you decide to call tools, you MUST respond ONLY with a single [function_calls] block using this EXACT syntax:\n" "[function_calls]\n" "[call:tool_name]\n" - '{"argument": "value"}\n' + "@args\n" + "\n<<>>\n" + "value\n" + "<<>>\n" "[/call]\n" "[/function_calls]\n" - "CRITICAL: Every [call:...] MUST have a raw JSON object followed by a mandatory [/call] closing tag. DO NOT use markdown blocks or add text inside the block.\n" + "CRITICAL: Arguments MUST use <<>>...<<>> tags. Content inside tags can be any format.\n" ) TOOL_BLOCK_RE = re.compile( - r"\[function_calls]\s*(.*?)\s*\[/function_calls]", re.DOTALL | re.IGNORECASE + r"\\?\[function_calls\\?]\s*(.*?)\s*\\?\[/function_calls\\?]", re.DOTALL | re.IGNORECASE +) +TOOL_CALL_RE = re.compile( + r"\\?\[call:([^]\\]+)\\?]\s*(.*?)\s*\\?\[/call\\?]", re.DOTALL | re.IGNORECASE ) -TOOL_CALL_RE = re.compile(r"\[call:([^]]+)]\s*(.*?)\s*\[/call]", re.DOTALL | re.IGNORECASE) RESPONSE_BLOCK_RE = re.compile( - r"\[function_responses]\s*(.*?)\s*\[/function_responses]", re.DOTALL | re.IGNORECASE + r"\\?\[function_responses\\?]\s*(.*?)\s*\\?\[/function_responses\\?]", + re.DOTALL | re.IGNORECASE, ) RESPONSE_ITEM_RE = re.compile( - r"\[response:([^]]+)]\s*(.*?)\s*\[/response]", re.DOTALL | re.IGNORECASE + r"\\?\[response:([^]\\]+)\\?]\s*(.*?)\s*\\?\[/response\\?]", re.DOTALL | re.IGNORECASE +) +TAGGED_ARG_RE = re.compile( + r"(?:\\?<){3}ARG:([^>\\]+)(?:\\?>){3}\s*(.*?)\s*(?:\\?<){3}END:\1(?:\\?>){3}", + re.DOTALL | re.IGNORECASE, +) +TAGGED_RESULT_RE = re.compile( + r"(?:\\?<){3}RESULT(?:\\?>){3}\s*(.*?)\s*(?:\\?<){3}END:RESULT(?:\\?>){3}", + re.DOTALL | re.IGNORECASE, ) -CONTROL_TOKEN_RE = re.compile(r"<\|im_(?:start|end)\|>") +CONTROL_TOKEN_RE = re.compile(r"\\?<\|im_(?:start|end)\|\\?>") +COMMONMARK_UNESCAPE_RE = re.compile( + r"\\([!\"#$%&'()*+,\-./:;<=>?@\[\\\]^_`{|}~])" +) # See: https://spec.commonmark.org/current/#backslash-escapes TOOL_HINT_STRIPPED = TOOL_WRAP_HINT.strip() _hint_lines = [line.strip() for line in TOOL_WRAP_HINT.split("\n") if line.strip()] TOOL_HINT_LINE_START = _hint_lines[0] if _hint_lines else "" @@ -50,6 +69,26 @@ def add_tag(role: str, content: str, unclose: bool = False) -> str: return f"<|im_start|>{role}\n{content}" + ("\n<|im_end|>" if not unclose else "") +def normalize_llm_text(s: str) -> str: + """ + Safely normalize LLM-generated text for both display and hashing. + Includes: HTML unescaping, NFC normalization, and line ending standardization. + """ + if not s: + return "" + + s = html.unescape(s) + s = unicodedata.normalize("NFC", s) + s = s.replace("\r\n", "\n").replace("\r", "\n") + + return s + + +def unescape_llm_text(s: str) -> str: + r"""Unescape characters escaped by Gemini Web's post-processing.""" + return COMMONMARK_UNESCAPE_RE.sub(r"\1", s) + + def estimate_tokens(text: str | None) -> int: """Estimate the number of tokens heuristically based on character count""" if not text: @@ -202,27 +241,23 @@ def _create_tool_call(name: str, raw_args: str) -> None: logger.warning("Encountered tool_call without a function name.") return - arguments = raw_args - try: - parsed_args = orjson.loads(raw_args) - arguments = orjson.dumps(parsed_args, option=orjson.OPT_SORT_KEYS).decode("utf-8") - except orjson.JSONDecodeError: - json_match = re.search(r"({.*})", raw_args, re.DOTALL) - if json_match: - potential_json = json_match.group(1) - try: - parsed_args = orjson.loads(potential_json) - arguments = orjson.dumps(parsed_args, option=orjson.OPT_SORT_KEYS).decode( - "utf-8" - ) - except orjson.JSONDecodeError: - logger.warning( - f"Failed to parse extracted JSON arguments for '{name}': {reprlib.repr(potential_json)}" - ) + name = unescape_llm_text(name.strip()) + raw_args = unescape_llm_text(raw_args) + + arg_matches = TAGGED_ARG_RE.findall(raw_args) + if arg_matches: + args_dict = {arg_name.strip(): arg_value.strip() for arg_name, arg_value in arg_matches} + arguments = orjson.dumps(args_dict).decode("utf-8") + logger.debug(f"Successfully parsed {len(args_dict)} tagged arguments for tool: {name}") + else: + cleaned_raw = raw_args.replace("@args", "").strip() + if not cleaned_raw: + logger.debug(f"Tool '{name}' called without arguments.") else: logger.warning( - f"Failed to parse tool call arguments for '{name}'. Passing raw string: {reprlib.repr(raw_args)}" + f"Malformed arguments for tool '{name}'. Text found but no valid tags: {reprlib.repr(cleaned_raw)}" ) + arguments = "{}" index = len(tool_calls) seed = f"{name}:{arguments}:{index}".encode("utf-8") @@ -241,7 +276,7 @@ def _create_tool_call(name: str, raw_args: str) -> None: all_calls.append( { "start": match.start(), - "name": (match.group(1) or "").strip(), + "name": unescape_llm_text((match.group(1) or "").strip()), "args": (match.group(2) or "").strip(), } ) @@ -256,6 +291,8 @@ def _create_tool_call(name: str, raw_args: str) -> None: cleaned = TOOL_CALL_RE.sub("", cleaned) cleaned = RESPONSE_BLOCK_RE.sub("", cleaned) cleaned = RESPONSE_ITEM_RE.sub("", cleaned) + cleaned = TAGGED_ARG_RE.sub("", cleaned) + cleaned = TAGGED_RESULT_RE.sub("", cleaned) return cleaned, tool_calls From d86798bc360b5ba76f3fb778c3b7e86b736400f1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C6=B0u=20Quang=20V=C5=A9?= Date: Thu, 12 Feb 2026 15:05:48 +0700 Subject: [PATCH 101/291] Refactor: Rewrite the function call format to match the client's complex argument structure, such as in Roo Code. --- app/server/chat.py | 114 ++++++++++++++++++------------- app/services/client.py | 24 ++++--- app/services/lmdb.py | 4 +- app/utils/helper.py | 150 ++++++++++++++++------------------------- 4 files changed, 141 insertions(+), 151 deletions(-) diff --git a/app/server/chat.py b/app/server/chat.py index 080d015..4262d0d 100644 --- a/app/server/chat.py +++ b/app/server/chat.py @@ -342,7 +342,7 @@ def _build_tool_prompt( tools: list[Tool], tool_choice: str | ToolChoiceFunction | None, ) -> str: - """Generate a system prompt chunk describing available tools.""" + """Generate a system prompt describing available tools and the PascalCase protocol.""" if not tools: return "" @@ -378,29 +378,27 @@ def _build_tool_prompt( ) lines.append( - "When you decide to call tools, you MUST respond ONLY with a single [function_calls] block using this EXACT syntax:" + "When you decide to call tools, you MUST respond ONLY with a single [ToolCalls] block using this EXACT syntax:" ) - lines.append("[function_calls]") - lines.append("[call:tool_name]") + lines.append("[ToolCalls]") + lines.append("[Call:tool_name]") lines.append("@args") - lines.append("") - lines.append("<<>>") + lines.append("<<>>") lines.append("value") - lines.append("<<>>") - lines.append("") - lines.append("[/call]") - lines.append("[/function_calls]") + lines.append("<<>>") + lines.append("[/Call]") + lines.append("[/ToolCalls]") lines.append( - "CRITICAL: Arguments MUST use <<>>...<<>> tags. Content inside tags can be any format." + "CRITICAL: Every argument MUST be enclosed in <<>>...<<>>. Output as RAW text. Content inside tags can be any format." ) lines.append( - "If multiple tools are needed, list them sequentially within the same [function_calls] block." + "If multiple tools are needed, list them sequentially within the same [ToolCalls] block." ) lines.append( - "If no tool call is needed, provide a normal response and NEVER use the [function_calls] tag." + "If no tool call is needed, provide a normal response and NEVER use the [ToolCalls] tag." ) lines.append( - "Note: Tool results are returned in a [function_responses] block using @results and <<>> tags." + "Note: Tool results are returned in a [ToolResults] block using @results and <<>> tags." ) return "\n".join(lines) @@ -771,8 +769,8 @@ async def _send_with_split( class StreamingOutputFilter: """ - State Machine filter to suppress technical markers, tool calls, and system hints. - Handles fragmentation where markers are split across multiple chunks. + Filter to suppress technical protocol markers, tool calls, and system hints from the stream. + Uses a state machine to handle fragmentation where markers are split across multiple chunks. """ def __init__(self): @@ -783,28 +781,32 @@ def __init__(self): self.STATE_MARKERS = { "TOOL": { - "starts": ["[function_calls]", "\\[function_calls\\]"], - "ends": ["[/function_calls]", "\\[/function_calls\\]"], + "starts": ["[ToolCalls]", "\\[ToolCalls\\]"], + "ends": ["[/ToolCalls]", "\\[/ToolCalls\\]"], }, "ORPHAN": { - "starts": ["[call:", "\\[call:"], - "ends": ["[/call]", "\\[/call\\]"], + "starts": ["[Call:", "\\[Call:", "\\[Call\\:"], + "ends": ["[/Call]", "\\[/Call\\]"], }, "RESP": { - "starts": ["[function_responses]", "\\[function_responses\\]"], - "ends": ["[/function_responses]", "\\[/function_responses\\]"], + "starts": ["[ToolResults]", "\\[ToolResults\\]"], + "ends": ["[/ToolResults]", "\\[/ToolResults\\]"], }, "ARG": { - "starts": ["<<>>", "\\<\\<\\\\>\\>"], }, "RESULT": { - "starts": ["<<>>", "\\<\\<\\\\>\\>"], - "ends": ["<<>>", "\\<\\<\\\\>\\>"], + "starts": ["<<>>", "\\<\\<\\\\>\\>"], + "ends": ["<<>>", "\\<\\<\\\\>\\>"], }, "TAG": { - "starts": ["<|im_start|>", "\\<|im_start|\\>"], - "ends": ["<|im_end|>", "\\<|im_end|\\>"], + "starts": ["<|im_start|>", "\\<|im\\_start|\\>"], + "ends": ["<|im_end|>", "\\<|im\\_end|\\>"], }, } @@ -815,10 +817,20 @@ def __init__(self): "ends": [TOOL_HINT_LINE_END], } + self.ORPHAN_ENDS = [ + "<|im_end|>", + "\\<|im\\_end|\\>", + "[/Call]", + "\\[/Call\\]", + "[/ToolCalls]", + "\\[/ToolCalls\\]", + ] + self.WATCH_MARKERS = [] for cfg in self.STATE_MARKERS.values(): self.WATCH_MARKERS.extend(cfg["starts"]) self.WATCH_MARKERS.extend(cfg.get("ends", [])) + self.WATCH_MARKERS.extend(self.ORPHAN_ENDS) def process(self, chunk: str) -> str: self.buffer += chunk @@ -834,8 +846,12 @@ def process(self, chunk: str) -> str: if idx != -1: indices.append((idx, m_type, len(p))) + for p in self.ORPHAN_ENDS: + idx = buf_low.find(p.lower()) + if idx != -1: + indices.append((idx, "SKIP", len(p))) + if not indices: - # Guard against split markers (case-insensitive) keep_len = 0 for marker in self.WATCH_MARKERS: m_low = marker.lower() @@ -854,6 +870,10 @@ def process(self, chunk: str) -> str: output.append(self.buffer[:idx]) self.buffer = self.buffer[idx:] + if m_type == "SKIP": + self.buffer = self.buffer[m_len:] + continue + self.state = f"IN_{m_type}" if m_type in ("TOOL", "ORPHAN"): self.block_buffer = "" @@ -886,17 +906,12 @@ def process(self, chunk: str) -> str: found_idx, found_len = idx, len(p) if found_idx != -1: - bracket_idx = self.buffer.find(">", found_idx + found_len) - if bracket_idx != -1: - end_pos = bracket_idx + 1 - while end_pos < len(self.buffer) and self.buffer[end_pos] == ">": - end_pos += 1 - - self.buffer = self.buffer[end_pos:] - self.state = "NORMAL" - else: - break + self.buffer = self.buffer[found_idx + found_len :] + self.state = "NORMAL" else: + max_end_len = max(len(p) for p in cfg["ends"]) + if len(self.buffer) > max_end_len: + self.buffer = self.buffer[-max_end_len:] break elif self.state == "IN_RESULT": @@ -911,6 +926,9 @@ def process(self, chunk: str) -> str: self.buffer = self.buffer[found_idx + found_len :] self.state = "NORMAL" else: + max_end_len = max(len(p) for p in cfg["ends"]) + if len(self.buffer) > max_end_len: + self.buffer = self.buffer[-max_end_len:] break elif self.state == "IN_RESP": @@ -940,6 +958,10 @@ def process(self, chunk: str) -> str: self.buffer = self.buffer[found_idx + found_len :] self.state = "NORMAL" else: + max_end_len = max(len(p) for p in cfg["ends"]) + if len(self.buffer) > max_end_len: + self.block_buffer += self.buffer[:-max_end_len] + self.buffer = self.buffer[-max_end_len:] break elif self.state == "IN_ORPHAN": @@ -955,6 +977,10 @@ def process(self, chunk: str) -> str: self.buffer = self.buffer[found_idx + found_len :] self.state = "NORMAL" else: + max_end_len = max(len(p) for p in cfg["ends"]) + if len(self.buffer) > max_end_len: + self.block_buffer += self.buffer[:-max_end_len] + self.buffer = self.buffer[-max_end_len:] break elif self.state == "IN_TAG": @@ -996,16 +1022,12 @@ def process(self, chunk: str) -> str: return "".join(output) def flush(self) -> str: + """Release remaining buffer content and perform final cleanup at stream end.""" res = "" - if self.state == "IN_TOOL": - orphan_starts = self.STATE_MARKERS["ORPHAN"]["starts"] - is_orphan = any(p.lower() in self.block_buffer.lower() for p in orphan_starts) - if not is_orphan: - res = f"{self.STATE_MARKERS['TOOL']['starts'][0]}{self.block_buffer}" + if self.state in ("IN_TOOL", "IN_ORPHAN", "IN_RESP", "IN_HINT", "IN_ARG", "IN_RESULT"): + res = "" elif self.state == "IN_BLOCK" and self.current_role != "tool": res = self.buffer - elif self.state in ("IN_ORPHAN", "IN_RESP", "IN_HINT", "IN_ARG", "IN_RESULT"): - res = "" elif self.state == "NORMAL": res = self.buffer diff --git a/app/services/client.py b/app/services/client.py index c955456..ba203d9 100644 --- a/app/services/client.py +++ b/app/services/client.py @@ -78,8 +78,8 @@ async def process_message( message: Message, tempdir: Path | None = None, tagged: bool = True, wrap_tool: bool = True ) -> tuple[str, list[Path | str]]: """ - Process a single Message object into a format suitable for the Gemini API. - Extracts text fragments, handles images and files, and appends tool call blocks if present. + Process a Message into Gemini API format using the PascalCase technical protocol. + Extracts text, handles files, and appends ToolCalls/ToolResults blocks. """ files: list[Path | str] = [] text_fragments: list[str] = [] @@ -118,13 +118,13 @@ async def process_message( tool_name = message.name or "unknown" combined_content = "\n".join(text_fragments).strip() res_block = ( - f"[response:{tool_name}]\n" - f"@results\n\n" - f"<<>>\n{combined_content}\n<<>>\n\n" - f"[/response]" + f"[Result:{tool_name}]\n" + f"@results\n" + f"<<>>\n{combined_content}\n<<>>\n" + f"[/Result]" ) if wrap_tool: - text_fragments = [f"[function_responses]\n{res_block}\n[/function_responses]"] + text_fragments = [f"[ToolResults]\n{res_block}\n[/ToolResults]"] else: text_fragments = [res_block] @@ -132,22 +132,24 @@ async def process_message( tool_blocks: list[str] = [] for call in message.tool_calls: args_text = call.function.arguments.strip() - formatted_args = "\n@args\n" + formatted_args = "@args\n" try: parsed_args = orjson.loads(args_text) if isinstance(parsed_args, dict): for k, v in parsed_args.items(): val_str = v if isinstance(v, str) else orjson.dumps(v).decode("utf-8") - formatted_args += f"\n<<>>\n{val_str}\n<<>>\n" + formatted_args += ( + f"<<>>\n{val_str}\n<<>>\n" + ) else: formatted_args += args_text except orjson.JSONDecodeError: formatted_args += args_text - tool_blocks.append(f"[call:{call.function.name}]{formatted_args}\n[/call]") + tool_blocks.append(f"[Call:{call.function.name}]\n{formatted_args}[/Call]") if tool_blocks: - tool_section = "[function_calls]\n" + "\n".join(tool_blocks) + "\n[/function_calls]" + tool_section = "[ToolCalls]\n" + "\n".join(tool_blocks) + "\n[/ToolCalls]" text_fragments.append(tool_section) model_input = "\n".join(fragment for fragment in text_fragments if fragment is not None) diff --git a/app/services/lmdb.py b/app/services/lmdb.py index a90c684..ad92bbf 100644 --- a/app/services/lmdb.py +++ b/app/services/lmdb.py @@ -33,9 +33,7 @@ def _fuzzy_normalize(text: str | None) -> str | None: def _normalize_text(text: str | None, fuzzy: bool = False) -> str | None: - """ - Perform semantic normalization for hashing. - """ + """Perform safe semantic normalization for hashing using helper utilities.""" if text is None: return None diff --git a/app/utils/helper.py b/app/utils/helper.py index dfb4abd..25f9c9b 100644 --- a/app/utils/helper.py +++ b/app/utils/helper.py @@ -18,42 +18,43 @@ VALID_TAG_ROLES = {"user", "assistant", "system", "tool"} TOOL_WRAP_HINT = ( - "\nWhen you decide to call tools, you MUST respond ONLY with a single [function_calls] block using this EXACT syntax:\n" - "[function_calls]\n" - "[call:tool_name]\n" + "\nWhen you decide to call tools, you MUST respond ONLY with a single [ToolCalls] block using this EXACT syntax:\n" + "[ToolCalls]\n" + "[Call:tool_name]\n" "@args\n" - "\n<<>>\n" + "<<>>\n" "value\n" - "<<>>\n" - "[/call]\n" - "[/function_calls]\n" - "CRITICAL: Arguments MUST use <<>>...<<>> tags. Content inside tags can be any format.\n" + "<<>>\n" + "[/Call]\n" + "[/ToolCalls]\n" + "CRITICAL: Every argument MUST be enclosed in <<>>...<<>>. Output as RAW text. Content inside tags can be any format.\n" ) TOOL_BLOCK_RE = re.compile( - r"\\?\[function_calls\\?]\s*(.*?)\s*\\?\[/function_calls\\?]", re.DOTALL | re.IGNORECASE + r"\\?\[ToolCalls\\?]\s*(.*?)\s*\\?\[/ToolCalls\\?]", re.DOTALL | re.IGNORECASE ) TOOL_CALL_RE = re.compile( - r"\\?\[call:([^]\\]+)\\?]\s*(.*?)\s*\\?\[/call\\?]", re.DOTALL | re.IGNORECASE + r"\\?\[Call\\?:((?:[^]\\]|\\.)+)\\?]\s*(.*?)\s*\\?\[/Call\\?]", re.DOTALL | re.IGNORECASE ) RESPONSE_BLOCK_RE = re.compile( - r"\\?\[function_responses\\?]\s*(.*?)\s*\\?\[/function_responses\\?]", + r"\\?\[ToolResults\\?]\s*(.*?)\s*\\?\[/ToolResults\\?]", re.DOTALL | re.IGNORECASE, ) RESPONSE_ITEM_RE = re.compile( - r"\\?\[response:([^]\\]+)\\?]\s*(.*?)\s*\\?\[/response\\?]", re.DOTALL | re.IGNORECASE + r"\\?\[Result\\?:((?:[^]\\]|\\.)+)\\?]\s*(.*?)\s*\\?\[/Result\\?]", + re.DOTALL | re.IGNORECASE, ) TAGGED_ARG_RE = re.compile( - r"(?:\\?<){3}ARG:([^>\\]+)(?:\\?>){3}\s*(.*?)\s*(?:\\?<){3}END:\1(?:\\?>){3}", + r"(?:\\?<){3}CallParameter\\?:((?:[^>\\]|\\.)+)(?:\\?>){3}\s*(.*?)\s*(?:\\?<){3}EndCallParameter(?:\\?>){3}", re.DOTALL | re.IGNORECASE, ) TAGGED_RESULT_RE = re.compile( - r"(?:\\?<){3}RESULT(?:\\?>){3}\s*(.*?)\s*(?:\\?<){3}END:RESULT(?:\\?>){3}", + r"(?:\\?<){3}ToolResult(?:\\?>){3}\s*(.*?)\s*(?:\\?<){3}EndToolResult(?:\\?>){3}", re.DOTALL | re.IGNORECASE, ) -CONTROL_TOKEN_RE = re.compile(r"\\?<\|im_(?:start|end)\|\\?>") -COMMONMARK_UNESCAPE_RE = re.compile( - r"\\([!\"#$%&'()*+,\-./:;<=>?@\[\\\]^_`{|}~])" -) # See: https://spec.commonmark.org/current/#backslash-escapes +CONTROL_TOKEN_RE = re.compile(r"\\?<\|im\\?_(?:start|end)\|\\?>", re.IGNORECASE) +CHATML_START_RE = re.compile(r"\\?<\|im\\?_start\|\\?>\s*(\w+)\s*\n?", re.IGNORECASE) +CHATML_END_RE = re.compile(r"\\?<\|im\\?_end\|\\?>", re.IGNORECASE) +COMMONMARK_UNESCAPE_RE = re.compile(r"\\([!\"#$%&'()*+,\-./:;<=>?@\[\\\]^_`{|}~])") TOOL_HINT_STRIPPED = TOOL_WRAP_HINT.strip() _hint_lines = [line.strip() for line in TOOL_WRAP_HINT.split("\n") if line.strip()] TOOL_HINT_LINE_START = _hint_lines[0] if _hint_lines else "" @@ -61,7 +62,7 @@ def add_tag(role: str, content: str, unclose: bool = False) -> str: - """Surround content with role tags""" + """Surround content with ChatML role tags.""" if role not in VALID_TAG_ROLES: logger.warning(f"Unknown role: {role}, returning content without tags") return content @@ -85,12 +86,12 @@ def normalize_llm_text(s: str) -> str: def unescape_llm_text(s: str) -> str: - r"""Unescape characters escaped by Gemini Web's post-processing.""" + """Unescape characters escaped by Gemini Web's post-processing (e.g., \\_ to _).""" return COMMONMARK_UNESCAPE_RE.sub(r"\1", s) def estimate_tokens(text: str | None) -> int: - """Estimate the number of tokens heuristically based on character count""" + """Estimate the number of tokens heuristically based on character count.""" if not text: return 0 return int(len(text) / 3) @@ -99,6 +100,7 @@ def estimate_tokens(text: str | None) -> int: async def save_file_to_tempfile( file_in_base64: str, file_name: str = "", tempdir: Path | None = None ) -> Path: + """Decode base64 file data and save to a temporary file.""" data = base64.b64decode(file_in_base64) suffix = Path(file_name).suffix if file_name else ".bin" @@ -110,6 +112,7 @@ async def save_file_to_tempfile( async def save_url_to_tempfile(url: str, tempdir: Path | None = None) -> Path: + """Download content from a URL and save to a temporary file.""" data: bytes | None = None suffix: str | None = None if url.startswith("data:image/"): @@ -148,67 +151,48 @@ async def save_url_to_tempfile(url: str, tempdir: Path | None = None) -> Path: def strip_tagged_blocks(text: str) -> str: - """Remove <|im_start|>role ... <|im_end|> sections. - - tool blocks are removed entirely (including content). - - other roles: remove markers and role, keep inner content. + """ + Remove ChatML role blocks (<|im_start|>role...<|im_end|>). + Role 'tool' blocks are removed entirely; others have markers stripped but content preserved. + Handles both raw and escaped markers consistently. """ if not text: return text - result: list[str] = [] + result = [] idx = 0 - length = len(text) - start_marker = "<|im_start|>" - end_marker = "<|im_end|>" - - while idx < length: - start = text.find(start_marker, idx) - if start == -1: + while idx < len(text): + match_start = CHATML_START_RE.search(text, idx) + if not match_start: result.append(text[idx:]) break - result.append(text[idx:start]) + result.append(text[idx : match_start.start()]) + role = match_start.group(1).lower() + content_start = match_start.end() - role_start = start + len(start_marker) - newline = text.find("\n", role_start) - if newline == -1: - result.append(text[start:]) + match_end = CHATML_END_RE.search(text, content_start) + if not match_end: + if role != "tool": + result.append(text[content_start:]) break - role = text[role_start:newline].strip().lower() - - end = text.find(end_marker, newline + 1) - if end == -1: - if role == "tool": - break - else: - result.append(text[newline + 1 :]) - break - - block_end = end + len(end_marker) + if role != "tool": + result.append(text[content_start : match_end.start()]) - if role == "tool": - idx = block_end - continue - - content = text[newline + 1 : end] - result.append(content) - idx = block_end + idx = match_end.end() return "".join(result) def strip_system_hints(text: str) -> str: - """Remove system-level hint text from a given string.""" + """Remove system hints, ChatML tags, and technical protocol markers from text.""" if not text: return text - # Remove the full hints first cleaned = text.replace(TOOL_WRAP_HINT, "").replace(TOOL_HINT_STRIPPED, "") - # Remove fragments or multi-line blocks using derived constants if TOOL_HINT_LINE_START and TOOL_HINT_LINE_END: - # Match from the start line to the end line, inclusive, handling internal modifications pattern = rf"\n?{re.escape(TOOL_HINT_LINE_START)}.*?{re.escape(TOOL_HINT_LINE_END)}\.?\n?" cleaned = re.sub(pattern, "", cleaned, flags=re.DOTALL) @@ -218,20 +202,26 @@ def strip_system_hints(text: str) -> str: cleaned = re.sub(rf"\s*{re.escape(TOOL_HINT_LINE_END)}\.?\n?", "", cleaned) cleaned = strip_tagged_blocks(cleaned) + cleaned = CONTROL_TOKEN_RE.sub("", cleaned) + cleaned = TOOL_BLOCK_RE.sub("", cleaned) + cleaned = TOOL_CALL_RE.sub("", cleaned) + cleaned = RESPONSE_BLOCK_RE.sub("", cleaned) + cleaned = RESPONSE_ITEM_RE.sub("", cleaned) + cleaned = TAGGED_ARG_RE.sub("", cleaned) + cleaned = TAGGED_RESULT_RE.sub("", cleaned) + return cleaned def _process_tools_internal(text: str, extract: bool = True) -> tuple[str, list[ToolCall]]: """ - Unified engine for stripping tool call blocks and extracting tool metadata. - If extract=True, parses JSON arguments and assigns deterministic call IDs. + Extract tool metadata and return text stripped of technical markers. + Arguments are parsed into JSON and assigned deterministic call IDs. """ if not text: return text, [] - cleaned = strip_system_hints(text) - tool_calls: list[ToolCall] = [] def _create_tool_call(name: str, raw_args: str) -> None: @@ -271,45 +261,27 @@ def _create_tool_call(name: str, raw_args: str) -> None: ) ) - all_calls = [] - for match in TOOL_CALL_RE.finditer(cleaned): - all_calls.append( - { - "start": match.start(), - "name": unescape_llm_text((match.group(1) or "").strip()), - "args": (match.group(2) or "").strip(), - } - ) - - all_calls.sort(key=lambda x: x["start"]) - - if extract: - for call in all_calls: - _create_tool_call(call["name"], call["args"]) + for match in TOOL_CALL_RE.finditer(text): + _create_tool_call(match.group(1), match.group(2)) - cleaned = TOOL_BLOCK_RE.sub("", cleaned) - cleaned = TOOL_CALL_RE.sub("", cleaned) - cleaned = RESPONSE_BLOCK_RE.sub("", cleaned) - cleaned = RESPONSE_ITEM_RE.sub("", cleaned) - cleaned = TAGGED_ARG_RE.sub("", cleaned) - cleaned = TAGGED_RESULT_RE.sub("", cleaned) + cleaned = strip_system_hints(text) return cleaned, tool_calls def remove_tool_call_blocks(text: str) -> str: - """Strip tool call code blocks from text.""" + """Strip tool call blocks from text for display.""" cleaned, _ = _process_tools_internal(text, extract=False) return cleaned def extract_tool_calls(text: str) -> tuple[str, list[ToolCall]]: - """Extract tool call definitions and return cleaned text.""" + """Extract tool calls and return cleaned text.""" return _process_tools_internal(text, extract=True) def text_from_message(message: Message) -> str: - """Return text content from a message for token estimation.""" + """Concatenate text and tool arguments from a message for token estimation.""" base_text = "" if isinstance(message.content, str): base_text = message.content @@ -329,7 +301,6 @@ def text_from_message(message: Message) -> str: def extract_image_dimensions(data: bytes) -> tuple[int | None, int | None]: """Return image dimensions (width, height) if PNG or JPEG headers are present.""" - # PNG: dimensions stored in bytes 16..24 of the IHDR chunk if len(data) >= 24 and data.startswith(b"\x89PNG\r\n\x1a\n"): try: width, height = struct.unpack(">II", data[16:24]) @@ -337,7 +308,6 @@ def extract_image_dimensions(data: bytes) -> tuple[int | None, int | None]: except struct.error: return None, None - # JPEG: dimensions stored in SOF segment; iterate through markers to locate it if len(data) >= 4 and data[0:2] == b"\xff\xd8": idx = 2 length = len(data) @@ -357,7 +327,6 @@ def extract_image_dimensions(data: bytes) -> tuple[int | None, int | None]: 0xCF, } while idx < length: - # Find marker alignment (markers are prefixed with 0xFF bytes) if data[idx] != 0xFF: idx += 1 continue @@ -380,7 +349,6 @@ def extract_image_dimensions(data: bytes) -> tuple[int | None, int | None]: if marker in sof_markers: if idx + 4 < length: - # Skip precision byte at idx, then read height/width (big-endian) height = (data[idx + 1] << 8) + data[idx + 2] width = (data[idx + 3] << 8) + data[idx + 4] return int(width), int(height) From 0d18e9e84525c346bf4cf5fb3b545f7884f2157f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C6=B0u=20Quang=20V=C5=A9?= Date: Thu, 12 Feb 2026 16:55:17 +0700 Subject: [PATCH 102/291] Refactor: Rewrite the function call format to match the client's complex argument structure, such as in Roo Code. --- app/server/chat.py | 25 +++++++++++-------------- app/services/client.py | 14 +++----------- app/utils/helper.py | 15 ++++++--------- 3 files changed, 20 insertions(+), 34 deletions(-) diff --git a/app/server/chat.py b/app/server/chat.py index 4262d0d..66c6d11 100644 --- a/app/server/chat.py +++ b/app/server/chat.py @@ -382,14 +382,11 @@ def _build_tool_prompt( ) lines.append("[ToolCalls]") lines.append("[Call:tool_name]") - lines.append("@args") - lines.append("<<>>") - lines.append("value") - lines.append("<<>>") + lines.append("[CallParameter:arg_name]value[/CallParameter]") lines.append("[/Call]") lines.append("[/ToolCalls]") lines.append( - "CRITICAL: Every argument MUST be enclosed in <<>>...<<>>. Output as RAW text. Content inside tags can be any format." + "CRITICAL: Every argument MUST be enclosed in [CallParameter:arg_name]...[/CallParameter]. Output as RAW text. Content inside tags can be any format." ) lines.append( "If multiple tools are needed, list them sequentially within the same [ToolCalls] block." @@ -398,7 +395,7 @@ def _build_tool_prompt( "If no tool call is needed, provide a normal response and NEVER use the [ToolCalls] tag." ) lines.append( - "Note: Tool results are returned in a [ToolResults] block using @results and <<>> tags." + "Note: Tool results are returned in a [ToolResults] block using [ToolResult] tags." ) return "\n".join(lines) @@ -793,16 +790,12 @@ def __init__(self): "ends": ["[/ToolResults]", "\\[/ToolResults\\]"], }, "ARG": { - "starts": [ - "<<>>", "\\<\\<\\\\>\\>"], + "starts": ["[CallParameter:", "\\[CallParameter:", "\\[CallParameter\\:"], + "ends": ["[/CallParameter]", "\\[/CallParameter\\]"], }, "RESULT": { - "starts": ["<<>>", "\\<\\<\\\\>\\>"], - "ends": ["<<>>", "\\<\\<\\\\>\\>"], + "starts": ["[ToolResult]", "\\[ToolResult\\]"], + "ends": ["[/ToolResult]", "\\[/ToolResult\\]"], }, "TAG": { "starts": ["<|im_start|>", "\\<|im\\_start|\\>"], @@ -824,6 +817,10 @@ def __init__(self): "\\[/Call\\]", "[/ToolCalls]", "\\[/ToolCalls\\]", + "[/CallParameter]", + "\\[/CallParameter\\]", + "[/ToolResult]", + "\\[/ToolResult\\]", ] self.WATCH_MARKERS = [] diff --git a/app/services/client.py b/app/services/client.py index ba203d9..9f9ac0f 100644 --- a/app/services/client.py +++ b/app/services/client.py @@ -118,10 +118,7 @@ async def process_message( tool_name = message.name or "unknown" combined_content = "\n".join(text_fragments).strip() res_block = ( - f"[Result:{tool_name}]\n" - f"@results\n" - f"<<>>\n{combined_content}\n<<>>\n" - f"[/Result]" + f"[Result:{tool_name}]\n[ToolResult]\n{combined_content}\n[/ToolResult]\n[/Result]" ) if wrap_tool: text_fragments = [f"[ToolResults]\n{res_block}\n[/ToolResults]"] @@ -138,9 +135,7 @@ async def process_message( if isinstance(parsed_args, dict): for k, v in parsed_args.items(): val_str = v if isinstance(v, str) else orjson.dumps(v).decode("utf-8") - formatted_args += ( - f"<<>>\n{val_str}\n<<>>\n" - ) + formatted_args += f"[CallParameter:{k}]{val_str}[/CallParameter]\n" else: formatted_args += args_text except orjson.JSONDecodeError: @@ -171,7 +166,6 @@ async def process_conversation( while i < len(messages): msg = messages[i] if msg.role == "tool": - # Group consecutive tool messages tool_blocks: list[str] = [] while i < len(messages) and messages[i].role == "tool": part, part_files = await GeminiClientWrapper.process_message( @@ -182,9 +176,7 @@ async def process_conversation( i += 1 combined_tool_content = "\n".join(tool_blocks) - wrapped_content = ( - f"[function_responses]\n{combined_tool_content}\n[/function_responses]" - ) + wrapped_content = f"[ToolResults]\n{combined_tool_content}\n[/ToolResults]" conversation.append(add_tag("tool", wrapped_content)) else: input_part, files_part = await GeminiClientWrapper.process_message( diff --git a/app/utils/helper.py b/app/utils/helper.py index 25f9c9b..4172154 100644 --- a/app/utils/helper.py +++ b/app/utils/helper.py @@ -21,13 +21,10 @@ "\nWhen you decide to call tools, you MUST respond ONLY with a single [ToolCalls] block using this EXACT syntax:\n" "[ToolCalls]\n" "[Call:tool_name]\n" - "@args\n" - "<<>>\n" - "value\n" - "<<>>\n" + "[CallParameter:arg_name]value[/CallParameter]\n" "[/Call]\n" "[/ToolCalls]\n" - "CRITICAL: Every argument MUST be enclosed in <<>>...<<>>. Output as RAW text. Content inside tags can be any format.\n" + "CRITICAL: Every argument MUST be enclosed in [CallParameter:arg_name]...[/CallParameter]. Output as RAW text. Content inside tags can be any format.\n" ) TOOL_BLOCK_RE = re.compile( r"\\?\[ToolCalls\\?]\s*(.*?)\s*\\?\[/ToolCalls\\?]", re.DOTALL | re.IGNORECASE @@ -44,11 +41,11 @@ re.DOTALL | re.IGNORECASE, ) TAGGED_ARG_RE = re.compile( - r"(?:\\?<){3}CallParameter\\?:((?:[^>\\]|\\.)+)(?:\\?>){3}\s*(.*?)\s*(?:\\?<){3}EndCallParameter(?:\\?>){3}", + r"\\?\[CallParameter\\?:((?:[^]\\]|\\.)+)\\?]\s*(.*?)\s*\\?\[/CallParameter\\?]", re.DOTALL | re.IGNORECASE, ) TAGGED_RESULT_RE = re.compile( - r"(?:\\?<){3}ToolResult(?:\\?>){3}\s*(.*?)\s*(?:\\?<){3}EndToolResult(?:\\?>){3}", + r"\\?\[ToolResult\\?]\s*(.*?)\s*\\?\[/ToolResult\\?]", re.DOTALL | re.IGNORECASE, ) CONTROL_TOKEN_RE = re.compile(r"\\?<\|im\\?_(?:start|end)\|\\?>", re.IGNORECASE) @@ -86,7 +83,7 @@ def normalize_llm_text(s: str) -> str: def unescape_llm_text(s: str) -> str: - """Unescape characters escaped by Gemini Web's post-processing (e.g., \\_ to _).""" + """Unescape characters escaped by Gemini Web's post-processing.""" return COMMONMARK_UNESCAPE_RE.sub(r"\1", s) @@ -240,7 +237,7 @@ def _create_tool_call(name: str, raw_args: str) -> None: arguments = orjson.dumps(args_dict).decode("utf-8") logger.debug(f"Successfully parsed {len(args_dict)} tagged arguments for tool: {name}") else: - cleaned_raw = raw_args.replace("@args", "").strip() + cleaned_raw = raw_args.strip() if not cleaned_raw: logger.debug(f"Tool '{name}' called without arguments.") else: From 8fa4329c5a1483784876630e0a891e7dba781fdd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C6=B0u=20Quang=20V=C5=A9?= Date: Thu, 12 Feb 2026 17:11:30 +0700 Subject: [PATCH 103/291] Refactor: Rewrite the function call format to match the client's complex argument structure, such as in Roo Code. --- app/server/chat.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/app/server/chat.py b/app/server/chat.py index 66c6d11..c31a079 100644 --- a/app/server/chat.py +++ b/app/server/chat.py @@ -49,6 +49,7 @@ estimate_tokens, extract_image_dimensions, extract_tool_calls, + remove_tool_call_blocks, strip_system_hints, text_from_message, ) @@ -221,7 +222,7 @@ def _process_llm_output( structured_requirement: StructuredOutputRequirement | None, ) -> tuple[str, str, list[Any]]: """ - Common post-processing logic for Gemini output. + Post-process Gemini output to extract tool calls and prepare clean text for display and storage. Returns: (visible_text, storage_output, tool_calls) """ visible_with_think, tool_calls = extract_tool_calls(raw_output_with_think) @@ -230,7 +231,7 @@ def _process_llm_output( visible_output = visible_with_think.strip() - storage_output, _ = extract_tool_calls(raw_output_clean) + storage_output = remove_tool_call_blocks(raw_output_clean) storage_output = storage_output.strip() if structured_requirement: From dcd7276ee41a202eb840494c986074da3a53499d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C6=B0u=20Quang=20V=C5=A9?= Date: Thu, 12 Feb 2026 20:36:20 +0700 Subject: [PATCH 104/291] Refactor: Rewrite the function call format to match the client's complex argument structure, such as in Roo Code. --- app/services/client.py | 33 +-------------------------------- app/utils/helper.py | 35 +++++++++++++++++++++++++++++++---- 2 files changed, 32 insertions(+), 36 deletions(-) diff --git a/app/services/client.py b/app/services/client.py index 9f9ac0f..6ab80cd 100644 --- a/app/services/client.py +++ b/app/services/client.py @@ -1,4 +1,3 @@ -import re from pathlib import Path from typing import Any, cast @@ -15,13 +14,6 @@ save_url_to_tempfile, ) -FILE_PATH_PATTERN = re.compile( - r"^(?=.*[./\\]|.*:\d+|^(?:Dockerfile|Makefile|Jenkinsfile|Procfile|Rakefile|Gemfile|Vagrantfile|Caddyfile|Justfile|LICENSE|README|CONTRIBUTING|CODEOWNERS|AUTHORS|NOTICE|CHANGELOG)$)([a-zA-Z0-9_./\\-]+(?::\d+)?)$", - re.IGNORECASE, -) -GOOGLE_SEARCH_LINK_PATTERN = re.compile( - r"`?\[`?(.+?)`?`?]\((https://www\.google\.com/search\?q=)([^)]*)\)`?" -) _UNSET = object() @@ -199,27 +191,4 @@ def extract_output(response: ModelOutput, include_thoughts: bool = True) -> str: else: text += str(response) - text = normalize_llm_text(text) - - def extract_file_path_from_display_text(text_content: str) -> str | None: - match = re.match(FILE_PATH_PATTERN, text_content) - if match: - return match.group(1) - return None - - def replacer(match: re.Match) -> str: - display_text = str(match.group(1)).strip() - google_search_prefix = match.group(2) - query_part = match.group(3) - - file_path = extract_file_path_from_display_text(display_text) - - if file_path: - # If it's a file path, transform it into a self-referencing Markdown link - return f"[`{file_path}`]({file_path})" - else: - # Otherwise, reconstruct the original Google search link with the display_text - original_google_search_url = f"{google_search_prefix}{query_part}" - return f"[`{display_text}`]({original_google_search_url})" - - return re.sub(GOOGLE_SEARCH_LINK_PATTERN, replacer, text) + return normalize_llm_text(text) diff --git a/app/utils/helper.py b/app/utils/helper.py index 4172154..ec39ebc 100644 --- a/app/utils/helper.py +++ b/app/utils/helper.py @@ -52,6 +52,16 @@ CHATML_START_RE = re.compile(r"\\?<\|im\\?_start\|\\?>\s*(\w+)\s*\n?", re.IGNORECASE) CHATML_END_RE = re.compile(r"\\?<\|im\\?_end\|\\?>", re.IGNORECASE) COMMONMARK_UNESCAPE_RE = re.compile(r"\\([!\"#$%&'()*+,\-./:;<=>?@\[\\\]^_`{|}~])") +FILE_PATH_PATTERN = re.compile( + r"^(?=.*[./\\]|.*:\d+|^(?:Dockerfile|Makefile|Jenkinsfile|Procfile|Rakefile|Gemfile|Vagrantfile|Caddyfile|Justfile|LICENSE|README|CONTRIBUTING|CODEOWNERS|AUTHORS|NOTICE|CHANGELOG)$)([a-zA-Z0-9_./\\-]+(?::\d+)?)$", + re.IGNORECASE, +) +GOOGLE_SEARCH_LINK_PATTERN = re.compile( + r"`?\[`?(.+?)`?`?]\((https://www\.google\.com/search\?q=)([^)]*)\)`?" +) +CONFLICT_START_RE = re.compile(r"<(?:\s*<){6,}") +CONFLICT_SEP_RE = re.compile(r"=(?:\s*=){6,}") +CONFLICT_END_RE = re.compile(r">(?:\s*>){6,}") TOOL_HINT_STRIPPED = TOOL_WRAP_HINT.strip() _hint_lines = [line.strip() for line in TOOL_WRAP_HINT.split("\n") if line.strip()] TOOL_HINT_LINE_START = _hint_lines[0] if _hint_lines else "" @@ -82,9 +92,26 @@ def normalize_llm_text(s: str) -> str: return s +def _strip_google_search_links(match: re.Match) -> str: + """Extract local Markdown link from Google Search links if applicable.""" + display_text = str(match.group(1)).strip() + if FILE_PATH_PATTERN.match(display_text): + return f"[`{display_text}`]({display_text})" + return match.group(0) + + def unescape_llm_text(s: str) -> str: - """Unescape characters escaped by Gemini Web's post-processing.""" - return COMMONMARK_UNESCAPE_RE.sub(r"\1", s) + """Unescape and mend text fragments broken by Gemini Web's post-processing.""" + if not s: + return "" + + s = COMMONMARK_UNESCAPE_RE.sub(r"\1", s) + + s = CONFLICT_START_RE.sub("<<<<<<<", s) + s = CONFLICT_SEP_RE.sub("=======", s) + s = CONFLICT_END_RE.sub(">>>>>>>", s) + + return GOOGLE_SEARCH_LINK_PATTERN.sub(_strip_google_search_links, s) def estimate_tokens(text: str | None) -> int: @@ -235,11 +262,11 @@ def _create_tool_call(name: str, raw_args: str) -> None: if arg_matches: args_dict = {arg_name.strip(): arg_value.strip() for arg_name, arg_value in arg_matches} arguments = orjson.dumps(args_dict).decode("utf-8") - logger.debug(f"Successfully parsed {len(args_dict)} tagged arguments for tool: {name}") + logger.debug(f"Successfully parsed {len(args_dict)} arguments for tool: {name}") else: cleaned_raw = raw_args.strip() if not cleaned_raw: - logger.debug(f"Tool '{name}' called without arguments.") + logger.debug(f"Successfully parsed 0 arguments for tool: {name}") else: logger.warning( f"Malformed arguments for tool '{name}'. Text found but no valid tags: {reprlib.repr(cleaned_raw)}" From 737aa3aa4ac51a377ab3687e73623c7943c14872 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C6=B0u=20Quang=20V=C5=A9?= Date: Thu, 12 Feb 2026 23:24:44 +0700 Subject: [PATCH 105/291] Refactor: Rewrite the function call format to match the client's complex argument structure, such as in Roo Code. --- app/utils/helper.py | 66 ++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 63 insertions(+), 3 deletions(-) diff --git a/app/utils/helper.py b/app/utils/helper.py index ec39ebc..0a83f8d 100644 --- a/app/utils/helper.py +++ b/app/utils/helper.py @@ -100,18 +100,78 @@ def _strip_google_search_links(match: re.Match) -> str: return match.group(0) +def _remove_injected_fences(s: str) -> str: + """ + Strip anonymous Markdown code fences often injected by LLMs around + responses or tool calls, while preserving named blocks and all internal content. + """ + if not s: + return "" + + lines = s.splitlines() + out = [] + in_fence = False + fence_len = 0 + is_anonymous = False + + for line in lines: + stripped = line.strip() + if stripped.startswith("```"): + count = 0 + for char in stripped: + if char == "`": + count += 1 + else: + break + + lang = stripped[count:].strip() + + if not in_fence: + in_fence = True + fence_len = count + is_anonymous = not lang + if not is_anonymous: + out.append(line) + continue + + if count >= fence_len: + if is_anonymous and lang: + out.append(line) + continue + + if not is_anonymous: + out.append(line) + in_fence = False + is_anonymous = False + fence_len = 0 + continue + + out.append(line) + + return "\n".join(out) + + def unescape_llm_text(s: str) -> str: - """Unescape and mend text fragments broken by Gemini Web's post-processing.""" + """ + Standardize and repair LLM-generated text fragments. + + Sequence: + 1. Reverse CommonMark escapes. + 2. Restore git conflict markers broken by web processing. + 3. Strip injected anonymous code fences. + 4. Process and normalize Google Search links. + """ if not s: return "" s = COMMONMARK_UNESCAPE_RE.sub(r"\1", s) - s = CONFLICT_START_RE.sub("<<<<<<<", s) s = CONFLICT_SEP_RE.sub("=======", s) s = CONFLICT_END_RE.sub(">>>>>>>", s) + s = _remove_injected_fences(s) + s = GOOGLE_SEARCH_LINK_PATTERN.sub(_strip_google_search_links, s) - return GOOGLE_SEARCH_LINK_PATTERN.sub(_strip_google_search_links, s) + return s def estimate_tokens(text: str | None) -> int: From 2c808955f8f761e1ab062c4d2cd5aab705ac050c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C6=B0u=20Quang=20V=C5=A9?= Date: Fri, 13 Feb 2026 08:55:58 +0700 Subject: [PATCH 106/291] Refactor: Rewrite the function call format to match the client's complex argument structure, such as in Roo Code. --- app/utils/helper.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/app/utils/helper.py b/app/utils/helper.py index 0a83f8d..2367b72 100644 --- a/app/utils/helper.py +++ b/app/utils/helper.py @@ -61,6 +61,7 @@ ) CONFLICT_START_RE = re.compile(r"<(?:\s*<){6,}") CONFLICT_SEP_RE = re.compile(r"=(?:\s*=){6,}") +CONFLICT_SEP_DASH_RE = re.compile(r"[-—](?:\s*[-—]){6,}") CONFLICT_END_RE = re.compile(r">(?:\s*>){6,}") TOOL_HINT_STRIPPED = TOOL_WRAP_HINT.strip() _hint_lines = [line.strip() for line in TOOL_WRAP_HINT.split("\n") if line.strip()] @@ -167,6 +168,7 @@ def unescape_llm_text(s: str) -> str: s = COMMONMARK_UNESCAPE_RE.sub(r"\1", s) s = CONFLICT_START_RE.sub("<<<<<<<", s) s = CONFLICT_SEP_RE.sub("=======", s) + s = CONFLICT_SEP_DASH_RE.sub("-------", s) s = CONFLICT_END_RE.sub(">>>>>>>", s) s = _remove_injected_fences(s) s = GOOGLE_SEARCH_LINK_PATTERN.sub(_strip_google_search_links, s) From 9b0e1d5365ba323a406f77c7452283ef7a9879a5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C6=B0u=20Quang=20V=C5=A9?= Date: Fri, 13 Feb 2026 09:23:08 +0700 Subject: [PATCH 107/291] Refactor: Rewrite the function call format to match the client's complex argument structure, such as in Roo Code. --- app/utils/helper.py | 23 +++++++++++++---------- 1 file changed, 13 insertions(+), 10 deletions(-) diff --git a/app/utils/helper.py b/app/utils/helper.py index 2367b72..e612252 100644 --- a/app/utils/helper.py +++ b/app/utils/helper.py @@ -59,10 +59,10 @@ GOOGLE_SEARCH_LINK_PATTERN = re.compile( r"`?\[`?(.+?)`?`?]\((https://www\.google\.com/search\?q=)([^)]*)\)`?" ) -CONFLICT_START_RE = re.compile(r"<(?:\s*<){6,}") -CONFLICT_SEP_RE = re.compile(r"=(?:\s*=){6,}") -CONFLICT_SEP_DASH_RE = re.compile(r"[-—](?:\s*[-—]){6,}") -CONFLICT_END_RE = re.compile(r">(?:\s*>){6,}") +CONFLICT_START_RE = re.compile(r"(\\?)\s*<\s*(?:<\s*){6,}") +CONFLICT_SEP_RE = re.compile(r"(\\?)\s*=\s*(?:=\s*){6,}") +CONFLICT_SEP_DASH_RE = re.compile(r"(\\?)\s*[-—]\s*(?:[-—]\s*){6,}") +CONFLICT_END_RE = re.compile(r"(\\?)\s*>\s*(?:>\s*){6,}") TOOL_HINT_STRIPPED = TOOL_WRAP_HINT.strip() _hint_lines = [line.strip() for line in TOOL_WRAP_HINT.split("\n") if line.strip()] TOOL_HINT_LINE_START = _hint_lines[0] if _hint_lines else "" @@ -155,21 +155,24 @@ def _remove_injected_fences(s: str) -> str: def unescape_llm_text(s: str) -> str: """ Standardize and repair LLM-generated text fragments. + These patches are specifically designed for complex clients like Roo Code to ensure + compatibility with their specialized tool protocols (e.g., apply_diff) which may be + mangled by Gemini Web's interface or browser auto-formatting. Sequence: - 1. Reverse CommonMark escapes. - 2. Restore git conflict markers broken by web processing. + 1. Restore git conflict markers and DOUBLE any leading backslash to protect it. + 2. Reverse CommonMark escapes (consuming one level of doubled backslashes). 3. Strip injected anonymous code fences. 4. Process and normalize Google Search links. """ if not s: return "" + s = CONFLICT_START_RE.sub(r"\1\1<<<<<<<", s) + s = CONFLICT_SEP_RE.sub(r"\1\1=======", s) + s = CONFLICT_SEP_DASH_RE.sub(r"\1\1-------", s) + s = CONFLICT_END_RE.sub(r"\1\1>>>>>>>", s) s = COMMONMARK_UNESCAPE_RE.sub(r"\1", s) - s = CONFLICT_START_RE.sub("<<<<<<<", s) - s = CONFLICT_SEP_RE.sub("=======", s) - s = CONFLICT_SEP_DASH_RE.sub("-------", s) - s = CONFLICT_END_RE.sub(">>>>>>>", s) s = _remove_injected_fences(s) s = GOOGLE_SEARCH_LINK_PATTERN.sub(_strip_google_search_links, s) From 95f87f6d905cdbb1352698e03f29e0821a224d31 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C6=B0u=20Quang=20V=C5=A9?= Date: Fri, 13 Feb 2026 09:38:08 +0700 Subject: [PATCH 108/291] Refactor: Rewrite the function call format to match the client's complex argument structure, such as in Roo Code. --- app/utils/helper.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/app/utils/helper.py b/app/utils/helper.py index e612252..8262b85 100644 --- a/app/utils/helper.py +++ b/app/utils/helper.py @@ -60,8 +60,8 @@ r"`?\[`?(.+?)`?`?]\((https://www\.google\.com/search\?q=)([^)]*)\)`?" ) CONFLICT_START_RE = re.compile(r"(\\?)\s*<\s*(?:<\s*){6,}") -CONFLICT_SEP_RE = re.compile(r"(\\?)\s*=\s*(?:=\s*){6,}") -CONFLICT_SEP_DASH_RE = re.compile(r"(\\?)\s*[-—]\s*(?:[-—]\s*){6,}") +CONFLICT_SEP_RE = re.compile(r"=(?:\s*=){6,}") +CONFLICT_SEP_DASH_RE = re.compile(r"[-—](?:\s*[-—]){6,}") CONFLICT_END_RE = re.compile(r"(\\?)\s*>\s*(?:>\s*){6,}") TOOL_HINT_STRIPPED = TOOL_WRAP_HINT.strip() _hint_lines = [line.strip() for line in TOOL_WRAP_HINT.split("\n") if line.strip()] @@ -169,8 +169,8 @@ def unescape_llm_text(s: str) -> str: return "" s = CONFLICT_START_RE.sub(r"\1\1<<<<<<<", s) - s = CONFLICT_SEP_RE.sub(r"\1\1=======", s) - s = CONFLICT_SEP_DASH_RE.sub(r"\1\1-------", s) + s = CONFLICT_SEP_RE.sub("=======", s) + s = CONFLICT_SEP_DASH_RE.sub("-------", s) s = CONFLICT_END_RE.sub(r"\1\1>>>>>>>", s) s = COMMONMARK_UNESCAPE_RE.sub(r"\1", s) s = _remove_injected_fences(s) From 4569689ad1280aa2808927345c1a2b29e170f997 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C6=B0u=20Quang=20V=C5=A9?= Date: Fri, 13 Feb 2026 10:47:56 +0700 Subject: [PATCH 109/291] Refactor: Update `unescape_llm_text` to standardize and repair LLM-generated text fragments. - These patches are specifically designed for complex clients like Roo Code to ensure compatibility with their specialized tool protocols. --- app/utils/helper.py | 20 ++++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/app/utils/helper.py b/app/utils/helper.py index 8262b85..89429c9 100644 --- a/app/utils/helper.py +++ b/app/utils/helper.py @@ -59,10 +59,10 @@ GOOGLE_SEARCH_LINK_PATTERN = re.compile( r"`?\[`?(.+?)`?`?]\((https://www\.google\.com/search\?q=)([^)]*)\)`?" ) -CONFLICT_START_RE = re.compile(r"(\\?)\s*<\s*(?:<\s*){6,}") -CONFLICT_SEP_RE = re.compile(r"=(?:\s*=){6,}") -CONFLICT_SEP_DASH_RE = re.compile(r"[-—](?:\s*[-—]){6,}") -CONFLICT_END_RE = re.compile(r"(\\?)\s*>\s*(?:>\s*){6,}") +CONFLICT_START_RE = re.compile(r"(\\?)\s*<\s*(?:<\s*){6,}(?:\s*(SEARCH)\b)?", re.IGNORECASE) +CONFLICT_SEP_RE = re.compile(r"(\\?)\s*=(?:\s*=){6,}") +CONFLICT_SEP_DASH_RE = re.compile(r"(\\?)\s*[-—](?:\s*[-—]){6,}") +CONFLICT_END_RE = re.compile(r"(\\?)\s*>\s*(?:>\s*){6,}(?:\s*(REPLACE)\b)?", re.IGNORECASE) TOOL_HINT_STRIPPED = TOOL_WRAP_HINT.strip() _hint_lines = [line.strip() for line in TOOL_WRAP_HINT.split("\n") if line.strip()] TOOL_HINT_LINE_START = _hint_lines[0] if _hint_lines else "" @@ -168,10 +168,14 @@ def unescape_llm_text(s: str) -> str: if not s: return "" - s = CONFLICT_START_RE.sub(r"\1\1<<<<<<<", s) - s = CONFLICT_SEP_RE.sub("=======", s) - s = CONFLICT_SEP_DASH_RE.sub("-------", s) - s = CONFLICT_END_RE.sub(r"\1\1>>>>>>>", s) + s = CONFLICT_START_RE.sub( + lambda m: (m.group(1) or "") + "<<<<<<<" + (" SEARCH" if m.group(2) else ""), s + ) + s = CONFLICT_SEP_RE.sub(lambda m: (m.group(1) or "") + "=======", s) + s = CONFLICT_SEP_DASH_RE.sub(lambda m: (m.group(1) or "") + "-------", s) + s = CONFLICT_END_RE.sub( + lambda m: (m.group(1) or "") + ">>>>>>>" + (" REPLACE" if m.group(2) else ""), s + ) s = COMMONMARK_UNESCAPE_RE.sub(r"\1", s) s = _remove_injected_fences(s) s = GOOGLE_SEARCH_LINK_PATTERN.sub(_strip_google_search_links, s) From 5a65cb6a9ebb6a51a21016f5d977bbef76f23f55 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C6=B0u=20Quang=20V=C5=A9?= Date: Fri, 13 Feb 2026 10:56:50 +0700 Subject: [PATCH 110/291] Refactor: Update `unescape_llm_text` to standardize and repair LLM-generated text fragments. - These patches are specifically designed for complex clients like Roo Code to ensure compatibility with their specialized tool protocols. --- app/utils/helper.py | 31 ++++++++++++++++--------------- 1 file changed, 16 insertions(+), 15 deletions(-) diff --git a/app/utils/helper.py b/app/utils/helper.py index 89429c9..ff339e1 100644 --- a/app/utils/helper.py +++ b/app/utils/helper.py @@ -154,28 +154,29 @@ def _remove_injected_fences(s: str) -> str: def unescape_llm_text(s: str) -> str: """ - Standardize and repair LLM-generated text fragments. - These patches are specifically designed for complex clients like Roo Code to ensure - compatibility with their specialized tool protocols (e.g., apply_diff) which may be - mangled by Gemini Web's interface or browser auto-formatting. + Standardize and repair LLM-generated text fragments for specialized client protocols. + Designed to ensure compatibility with clients like Roo Code by fixing + mangled conflict markers, escaping issues, and injected Markdown formatting. Sequence: - 1. Restore git conflict markers and DOUBLE any leading backslash to protect it. - 2. Reverse CommonMark escapes (consuming one level of doubled backslashes). - 3. Strip injected anonymous code fences. + 1. Normalize git conflict markers (handles mangled spacing and keyword standardization). + 2. Reverse CommonMark escapes (removes leading backslashes from structural markers). + 3. Strip injected anonymous Markdown code fences. 4. Process and normalize Google Search links. """ if not s: return "" - s = CONFLICT_START_RE.sub( - lambda m: (m.group(1) or "") + "<<<<<<<" + (" SEARCH" if m.group(2) else ""), s - ) - s = CONFLICT_SEP_RE.sub(lambda m: (m.group(1) or "") + "=======", s) - s = CONFLICT_SEP_DASH_RE.sub(lambda m: (m.group(1) or "") + "-------", s) - s = CONFLICT_END_RE.sub( - lambda m: (m.group(1) or "") + ">>>>>>>" + (" REPLACE" if m.group(2) else ""), s - ) + if any(c in s for c in ("<", "=", ">", "-", "—")): + s = CONFLICT_START_RE.sub( + lambda m: (m.group(1) or "") + "<<<<<<<" + (" SEARCH" if m.group(2) else ""), s + ) + s = CONFLICT_SEP_RE.sub(lambda m: (m.group(1) or "") + "=======", s) + s = CONFLICT_SEP_DASH_RE.sub(lambda m: (m.group(1) or "") + "-------", s) + s = CONFLICT_END_RE.sub( + lambda m: (m.group(1) or "") + ">>>>>>>" + (" REPLACE" if m.group(2) else ""), s + ) + s = COMMONMARK_UNESCAPE_RE.sub(r"\1", s) s = _remove_injected_fences(s) s = GOOGLE_SEARCH_LINK_PATTERN.sub(_strip_google_search_links, s) From 8a03c3347bce1e41bb0b694b42095aec62a9e6cc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C6=B0u=20Quang=20V=C5=A9?= Date: Fri, 13 Feb 2026 12:18:06 +0700 Subject: [PATCH 111/291] Refactor: Update `unescape_llm_text` to standardize and repair LLM-generated text fragments. - These patches are specifically designed for complex clients like Roo Code to ensure compatibility with their specialized tool protocols. --- app/utils/helper.py | 32 ++++++++++++++++---------------- 1 file changed, 16 insertions(+), 16 deletions(-) diff --git a/app/utils/helper.py b/app/utils/helper.py index ff339e1..fba276a 100644 --- a/app/utils/helper.py +++ b/app/utils/helper.py @@ -8,7 +8,7 @@ import tempfile import unicodedata from pathlib import Path -from urllib.parse import urlparse +from urllib.parse import unquote, urlparse import httpx import orjson @@ -56,8 +56,13 @@ r"^(?=.*[./\\]|.*:\d+|^(?:Dockerfile|Makefile|Jenkinsfile|Procfile|Rakefile|Gemfile|Vagrantfile|Caddyfile|Justfile|LICENSE|README|CONTRIBUTING|CODEOWNERS|AUTHORS|NOTICE|CHANGELOG)$)([a-zA-Z0-9_./\\-]+(?::\d+)?)$", re.IGNORECASE, ) -GOOGLE_SEARCH_LINK_PATTERN = re.compile( - r"`?\[`?(.+?)`?`?]\((https://www\.google\.com/search\?q=)([^)]*)\)`?" +GOOGLE_SEARCH_PATTERN = re.compile( + r"(?P`?\[`?)?" + r"(?P[^]]+)?" + r"(?(md_start)`?]\()?" + r"https://www\.google\.com/search\?q=(?P[^&\s\"'<>)]+)" + r"(?(md_start)\)?`?)", + re.IGNORECASE, ) CONFLICT_START_RE = re.compile(r"(\\?)\s*<\s*(?:<\s*){6,}(?:\s*(SEARCH)\b)?", re.IGNORECASE) CONFLICT_SEP_RE = re.compile(r"(\\?)\s*=(?:\s*=){6,}") @@ -93,11 +98,13 @@ def normalize_llm_text(s: str) -> str: return s -def _strip_google_search_links(match: re.Match) -> str: - """Extract local Markdown link from Google Search links if applicable.""" - display_text = str(match.group(1)).strip() - if FILE_PATH_PATTERN.match(display_text): - return f"[`{display_text}`]({display_text})" +def _strip_google_search(match: re.Match) -> str: + """Extract raw text from Google Search links if it looks like a file path.""" + text_to_check = match.group("text") if match.group("text") else unquote(match.group("query")) + text_to_check = unquote(text_to_check.strip()) + + if FILE_PATH_PATTERN.match(text_to_check): + return text_to_check return match.group(0) @@ -157,12 +164,6 @@ def unescape_llm_text(s: str) -> str: Standardize and repair LLM-generated text fragments for specialized client protocols. Designed to ensure compatibility with clients like Roo Code by fixing mangled conflict markers, escaping issues, and injected Markdown formatting. - - Sequence: - 1. Normalize git conflict markers (handles mangled spacing and keyword standardization). - 2. Reverse CommonMark escapes (removes leading backslashes from structural markers). - 3. Strip injected anonymous Markdown code fences. - 4. Process and normalize Google Search links. """ if not s: return "" @@ -179,7 +180,7 @@ def unescape_llm_text(s: str) -> str: s = COMMONMARK_UNESCAPE_RE.sub(r"\1", s) s = _remove_injected_fences(s) - s = GOOGLE_SEARCH_LINK_PATTERN.sub(_strip_google_search_links, s) + s = GOOGLE_SEARCH_PATTERN.sub(_strip_google_search, s) return s @@ -413,7 +414,6 @@ def extract_image_dimensions(data: bytes) -> tuple[int | None, int | None]: 0xC5, 0xC6, 0xC7, - 0xC9, 0xCA, 0xCB, 0xCD, From 7ed21323a8b51798c3b02c1ce625e94a31b603cf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C6=B0u=20Quang=20V=C5=A9?= Date: Fri, 13 Feb 2026 12:22:21 +0700 Subject: [PATCH 112/291] Refactor: Update `unescape_llm_text` to standardize and repair LLM-generated text fragments. - These patches are specifically designed for complex clients like Roo Code to ensure compatibility with their specialized tool protocols. --- app/utils/helper.py | 1 + 1 file changed, 1 insertion(+) diff --git a/app/utils/helper.py b/app/utils/helper.py index fba276a..8faa8b9 100644 --- a/app/utils/helper.py +++ b/app/utils/helper.py @@ -414,6 +414,7 @@ def extract_image_dimensions(data: bytes) -> tuple[int | None, int | None]: 0xC5, 0xC6, 0xC7, + 0xC9, 0xCA, 0xCB, 0xCD, From d92bc1c43a051c818fd0810be94710580994b40f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C6=B0u=20Quang=20V=C5=A9?= Date: Fri, 13 Feb 2026 15:28:52 +0700 Subject: [PATCH 113/291] Refactor: Update `unescape_llm_text` to standardize and repair LLM-generated text fragments. - These patches are specifically designed for complex clients like Roo Code to ensure compatibility with their specialized tool protocols. --- app/server/chat.py | 12 +--- app/utils/helper.py | 139 +++++++++----------------------------------- 2 files changed, 30 insertions(+), 121 deletions(-) diff --git a/app/server/chat.py b/app/server/chat.py index c31a079..3f8e0cd 100644 --- a/app/server/chat.py +++ b/app/server/chat.py @@ -378,17 +378,7 @@ def _build_tool_prompt( f"You are required to call the tool named `{target}`. Do not call any other tool." ) - lines.append( - "When you decide to call tools, you MUST respond ONLY with a single [ToolCalls] block using this EXACT syntax:" - ) - lines.append("[ToolCalls]") - lines.append("[Call:tool_name]") - lines.append("[CallParameter:arg_name]value[/CallParameter]") - lines.append("[/Call]") - lines.append("[/ToolCalls]") - lines.append( - "CRITICAL: Every argument MUST be enclosed in [CallParameter:arg_name]...[/CallParameter]. Output as RAW text. Content inside tags can be any format." - ) + lines.append(TOOL_WRAP_HINT.strip()) lines.append( "If multiple tools are needed, list them sequentially within the same [ToolCalls] block." ) diff --git a/app/utils/helper.py b/app/utils/helper.py index 8faa8b9..28260c3 100644 --- a/app/utils/helper.py +++ b/app/utils/helper.py @@ -18,13 +18,13 @@ VALID_TAG_ROLES = {"user", "assistant", "system", "tool"} TOOL_WRAP_HINT = ( - "\nWhen you decide to call tools, you MUST respond ONLY with a single [ToolCalls] block using this EXACT syntax:\n" + "\nWhen calling tools, use this EXACT protocol:\n" "[ToolCalls]\n" "[Call:tool_name]\n" "[CallParameter:arg_name]value[/CallParameter]\n" "[/Call]\n" "[/ToolCalls]\n" - "CRITICAL: Every argument MUST be enclosed in [CallParameter:arg_name]...[/CallParameter]. Output as RAW text. Content inside tags can be any format.\n" + "CRITICAL: Wrap ALL multi-line or complex values in a markdown code block (e.g., [CallParameter:arg_name]```\nvalue\n```[/CallParameter]) to prevent rendering corruption.\n" ) TOOL_BLOCK_RE = re.compile( r"\\?\[ToolCalls\\?]\s*(.*?)\s*\\?\[/ToolCalls\\?]", re.DOTALL | re.IGNORECASE @@ -64,10 +64,6 @@ r"(?(md_start)\)?`?)", re.IGNORECASE, ) -CONFLICT_START_RE = re.compile(r"(\\?)\s*<\s*(?:<\s*){6,}(?:\s*(SEARCH)\b)?", re.IGNORECASE) -CONFLICT_SEP_RE = re.compile(r"(\\?)\s*=(?:\s*=){6,}") -CONFLICT_SEP_DASH_RE = re.compile(r"(\\?)\s*[-—](?:\s*[-—]){6,}") -CONFLICT_END_RE = re.compile(r"(\\?)\s*>\s*(?:>\s*){6,}(?:\s*(REPLACE)\b)?", re.IGNORECASE) TOOL_HINT_STRIPPED = TOOL_WRAP_HINT.strip() _hint_lines = [line.strip() for line in TOOL_WRAP_HINT.split("\n") if line.strip()] TOOL_HINT_LINE_START = _hint_lines[0] if _hint_lines else "" @@ -108,78 +104,36 @@ def _strip_google_search(match: re.Match) -> str: return match.group(0) -def _remove_injected_fences(s: str) -> str: +def _strip_param_fences(s: str) -> str: """ - Strip anonymous Markdown code fences often injected by LLMs around - responses or tool calls, while preserving named blocks and all internal content. + Remove one layer of outermost Markdown code fences, + supporting nested blocks by detecting variable fence lengths. """ + s = s.strip() if not s: return "" - lines = s.splitlines() - out = [] - in_fence = False - fence_len = 0 - is_anonymous = False - - for line in lines: - stripped = line.strip() - if stripped.startswith("```"): - count = 0 - for char in stripped: - if char == "`": - count += 1 - else: - break - - lang = stripped[count:].strip() - - if not in_fence: - in_fence = True - fence_len = count - is_anonymous = not lang - if not is_anonymous: - out.append(line) - continue + match = re.match(r"^(?P`{3,})", s) + if not match or not s.endswith(match.group("fence")): + return s - if count >= fence_len: - if is_anonymous and lang: - out.append(line) - continue - - if not is_anonymous: - out.append(line) - in_fence = False - is_anonymous = False - fence_len = 0 - continue - - out.append(line) + lines = s.splitlines() + if len(lines) >= 2: + return "\n".join(lines[1:-1]) - return "\n".join(out) + n = len(match.group("fence")) + return s[n:-n].strip() def unescape_llm_text(s: str) -> str: """ - Standardize and repair LLM-generated text fragments for specialized client protocols. - Designed to ensure compatibility with clients like Roo Code by fixing - mangled conflict markers, escaping issues, and injected Markdown formatting. + Standardize and repair LLM-generated text fragments (unescaping, link normalization) + to ensure compatibility with specialized clients like Roo Code. """ if not s: return "" - if any(c in s for c in ("<", "=", ">", "-", "—")): - s = CONFLICT_START_RE.sub( - lambda m: (m.group(1) or "") + "<<<<<<<" + (" SEARCH" if m.group(2) else ""), s - ) - s = CONFLICT_SEP_RE.sub(lambda m: (m.group(1) or "") + "=======", s) - s = CONFLICT_SEP_DASH_RE.sub(lambda m: (m.group(1) or "") + "-------", s) - s = CONFLICT_END_RE.sub( - lambda m: (m.group(1) or "") + ">>>>>>>" + (" REPLACE" if m.group(2) else ""), s - ) - s = COMMONMARK_UNESCAPE_RE.sub(r"\1", s) - s = _remove_injected_fences(s) s = GOOGLE_SEARCH_PATTERN.sub(_strip_google_search, s) return s @@ -196,13 +150,11 @@ async def save_file_to_tempfile( file_in_base64: str, file_name: str = "", tempdir: Path | None = None ) -> Path: """Decode base64 file data and save to a temporary file.""" - data = base64.b64decode(file_in_base64) - suffix = Path(file_name).suffix if file_name else ".bin" - - with tempfile.NamedTemporaryFile(delete=False, suffix=suffix, dir=tempdir) as tmp: - tmp.write(data) + with tempfile.NamedTemporaryFile( + delete=False, suffix=Path(file_name).suffix if file_name else ".bin", dir=tempdir + ) as tmp: + tmp.write(base64.b64decode(file_in_base64)) path = Path(tmp.name) - return path @@ -213,35 +165,22 @@ async def save_url_to_tempfile(url: str, tempdir: Path | None = None) -> Path: if url.startswith("data:image/"): metadata_part = url.split(",")[0] mime_type = metadata_part.split(":")[1].split(";")[0] - - base64_data = url.split(",")[1] - data = base64.b64decode(base64_data) - - suffix = mimetypes.guess_extension(mime_type) - if not suffix: - suffix = f".{mime_type.split('/')[1]}" + data = base64.b64decode(url.split(",")[1]) + suffix = mimetypes.guess_extension(mime_type) or f".{mime_type.split('/')[1]}" else: async with httpx.AsyncClient(follow_redirects=True) as client: resp = await client.get(url) resp.raise_for_status() data = resp.content content_type = resp.headers.get("content-type") - if content_type: - mime_type = content_type.split(";")[0].strip() - suffix = mimetypes.guess_extension(mime_type) - + suffix = mimetypes.guess_extension(content_type.split(";")[0].strip()) if not suffix: - path_url = urlparse(url).path - suffix = Path(path_url).suffix - - if not suffix: - suffix = ".bin" + suffix = Path(urlparse(url).path).suffix or ".bin" with tempfile.NamedTemporaryFile(delete=False, suffix=suffix, dir=tempdir) as tmp: tmp.write(data) path = Path(tmp.name) - return path @@ -249,7 +188,6 @@ def strip_tagged_blocks(text: str) -> str: """ Remove ChatML role blocks (<|im_start|>role...<|im_end|>). Role 'tool' blocks are removed entirely; others have markers stripped but content preserved. - Handles both raw and escaped markers consistently. """ if not text: return text @@ -274,7 +212,6 @@ def strip_tagged_blocks(text: str) -> str: if role != "tool": result.append(text[content_start : match_end.start()]) - idx = match_end.end() return "".join(result) @@ -297,7 +234,6 @@ def strip_system_hints(text: str) -> str: cleaned = re.sub(rf"\s*{re.escape(TOOL_HINT_LINE_END)}\.?\n?", "", cleaned) cleaned = strip_tagged_blocks(cleaned) - cleaned = CONTROL_TOKEN_RE.sub("", cleaned) cleaned = TOOL_BLOCK_RE.sub("", cleaned) cleaned = TOOL_CALL_RE.sub("", cleaned) @@ -331,7 +267,10 @@ def _create_tool_call(name: str, raw_args: str) -> None: arg_matches = TAGGED_ARG_RE.findall(raw_args) if arg_matches: - args_dict = {arg_name.strip(): arg_value.strip() for arg_name, arg_value in arg_matches} + args_dict = { + arg_name.strip(): _strip_param_fences(arg_value) + for arg_name, arg_value in arg_matches + } arguments = orjson.dumps(args_dict).decode("utf-8") logger.debug(f"Successfully parsed {len(args_dict)} arguments for tool: {name}") else: @@ -360,7 +299,6 @@ def _create_tool_call(name: str, raw_args: str) -> None: _create_tool_call(match.group(1), match.group(2)) cleaned = strip_system_hints(text) - return cleaned, tool_calls @@ -406,21 +344,7 @@ def extract_image_dimensions(data: bytes) -> tuple[int | None, int | None]: if len(data) >= 4 and data[0:2] == b"\xff\xd8": idx = 2 length = len(data) - sof_markers = { - 0xC0, - 0xC1, - 0xC2, - 0xC3, - 0xC5, - 0xC6, - 0xC7, - 0xC9, - 0xCA, - 0xCB, - 0xCD, - 0xCE, - 0xCF, - } + sof_markers = {0xC0, 0xC1, 0xC2, 0xC3, 0xC5, 0xC6, 0xC7, 0xC9, 0xCA, 0xCB, 0xCD, 0xCE, 0xCF} while idx < length: if data[idx] != 0xFF: idx += 1 @@ -431,26 +355,21 @@ def extract_image_dimensions(data: bytes) -> tuple[int | None, int | None]: break marker = data[idx] idx += 1 - if marker in (0xD8, 0xD9, 0x01) or 0xD0 <= marker <= 0xD7: continue - if idx + 1 >= length: break segment_length = (data[idx] << 8) + data[idx + 1] idx += 2 if segment_length < 2: break - if marker in sof_markers: if idx + 4 < length: height = (data[idx + 1] << 8) + data[idx + 2] width = (data[idx + 3] << 8) + data[idx + 4] return int(width), int(height) break - idx += segment_length - 2 - return None, None From 4ecad566edbe7cb1c37ad50ecc6da034c88e209a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C6=B0u=20Quang=20V=C5=A9?= Date: Fri, 13 Feb 2026 16:18:28 +0700 Subject: [PATCH 114/291] Refactor: Update `unescape_llm_text` to standardize and repair LLM-generated text fragments. - These patches are specifically designed for complex clients like Roo Code to ensure compatibility with their specialized tool protocols. --- app/utils/helper.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/app/utils/helper.py b/app/utils/helper.py index 28260c3..99af0a5 100644 --- a/app/utils/helper.py +++ b/app/utils/helper.py @@ -18,13 +18,15 @@ VALID_TAG_ROLES = {"user", "assistant", "system", "tool"} TOOL_WRAP_HINT = ( - "\nWhen calling tools, use this EXACT protocol:\n" + "When calling tools, you MUST respond ONLY with a single [ToolCalls] block using this EXACT syntax:\n" "[ToolCalls]\n" "[Call:tool_name]\n" - "[CallParameter:arg_name]value[/CallParameter]\n" + "[CallParameter:arg_name]\n" + "value\n" + "[/CallParameter]\n" "[/Call]\n" "[/ToolCalls]\n" - "CRITICAL: Wrap ALL multi-line or complex values in a markdown code block (e.g., [CallParameter:arg_name]```\nvalue\n```[/CallParameter]) to prevent rendering corruption.\n" + "CRITICAL: If 'value' is multi-line or complex, you MUST wrap it in a markdown code block within the tags (use a fence longer than any backtick sequence in the content) to prevent rendering corruption.\n" ) TOOL_BLOCK_RE = re.compile( r"\\?\[ToolCalls\\?]\s*(.*?)\s*\\?\[/ToolCalls\\?]", re.DOTALL | re.IGNORECASE From 28378b4125e8900284814040d86f3139f2ddd644 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C6=B0u=20Quang=20V=C5=A9?= Date: Fri, 13 Feb 2026 16:19:54 +0700 Subject: [PATCH 115/291] Update dependencies --- pyproject.toml | 4 ++-- uv.lock | 48 ++++++++++++++++++++++++------------------------ 2 files changed, 26 insertions(+), 26 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index d3a1aaf..93dabab 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,7 +5,7 @@ description = "FastAPI Server built on Gemini Web API" readme = "README.md" requires-python = "==3.12.*" dependencies = [ - "fastapi>=0.128.7", + "fastapi>=0.129.0", "gemini-webapi>=1.19.1", "lmdb>=1.7.5", "loguru>=0.7.3", @@ -31,5 +31,5 @@ indent-style = "space" [dependency-groups] dev = [ - "ruff>=0.15.0", + "ruff>=0.15.1", ] diff --git a/uv.lock b/uv.lock index c038f53..249e84b 100644 --- a/uv.lock +++ b/uv.lock @@ -65,7 +65,7 @@ wheels = [ [[package]] name = "fastapi" -version = "0.128.7" +version = "0.129.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "annotated-doc" }, @@ -74,9 +74,9 @@ dependencies = [ { name = "typing-extensions" }, { name = "typing-inspection" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/a0/fc/af386750b3fd8d8828167e4c82b787a8eeca2eca5c5429c9db8bb7c70e04/fastapi-0.128.7.tar.gz", hash = "sha256:783c273416995486c155ad2c0e2b45905dedfaf20b9ef8d9f6a9124670639a24", size = 375325, upload-time = "2026-02-10T12:26:40.968Z" } +sdist = { url = "https://files.pythonhosted.org/packages/48/47/75f6bea02e797abff1bca968d5997793898032d9923c1935ae2efdece642/fastapi-0.129.0.tar.gz", hash = "sha256:61315cebd2e65df5f97ec298c888f9de30430dd0612d59d6480beafbc10655af", size = 375450, upload-time = "2026-02-12T13:54:52.541Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/af/1a/f983b45661c79c31be575c570d46c437a5409b67a939c1b3d8d6b3ed7a7f/fastapi-0.128.7-py3-none-any.whl", hash = "sha256:6bd9bd31cb7047465f2d3fa3ba3f33b0870b17d4eaf7cdb36d1576ab060ad662", size = 103630, upload-time = "2026-02-10T12:26:39.414Z" }, + { url = "https://files.pythonhosted.org/packages/9e/dd/d0ee25348ac58245ee9f90b6f3cbb666bf01f69be7e0911f9851bddbda16/fastapi-0.129.0-py3-none-any.whl", hash = "sha256:b4946880e48f462692b31c083be0432275cbfb6e2274566b1be91479cc1a84ec", size = 102950, upload-time = "2026-02-12T13:54:54.528Z" }, ] [[package]] @@ -106,7 +106,7 @@ dev = [ [package.metadata] requires-dist = [ - { name = "fastapi", specifier = ">=0.128.7" }, + { name = "fastapi", specifier = ">=0.129.0" }, { name = "gemini-webapi", specifier = ">=1.19.1" }, { name = "lmdb", specifier = ">=1.7.5" }, { name = "loguru", specifier = ">=0.7.3" }, @@ -119,7 +119,7 @@ requires-dist = [ provides-extras = ["dev"] [package.metadata.requires-dev] -dev = [{ name = "ruff", specifier = ">=0.15.0" }] +dev = [{ name = "ruff", specifier = ">=0.15.1" }] [[package]] name = "gemini-webapi" @@ -361,27 +361,27 @@ wheels = [ [[package]] name = "ruff" -version = "0.15.0" +version = "0.15.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/c8/39/5cee96809fbca590abea6b46c6d1c586b49663d1d2830a751cc8fc42c666/ruff-0.15.0.tar.gz", hash = "sha256:6bdea47cdbea30d40f8f8d7d69c0854ba7c15420ec75a26f463290949d7f7e9a", size = 4524893, upload-time = "2026-02-03T17:53:35.357Z" } +sdist = { url = "https://files.pythonhosted.org/packages/04/dc/4e6ac71b511b141cf626357a3946679abeba4cf67bc7cc5a17920f31e10d/ruff-0.15.1.tar.gz", hash = "sha256:c590fe13fb57c97141ae975c03a1aedb3d3156030cabd740d6ff0b0d601e203f", size = 4540855, upload-time = "2026-02-12T23:09:09.998Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/bc/88/3fd1b0aa4b6330d6aaa63a285bc96c9f71970351579152d231ed90914586/ruff-0.15.0-py3-none-linux_armv6l.whl", hash = "sha256:aac4ebaa612a82b23d45964586f24ae9bc23ca101919f5590bdb368d74ad5455", size = 10354332, upload-time = "2026-02-03T17:52:54.892Z" }, - { url = "https://files.pythonhosted.org/packages/72/f6/62e173fbb7eb75cc29fe2576a1e20f0a46f671a2587b5f604bfb0eaf5f6f/ruff-0.15.0-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:dcd4be7cc75cfbbca24a98d04d0b9b36a270d0833241f776b788d59f4142b14d", size = 10767189, upload-time = "2026-02-03T17:53:19.778Z" }, - { url = "https://files.pythonhosted.org/packages/99/e4/968ae17b676d1d2ff101d56dc69cf333e3a4c985e1ec23803df84fc7bf9e/ruff-0.15.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:d747e3319b2bce179c7c1eaad3d884dc0a199b5f4d5187620530adf9105268ce", size = 10075384, upload-time = "2026-02-03T17:53:29.241Z" }, - { url = "https://files.pythonhosted.org/packages/a2/bf/9843c6044ab9e20af879c751487e61333ca79a2c8c3058b15722386b8cae/ruff-0.15.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:650bd9c56ae03102c51a5e4b554d74d825ff3abe4db22b90fd32d816c2e90621", size = 10481363, upload-time = "2026-02-03T17:52:43.332Z" }, - { url = "https://files.pythonhosted.org/packages/55/d9/4ada5ccf4cd1f532db1c8d44b6f664f2208d3d93acbeec18f82315e15193/ruff-0.15.0-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a6664b7eac559e3048223a2da77769c2f92b43a6dfd4720cef42654299a599c9", size = 10187736, upload-time = "2026-02-03T17:53:00.522Z" }, - { url = "https://files.pythonhosted.org/packages/86/e2/f25eaecd446af7bb132af0a1d5b135a62971a41f5366ff41d06d25e77a91/ruff-0.15.0-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6f811f97b0f092b35320d1556f3353bf238763420ade5d9e62ebd2b73f2ff179", size = 10968415, upload-time = "2026-02-03T17:53:15.705Z" }, - { url = "https://files.pythonhosted.org/packages/e7/dc/f06a8558d06333bf79b497d29a50c3a673d9251214e0d7ec78f90b30aa79/ruff-0.15.0-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:761ec0a66680fab6454236635a39abaf14198818c8cdf691e036f4bc0f406b2d", size = 11809643, upload-time = "2026-02-03T17:53:23.031Z" }, - { url = "https://files.pythonhosted.org/packages/dd/45/0ece8db2c474ad7df13af3a6d50f76e22a09d078af63078f005057ca59eb/ruff-0.15.0-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:940f11c2604d317e797b289f4f9f3fa5555ffe4fb574b55ed006c3d9b6f0eb78", size = 11234787, upload-time = "2026-02-03T17:52:46.432Z" }, - { url = "https://files.pythonhosted.org/packages/8a/d9/0e3a81467a120fd265658d127db648e4d3acfe3e4f6f5d4ea79fac47e587/ruff-0.15.0-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bcbca3d40558789126da91d7ef9a7c87772ee107033db7191edefa34e2c7f1b4", size = 11112797, upload-time = "2026-02-03T17:52:49.274Z" }, - { url = "https://files.pythonhosted.org/packages/b2/cb/8c0b3b0c692683f8ff31351dfb6241047fa873a4481a76df4335a8bff716/ruff-0.15.0-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:9a121a96db1d75fa3eb39c4539e607f628920dd72ff1f7c5ee4f1b768ac62d6e", size = 11033133, upload-time = "2026-02-03T17:53:33.105Z" }, - { url = "https://files.pythonhosted.org/packages/f8/5e/23b87370cf0f9081a8c89a753e69a4e8778805b8802ccfe175cc410e50b9/ruff-0.15.0-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:5298d518e493061f2eabd4abd067c7e4fb89e2f63291c94332e35631c07c3662", size = 10442646, upload-time = "2026-02-03T17:53:06.278Z" }, - { url = "https://files.pythonhosted.org/packages/e1/9a/3c94de5ce642830167e6d00b5c75aacd73e6347b4c7fc6828699b150a5ee/ruff-0.15.0-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:afb6e603d6375ff0d6b0cee563fa21ab570fd15e65c852cb24922cef25050cf1", size = 10195750, upload-time = "2026-02-03T17:53:26.084Z" }, - { url = "https://files.pythonhosted.org/packages/30/15/e396325080d600b436acc970848d69df9c13977942fb62bb8722d729bee8/ruff-0.15.0-py3-none-musllinux_1_2_i686.whl", hash = "sha256:77e515f6b15f828b94dc17d2b4ace334c9ddb7d9468c54b2f9ed2b9c1593ef16", size = 10676120, upload-time = "2026-02-03T17:53:09.363Z" }, - { url = "https://files.pythonhosted.org/packages/8d/c9/229a23d52a2983de1ad0fb0ee37d36e0257e6f28bfd6b498ee2c76361874/ruff-0.15.0-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:6f6e80850a01eb13b3e42ee0ebdf6e4497151b48c35051aab51c101266d187a3", size = 11201636, upload-time = "2026-02-03T17:52:57.281Z" }, - { url = "https://files.pythonhosted.org/packages/6f/b0/69adf22f4e24f3677208adb715c578266842e6e6a3cc77483f48dd999ede/ruff-0.15.0-py3-none-win32.whl", hash = "sha256:238a717ef803e501b6d51e0bdd0d2c6e8513fe9eec14002445134d3907cd46c3", size = 10465945, upload-time = "2026-02-03T17:53:12.591Z" }, - { url = "https://files.pythonhosted.org/packages/51/ad/f813b6e2c97e9b4598be25e94a9147b9af7e60523b0cb5d94d307c15229d/ruff-0.15.0-py3-none-win_amd64.whl", hash = "sha256:dd5e4d3301dc01de614da3cdffc33d4b1b96fb89e45721f1598e5532ccf78b18", size = 11564657, upload-time = "2026-02-03T17:52:51.893Z" }, - { url = "https://files.pythonhosted.org/packages/f6/b0/2d823f6e77ebe560f4e397d078487e8d52c1516b331e3521bc75db4272ca/ruff-0.15.0-py3-none-win_arm64.whl", hash = "sha256:c480d632cc0ca3f0727acac8b7d053542d9e114a462a145d0b00e7cd658c515a", size = 10865753, upload-time = "2026-02-03T17:53:03.014Z" }, + { url = "https://files.pythonhosted.org/packages/23/bf/e6e4324238c17f9d9120a9d60aa99a7daaa21204c07fcd84e2ef03bb5fd1/ruff-0.15.1-py3-none-linux_armv6l.whl", hash = "sha256:b101ed7cf4615bda6ffe65bdb59f964e9f4a0d3f85cbf0e54f0ab76d7b90228a", size = 10367819, upload-time = "2026-02-12T23:09:03.598Z" }, + { url = "https://files.pythonhosted.org/packages/b3/ea/c8f89d32e7912269d38c58f3649e453ac32c528f93bb7f4219258be2e7ed/ruff-0.15.1-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:939c995e9277e63ea632cc8d3fae17aa758526f49a9a850d2e7e758bfef46602", size = 10798618, upload-time = "2026-02-12T23:09:22.928Z" }, + { url = "https://files.pythonhosted.org/packages/5e/0f/1d0d88bc862624247d82c20c10d4c0f6bb2f346559d8af281674cf327f15/ruff-0.15.1-py3-none-macosx_11_0_arm64.whl", hash = "sha256:1d83466455fdefe60b8d9c8df81d3c1bbb2115cede53549d3b522ce2bc703899", size = 10148518, upload-time = "2026-02-12T23:08:58.339Z" }, + { url = "https://files.pythonhosted.org/packages/f5/c8/291c49cefaa4a9248e986256df2ade7add79388fe179e0691be06fae6f37/ruff-0.15.1-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a9457e3c3291024866222b96108ab2d8265b477e5b1534c7ddb1810904858d16", size = 10518811, upload-time = "2026-02-12T23:09:31.865Z" }, + { url = "https://files.pythonhosted.org/packages/c3/1a/f5707440e5ae43ffa5365cac8bbb91e9665f4a883f560893829cf16a606b/ruff-0.15.1-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:92c92b003e9d4f7fbd33b1867bb15a1b785b1735069108dfc23821ba045b29bc", size = 10196169, upload-time = "2026-02-12T23:09:17.306Z" }, + { url = "https://files.pythonhosted.org/packages/2a/ff/26ddc8c4da04c8fd3ee65a89c9fb99eaa5c30394269d424461467be2271f/ruff-0.15.1-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1fe5c41ab43e3a06778844c586251eb5a510f67125427625f9eb2b9526535779", size = 10990491, upload-time = "2026-02-12T23:09:25.503Z" }, + { url = "https://files.pythonhosted.org/packages/fc/00/50920cb385b89413f7cdb4bb9bc8fc59c1b0f30028d8bccc294189a54955/ruff-0.15.1-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:66a6dd6df4d80dc382c6484f8ce1bcceb55c32e9f27a8b94c32f6c7331bf14fb", size = 11843280, upload-time = "2026-02-12T23:09:19.88Z" }, + { url = "https://files.pythonhosted.org/packages/5d/6d/2f5cad8380caf5632a15460c323ae326f1e1a2b5b90a6ee7519017a017ca/ruff-0.15.1-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6a4a42cbb8af0bda9bcd7606b064d7c0bc311a88d141d02f78920be6acb5aa83", size = 11274336, upload-time = "2026-02-12T23:09:14.907Z" }, + { url = "https://files.pythonhosted.org/packages/a3/1d/5f56cae1d6c40b8a318513599b35ea4b075d7dc1cd1d04449578c29d1d75/ruff-0.15.1-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4ab064052c31dddada35079901592dfba2e05f5b1e43af3954aafcbc1096a5b2", size = 11137288, upload-time = "2026-02-12T23:09:07.475Z" }, + { url = "https://files.pythonhosted.org/packages/cd/20/6f8d7d8f768c93b0382b33b9306b3b999918816da46537d5a61635514635/ruff-0.15.1-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:5631c940fe9fe91f817a4c2ea4e81f47bee3ca4aa646134a24374f3c19ad9454", size = 11070681, upload-time = "2026-02-12T23:08:55.43Z" }, + { url = "https://files.pythonhosted.org/packages/9a/67/d640ac76069f64cdea59dba02af2e00b1fa30e2103c7f8d049c0cff4cafd/ruff-0.15.1-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:68138a4ba184b4691ccdc39f7795c66b3c68160c586519e7e8444cf5a53e1b4c", size = 10486401, upload-time = "2026-02-12T23:09:27.927Z" }, + { url = "https://files.pythonhosted.org/packages/65/3d/e1429f64a3ff89297497916b88c32a5cc88eeca7e9c787072d0e7f1d3e1e/ruff-0.15.1-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:518f9af03bfc33c03bdb4cb63fabc935341bb7f54af500f92ac309ecfbba6330", size = 10197452, upload-time = "2026-02-12T23:09:12.147Z" }, + { url = "https://files.pythonhosted.org/packages/78/83/e2c3bade17dad63bf1e1c2ffaf11490603b760be149e1419b07049b36ef2/ruff-0.15.1-py3-none-musllinux_1_2_i686.whl", hash = "sha256:da79f4d6a826caaea95de0237a67e33b81e6ec2e25fc7e1993a4015dffca7c61", size = 10693900, upload-time = "2026-02-12T23:09:34.418Z" }, + { url = "https://files.pythonhosted.org/packages/a1/27/fdc0e11a813e6338e0706e8b39bb7a1d61ea5b36873b351acee7e524a72a/ruff-0.15.1-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:3dd86dccb83cd7d4dcfac303ffc277e6048600dfc22e38158afa208e8bf94a1f", size = 11227302, upload-time = "2026-02-12T23:09:36.536Z" }, + { url = "https://files.pythonhosted.org/packages/f6/58/ac864a75067dcbd3b95be5ab4eb2b601d7fbc3d3d736a27e391a4f92a5c1/ruff-0.15.1-py3-none-win32.whl", hash = "sha256:660975d9cb49b5d5278b12b03bb9951d554543a90b74ed5d366b20e2c57c2098", size = 10462555, upload-time = "2026-02-12T23:09:29.899Z" }, + { url = "https://files.pythonhosted.org/packages/e0/5e/d4ccc8a27ecdb78116feac4935dfc39d1304536f4296168f91ed3ec00cd2/ruff-0.15.1-py3-none-win_amd64.whl", hash = "sha256:c820fef9dd5d4172a6570e5721704a96c6679b80cf7be41659ed439653f62336", size = 11599956, upload-time = "2026-02-12T23:09:01.157Z" }, + { url = "https://files.pythonhosted.org/packages/2a/07/5bda6a85b220c64c65686bc85bd0bbb23b29c62b3a9f9433fa55f17cda93/ruff-0.15.1-py3-none-win_arm64.whl", hash = "sha256:5ff7d5f0f88567850f45081fac8f4ec212be8d0b963e385c3f7d0d2eb4899416", size = 10874604, upload-time = "2026-02-12T23:09:05.515Z" }, ] [[package]] From 3fcd01ead021e9536f1cbdaf4c4f0c2b2a0047e9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C6=B0u=20Quang=20V=C5=A9?= Date: Fri, 13 Feb 2026 17:28:44 +0700 Subject: [PATCH 116/291] Refactor: Rewrite the function call format to match the client's complex argument structure - These patches are specifically designed for complex clients like Roo Code to ensure compatibility with their specialized tool protocols. --- app/server/chat.py | 9 --------- app/utils/helper.py | 10 +++++++--- 2 files changed, 7 insertions(+), 12 deletions(-) diff --git a/app/server/chat.py b/app/server/chat.py index 3f8e0cd..114eedf 100644 --- a/app/server/chat.py +++ b/app/server/chat.py @@ -379,15 +379,6 @@ def _build_tool_prompt( ) lines.append(TOOL_WRAP_HINT.strip()) - lines.append( - "If multiple tools are needed, list them sequentially within the same [ToolCalls] block." - ) - lines.append( - "If no tool call is needed, provide a normal response and NEVER use the [ToolCalls] tag." - ) - lines.append( - "Note: Tool results are returned in a [ToolResults] block using [ToolResult] tags." - ) return "\n".join(lines) diff --git a/app/utils/helper.py b/app/utils/helper.py index 99af0a5..68066a3 100644 --- a/app/utils/helper.py +++ b/app/utils/helper.py @@ -18,15 +18,19 @@ VALID_TAG_ROLES = {"user", "assistant", "system", "tool"} TOOL_WRAP_HINT = ( - "When calling tools, you MUST respond ONLY with a single [ToolCalls] block using this EXACT syntax:\n" + "When calling tools, you MUST respond ONLY with a single [ToolCalls] block using this EXACT syntax:\n\n" "[ToolCalls]\n" "[Call:tool_name]\n" "[CallParameter:arg_name]\n" "value\n" "[/CallParameter]\n" "[/Call]\n" - "[/ToolCalls]\n" - "CRITICAL: If 'value' is multi-line or complex, you MUST wrap it in a markdown code block within the tags (use a fence longer than any backtick sequence in the content) to prevent rendering corruption.\n" + "[/ToolCalls]\n\n" + "CRITICAL: If 'value' contains ANY newlines or special characters, you MUST wrap it in a markdown code block (triple backticks or longer) within the tags. " + "Failure to wrap multi-line content will result in a protocol rejection. Use a fence longer than any backtick sequence in the content.\n\n" + "Multiple tool calls MUST be listed sequentially within the same [ToolCalls] block.\n" + "If no tool is needed, respond naturally and NEVER use any [ToolCalls] or [Call] tags.\n" + "Note: Tool results are returned in [ToolResults] blocks." ) TOOL_BLOCK_RE = re.compile( r"\\?\[ToolCalls\\?]\s*(.*?)\s*\\?\[/ToolCalls\\?]", re.DOTALL | re.IGNORECASE From ef24704b3d302f76233999002717a44d4a88c444 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C6=B0u=20Quang=20V=C5=A9?= Date: Fri, 13 Feb 2026 17:42:59 +0700 Subject: [PATCH 117/291] Refactor: Rewrite the function call format to match the client's complex argument structure - These patches are specifically designed for complex clients like Roo Code to ensure compatibility with their specialized tool protocols. --- app/utils/helper.py | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/app/utils/helper.py b/app/utils/helper.py index 68066a3..a2b4c23 100644 --- a/app/utils/helper.py +++ b/app/utils/helper.py @@ -18,7 +18,7 @@ VALID_TAG_ROLES = {"user", "assistant", "system", "tool"} TOOL_WRAP_HINT = ( - "When calling tools, you MUST respond ONLY with a single [ToolCalls] block using this EXACT syntax:\n\n" + "When calling tools, respond ONLY with a single [ToolCalls] block. NO other text allowed. EXACT syntax:\n\n" "[ToolCalls]\n" "[Call:tool_name]\n" "[CallParameter:arg_name]\n" @@ -26,11 +26,9 @@ "[/CallParameter]\n" "[/Call]\n" "[/ToolCalls]\n\n" - "CRITICAL: If 'value' contains ANY newlines or special characters, you MUST wrap it in a markdown code block (triple backticks or longer) within the tags. " - "Failure to wrap multi-line content will result in a protocol rejection. Use a fence longer than any backtick sequence in the content.\n\n" - "Multiple tool calls MUST be listed sequentially within the same [ToolCalls] block.\n" - "If no tool is needed, respond naturally and NEVER use any [ToolCalls] or [Call] tags.\n" - "Note: Tool results are returned in [ToolResults] blocks." + "CRITICAL: If 'value' has ANY newline, you MUST wrap it in a code block (```). " + "The tags MUST contain ONLY the code block, no other text. Use a fence longer than any backticks in content.\n" + "Multiple calls: list [Call] blocks inside [ToolCalls]. No tools: respond naturally, NO tags." ) TOOL_BLOCK_RE = re.compile( r"\\?\[ToolCalls\\?]\s*(.*?)\s*\\?\[/ToolCalls\\?]", re.DOTALL | re.IGNORECASE From 4edf4cdedc6c093a426da1554948fed074705edd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C6=B0u=20Quang=20V=C5=A9?= Date: Fri, 13 Feb 2026 18:08:52 +0700 Subject: [PATCH 118/291] Refactor: Rewrite the function call format to match the client's complex argument structure - These patches are specifically designed for complex clients like Roo Code to ensure compatibility with their specialized tool protocols. --- app/utils/helper.py | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/app/utils/helper.py b/app/utils/helper.py index a2b4c23..0efa460 100644 --- a/app/utils/helper.py +++ b/app/utils/helper.py @@ -18,17 +18,21 @@ VALID_TAG_ROLES = {"user", "assistant", "system", "tool"} TOOL_WRAP_HINT = ( - "When calling tools, respond ONLY with a single [ToolCalls] block. NO other text allowed. EXACT syntax:\n\n" + "SYSTEM INTERFACE: Tool calling protocol. You MUST follow these MANDATORY rules:\n\n" + "1. Respond ONLY with a single [ToolCalls] block. NO conversational text, NO explanations, NO filler.\n" + "2. For ALL parameters, the value MUST be wrapped in a markdown code block inside the tags to prevent rendering corruption.\n" + "3. Use a markdown fence (backticks) longer than any backtick sequence in the content (e.g., use ```` if content has ```).\n\n" + "EXACT SYNTAX TEMPLATE:\n" "[ToolCalls]\n" "[Call:tool_name]\n" "[CallParameter:arg_name]\n" + "```\n" "value\n" + "```\n" "[/CallParameter]\n" "[/Call]\n" "[/ToolCalls]\n\n" - "CRITICAL: If 'value' has ANY newline, you MUST wrap it in a code block (```). " - "The tags MUST contain ONLY the code block, no other text. Use a fence longer than any backticks in content.\n" - "Multiple calls: list [Call] blocks inside [ToolCalls]. No tools: respond naturally, NO tags." + "Multiple tools: List them sequentially inside one [ToolCalls] block. No tool: respond naturally, NEVER use protocol tags." ) TOOL_BLOCK_RE = re.compile( r"\\?\[ToolCalls\\?]\s*(.*?)\s*\\?\[/ToolCalls\\?]", re.DOTALL | re.IGNORECASE From dcadabb8341a4e94cc9a44a6c9e9350e70365a7e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C6=B0u=20Quang=20V=C5=A9?= Date: Fri, 13 Feb 2026 18:23:20 +0700 Subject: [PATCH 119/291] Refactor: Rewrite the function call format to match the client's complex argument structure - These patches are specifically designed for complex clients like Roo Code to ensure compatibility with their specialized tool protocols. --- app/server/chat.py | 2 +- app/utils/helper.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/app/server/chat.py b/app/server/chat.py index 114eedf..881f7c9 100644 --- a/app/server/chat.py +++ b/app/server/chat.py @@ -378,7 +378,7 @@ def _build_tool_prompt( f"You are required to call the tool named `{target}`. Do not call any other tool." ) - lines.append(TOOL_WRAP_HINT.strip()) + lines.append(TOOL_WRAP_HINT) return "\n".join(lines) diff --git a/app/utils/helper.py b/app/utils/helper.py index 0efa460..1e70c3b 100644 --- a/app/utils/helper.py +++ b/app/utils/helper.py @@ -18,7 +18,7 @@ VALID_TAG_ROLES = {"user", "assistant", "system", "tool"} TOOL_WRAP_HINT = ( - "SYSTEM INTERFACE: Tool calling protocol. You MUST follow these MANDATORY rules:\n\n" + "\n\nSYSTEM INTERFACE: Tool calling protocol. You MUST follow these MANDATORY rules:\n\n" "1. Respond ONLY with a single [ToolCalls] block. NO conversational text, NO explanations, NO filler.\n" "2. For ALL parameters, the value MUST be wrapped in a markdown code block inside the tags to prevent rendering corruption.\n" "3. Use a markdown fence (backticks) longer than any backtick sequence in the content (e.g., use ```` if content has ```).\n\n" @@ -32,7 +32,7 @@ "[/CallParameter]\n" "[/Call]\n" "[/ToolCalls]\n\n" - "Multiple tools: List them sequentially inside one [ToolCalls] block. No tool: respond naturally, NEVER use protocol tags." + "Multiple tools: List them sequentially inside one [ToolCalls] block. No tool: respond naturally, NEVER use protocol tags.\n" ) TOOL_BLOCK_RE = re.compile( r"\\?\[ToolCalls\\?]\s*(.*?)\s*\\?\[/ToolCalls\\?]", re.DOTALL | re.IGNORECASE From 83904142bb97676f09d93725ee5d4ad19f5d7cd1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C6=B0u=20Quang=20V=C5=A9?= Date: Fri, 13 Feb 2026 19:56:04 +0700 Subject: [PATCH 120/291] Refactor: Rewrite the function call format to match the client's complex argument structure - These patches are specifically designed for complex clients like Roo Code to ensure compatibility with their specialized tool protocols. --- app/utils/helper.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/app/utils/helper.py b/app/utils/helper.py index 1e70c3b..ae96f05 100644 --- a/app/utils/helper.py +++ b/app/utils/helper.py @@ -20,8 +20,8 @@ TOOL_WRAP_HINT = ( "\n\nSYSTEM INTERFACE: Tool calling protocol. You MUST follow these MANDATORY rules:\n\n" "1. Respond ONLY with a single [ToolCalls] block. NO conversational text, NO explanations, NO filler.\n" - "2. For ALL parameters, the value MUST be wrapped in a markdown code block inside the tags to prevent rendering corruption.\n" - "3. Use a markdown fence (backticks) longer than any backtick sequence in the content (e.g., use ```` if content has ```).\n\n" + "2. For ALL parameters, the value MUST be entirely enclosed in a single markdown code block (start/end with backticks) inside the tags. NO text allowed outside this block.\n" + "3. Use a markdown fence longer than any backtick sequence in the value (e.g., use ```` if value has ```).\n\n" "EXACT SYNTAX TEMPLATE:\n" "[ToolCalls]\n" "[Call:tool_name]\n" @@ -32,6 +32,7 @@ "[/CallParameter]\n" "[/Call]\n" "[/ToolCalls]\n\n" + "CRITICAL: Every tag MUST be opened and closed accurately.\n\n" "Multiple tools: List them sequentially inside one [ToolCalls] block. No tool: respond naturally, NEVER use protocol tags.\n" ) TOOL_BLOCK_RE = re.compile( From ce43d63ce2b2b6e042fa122d96d405c683407ef2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C6=B0u=20Quang=20V=C5=A9?= Date: Fri, 13 Feb 2026 22:26:00 +0700 Subject: [PATCH 121/291] Refactor: Remove all escape logic handlers. - Change tool call tags to snake_case. --- app/server/chat.py | 61 +++++++++++++++---------------- app/services/client.py | 32 ++++++++--------- app/services/lmdb.py | 22 ++++++------ app/utils/helper.py | 81 ++++++++++++++++++++---------------------- app/utils/logging.py | 2 +- 5 files changed, 93 insertions(+), 105 deletions(-) diff --git a/app/server/chat.py b/app/server/chat.py index 881f7c9..98277d6 100644 --- a/app/server/chat.py +++ b/app/server/chat.py @@ -121,18 +121,18 @@ def _calculate_usage( ) -> tuple[int, int, int]: """Calculate prompt, completion and total tokens consistently.""" prompt_tokens = sum(estimate_tokens(text_from_message(msg)) for msg in messages) - tool_args_text = "" + tool_params_text = "" if tool_calls: for call in tool_calls: if hasattr(call, "function"): - tool_args_text += call.function.arguments or "" + tool_params_text += call.function.arguments or "" elif isinstance(call, dict): - tool_args_text += call.get("function", {}).get("arguments", "") + tool_params_text += call.get("function", {}).get("arguments", "") completion_basis = assistant_text or "" - if tool_args_text: + if tool_params_text: completion_basis = ( - f"{completion_basis}\n{tool_args_text}" if completion_basis else tool_args_text + f"{completion_basis}\n{tool_params_text}" if completion_basis else tool_params_text ) completion_tokens = estimate_tokens(completion_basis) @@ -343,7 +343,7 @@ def _build_tool_prompt( tools: list[Tool], tool_choice: str | ToolChoiceFunction | None, ) -> str: - """Generate a system prompt describing available tools and the PascalCase protocol.""" + """Generate a system prompt describing available tools and the snake_case protocol.""" if not tools: return "" @@ -359,10 +359,10 @@ def _build_tool_prompt( schema_text = orjson.dumps(function.parameters, option=orjson.OPT_SORT_KEYS).decode( "utf-8" ) - lines.append("Arguments JSON schema:") + lines.append("Parameters JSON schema:") lines.append(schema_text) else: - lines.append("Arguments JSON schema: {}") + lines.append("Parameters JSON schema: {}") if tool_choice == "none": lines.append( @@ -760,28 +760,28 @@ def __init__(self): self.STATE_MARKERS = { "TOOL": { - "starts": ["[ToolCalls]", "\\[ToolCalls\\]"], - "ends": ["[/ToolCalls]", "\\[/ToolCalls\\]"], + "starts": ["[tool_calls]"], + "ends": ["[/tool_calls]"], }, "ORPHAN": { - "starts": ["[Call:", "\\[Call:", "\\[Call\\:"], - "ends": ["[/Call]", "\\[/Call\\]"], + "starts": ["[call:"], + "ends": ["[/call]"], }, "RESP": { - "starts": ["[ToolResults]", "\\[ToolResults\\]"], - "ends": ["[/ToolResults]", "\\[/ToolResults\\]"], + "starts": ["[tool_results]"], + "ends": ["[/tool_results]"], }, - "ARG": { - "starts": ["[CallParameter:", "\\[CallParameter:", "\\[CallParameter\\:"], - "ends": ["[/CallParameter]", "\\[/CallParameter\\]"], + "PARAM": { + "starts": ["[call_parameter:"], + "ends": ["[/call_parameter]"], }, "RESULT": { - "starts": ["[ToolResult]", "\\[ToolResult\\]"], - "ends": ["[/ToolResult]", "\\[/ToolResult\\]"], + "starts": ["[tool_result]"], + "ends": ["[/tool_result]"], }, "TAG": { - "starts": ["<|im_start|>", "\\<|im\\_start|\\>"], - "ends": ["<|im_end|>", "\\<|im\\_end|\\>"], + "starts": ["<|im_start|>"], + "ends": ["<|im_end|>"], }, } @@ -794,15 +794,10 @@ def __init__(self): self.ORPHAN_ENDS = [ "<|im_end|>", - "\\<|im\\_end|\\>", - "[/Call]", - "\\[/Call\\]", - "[/ToolCalls]", - "\\[/ToolCalls\\]", - "[/CallParameter]", - "\\[/CallParameter\\]", - "[/ToolResult]", - "\\[/ToolResult\\]", + "[/call]", + "[/tool_calls]", + "[/call_parameter]", + "[/tool_result]", ] self.WATCH_MARKERS = [] @@ -876,8 +871,8 @@ def process(self, chunk: str) -> str: self.buffer = self.buffer[-max_end_len:] break - elif self.state == "IN_ARG": - cfg = self.STATE_MARKERS["ARG"] + elif self.state == "IN_PARAM": + cfg = self.STATE_MARKERS["PARAM"] found_idx, found_len = -1, 0 for p in cfg["ends"]: idx = buf_low.find(p.lower()) @@ -1003,7 +998,7 @@ def process(self, chunk: str) -> str: def flush(self) -> str: """Release remaining buffer content and perform final cleanup at stream end.""" res = "" - if self.state in ("IN_TOOL", "IN_ORPHAN", "IN_RESP", "IN_HINT", "IN_ARG", "IN_RESULT"): + if self.state in ("IN_TOOL", "IN_ORPHAN", "IN_RESP", "IN_HINT", "IN_PARAM", "IN_RESULT"): res = "" elif self.state == "IN_BLOCK" and self.current_role != "tool": res = self.buffer diff --git a/app/services/client.py b/app/services/client.py index 6ab80cd..1c826e5 100644 --- a/app/services/client.py +++ b/app/services/client.py @@ -70,8 +70,8 @@ async def process_message( message: Message, tempdir: Path | None = None, tagged: bool = True, wrap_tool: bool = True ) -> tuple[str, list[Path | str]]: """ - Process a Message into Gemini API format using the PascalCase technical protocol. - Extracts text, handles files, and appends ToolCalls/ToolResults blocks. + Process a Message into Gemini API format using the snake_case technical protocol. + Extracts text, handles files, and appends tool_calls/tool_results blocks. """ files: list[Path | str] = [] text_fragments: list[str] = [] @@ -109,34 +109,32 @@ async def process_message( if message.role == "tool": tool_name = message.name or "unknown" combined_content = "\n".join(text_fragments).strip() - res_block = ( - f"[Result:{tool_name}]\n[ToolResult]\n{combined_content}\n[/ToolResult]\n[/Result]" - ) + res_block = f"[result:{tool_name}]\n[tool_result]\n{combined_content}\n[/tool_result]\n[/result]" if wrap_tool: - text_fragments = [f"[ToolResults]\n{res_block}\n[/ToolResults]"] + text_fragments = [f"[tool_results]\n{res_block}\n[/tool_results]"] else: text_fragments = [res_block] if message.tool_calls: tool_blocks: list[str] = [] for call in message.tool_calls: - args_text = call.function.arguments.strip() - formatted_args = "@args\n" + params_text = call.function.arguments.strip() + formatted_params = "" try: - parsed_args = orjson.loads(args_text) - if isinstance(parsed_args, dict): - for k, v in parsed_args.items(): + parsed_params = orjson.loads(params_text) + if isinstance(parsed_params, dict): + for k, v in parsed_params.items(): val_str = v if isinstance(v, str) else orjson.dumps(v).decode("utf-8") - formatted_args += f"[CallParameter:{k}]{val_str}[/CallParameter]\n" + formatted_params += f"[call_parameter:{k}]{val_str}[/call_parameter]\n" else: - formatted_args += args_text + formatted_params += params_text except orjson.JSONDecodeError: - formatted_args += args_text + formatted_params += params_text - tool_blocks.append(f"[Call:{call.function.name}]\n{formatted_args}[/Call]") + tool_blocks.append(f"[call:{call.function.name}]\n{formatted_params}[/call]") if tool_blocks: - tool_section = "[ToolCalls]\n" + "\n".join(tool_blocks) + "\n[/ToolCalls]" + tool_section = "[tool_calls]\n" + "\n".join(tool_blocks) + "\n[/tool_calls]" text_fragments.append(tool_section) model_input = "\n".join(fragment for fragment in text_fragments if fragment is not None) @@ -168,7 +166,7 @@ async def process_conversation( i += 1 combined_tool_content = "\n".join(tool_blocks) - wrapped_content = f"[ToolResults]\n{combined_tool_content}\n[/ToolResults]" + wrapped_content = f"[tool_results]\n{combined_tool_content}\n[/tool_results]" conversation.append(add_tag("tool", wrapped_content)) else: input_part, files_part = await GeminiClientWrapper.process_message( diff --git a/app/services/lmdb.py b/app/services/lmdb.py index ad92bbf..2f59662 100644 --- a/app/services/lmdb.py +++ b/app/services/lmdb.py @@ -99,17 +99,17 @@ def _hash_message(message: Message, fuzzy: bool = False) -> str: if message.tool_calls: calls_data = [] for tc in message.tool_calls: - args = tc.function.arguments or "{}" + params = tc.function.arguments or "{}" try: - parsed = orjson.loads(args) - canon_args = orjson.dumps(parsed, option=orjson.OPT_SORT_KEYS).decode("utf-8") + parsed = orjson.loads(params) + canon_params = orjson.dumps(parsed, option=orjson.OPT_SORT_KEYS).decode("utf-8") except orjson.JSONDecodeError: - canon_args = args + canon_params = params calls_data.append( { "name": tc.function.name, - "arguments": canon_args, + "arguments": canon_params, } ) calls_data.sort(key=lambda x: (x["name"], x["arguments"])) @@ -149,7 +149,7 @@ def __init__( """ Initialize LMDB store. - Args: + Params: db_path: Path to LMDB database directory max_db_size: Maximum database size in bytes (default: 256 MB) retention_days: Number of days to retain conversations (default: 14, 0 disables cleanup) @@ -194,7 +194,7 @@ def _get_transaction(self, write: bool = False): """ Context manager for LMDB transactions. - Args: + Params: write: Whether the transaction should be writable. """ if not self._env: @@ -265,7 +265,7 @@ def store( """ Store a conversation model in LMDB. - Args: + Params: conv: Conversation model to store custom_key: Optional custom key, if not provided, hash will be used @@ -313,7 +313,7 @@ def get(self, key: str) -> Optional[ConversationInStore]: """ Retrieve conversation data by key. - Args: + Params: key: Storage key (hash or custom key) Returns: @@ -342,7 +342,7 @@ def find(self, model: str, messages: List[Message]) -> Optional[ConversationInSt Search conversation data by message list. Tries raw matching, then sanitized matching, and finally fuzzy matching. - Args: + Params: model: Model name messages: List of messages to match @@ -382,7 +382,7 @@ def _find_by_message_list( """ Internal find implementation based on a message list. - Args: + Params: model: Model name messages: Message list to hash fuzzy: Whether to use fuzzy hashing diff --git a/app/utils/helper.py b/app/utils/helper.py index ae96f05..752aed9 100644 --- a/app/utils/helper.py +++ b/app/utils/helper.py @@ -19,48 +19,45 @@ VALID_TAG_ROLES = {"user", "assistant", "system", "tool"} TOOL_WRAP_HINT = ( "\n\nSYSTEM INTERFACE: Tool calling protocol. You MUST follow these MANDATORY rules:\n\n" - "1. Respond ONLY with a single [ToolCalls] block. NO conversational text, NO explanations, NO filler.\n" + "1. Respond ONLY with a single [tool_calls] block. NO conversational text, NO explanations, NO filler.\n" "2. For ALL parameters, the value MUST be entirely enclosed in a single markdown code block (start/end with backticks) inside the tags. NO text allowed outside this block.\n" "3. Use a markdown fence longer than any backtick sequence in the value (e.g., use ```` if value has ```).\n\n" "EXACT SYNTAX TEMPLATE:\n" - "[ToolCalls]\n" - "[Call:tool_name]\n" - "[CallParameter:arg_name]\n" + "[tool_calls]\n" + "[call:tool_name]\n" + "[call_parameter:parameter_name]\n" "```\n" "value\n" "```\n" - "[/CallParameter]\n" - "[/Call]\n" - "[/ToolCalls]\n\n" + "[/call_parameter]\n" + "[/call]\n" + "[/tool_calls]\n\n" "CRITICAL: Every tag MUST be opened and closed accurately.\n\n" - "Multiple tools: List them sequentially inside one [ToolCalls] block. No tool: respond naturally, NEVER use protocol tags.\n" -) -TOOL_BLOCK_RE = re.compile( - r"\\?\[ToolCalls\\?]\s*(.*?)\s*\\?\[/ToolCalls\\?]", re.DOTALL | re.IGNORECASE + "Multiple tools: List them sequentially inside one [tool_calls] block. No tool: respond naturally, NEVER use protocol tags.\n" ) +TOOL_BLOCK_RE = re.compile(r"\[tool_calls]\s*(.*?)\s*\[/tool_calls]", re.DOTALL | re.IGNORECASE) TOOL_CALL_RE = re.compile( - r"\\?\[Call\\?:((?:[^]\\]|\\.)+)\\?]\s*(.*?)\s*\\?\[/Call\\?]", re.DOTALL | re.IGNORECASE + r"\[call:((?:[^]\\]|\\.)+)]\s*(.*?)\s*\[/call]", re.DOTALL | re.IGNORECASE ) RESPONSE_BLOCK_RE = re.compile( - r"\\?\[ToolResults\\?]\s*(.*?)\s*\\?\[/ToolResults\\?]", + r"\[tool_results]\s*(.*?)\s*\[/tool_results]", re.DOTALL | re.IGNORECASE, ) RESPONSE_ITEM_RE = re.compile( - r"\\?\[Result\\?:((?:[^]\\]|\\.)+)\\?]\s*(.*?)\s*\\?\[/Result\\?]", + r"\[result:((?:[^]\\]|\\.)+)]\s*(.*?)\s*\[/result]", re.DOTALL | re.IGNORECASE, ) -TAGGED_ARG_RE = re.compile( - r"\\?\[CallParameter\\?:((?:[^]\\]|\\.)+)\\?]\s*(.*?)\s*\\?\[/CallParameter\\?]", +TAGGED_PARAM_RE = re.compile( + r"\[call_parameter:((?:[^]\\]|\\.)+)]\s*(.*?)\s*\[/call_parameter]", re.DOTALL | re.IGNORECASE, ) TAGGED_RESULT_RE = re.compile( - r"\\?\[ToolResult\\?]\s*(.*?)\s*\\?\[/ToolResult\\?]", + r"\[tool_result]\s*(.*?)\s*\[/tool_result]", re.DOTALL | re.IGNORECASE, ) -CONTROL_TOKEN_RE = re.compile(r"\\?<\|im\\?_(?:start|end)\|\\?>", re.IGNORECASE) -CHATML_START_RE = re.compile(r"\\?<\|im\\?_start\|\\?>\s*(\w+)\s*\n?", re.IGNORECASE) -CHATML_END_RE = re.compile(r"\\?<\|im\\?_end\|\\?>", re.IGNORECASE) -COMMONMARK_UNESCAPE_RE = re.compile(r"\\([!\"#$%&'()*+,\-./:;<=>?@\[\\\]^_`{|}~])") +CONTROL_TOKEN_RE = re.compile(r"<\|im_(?:start|end)\|>", re.IGNORECASE) +CHATML_START_RE = re.compile(r"<\|im_start\|>\s*(\w+)\s*\n?", re.IGNORECASE) +CHATML_END_RE = re.compile(r"<\|im_end\|>", re.IGNORECASE) FILE_PATH_PATTERN = re.compile( r"^(?=.*[./\\]|.*:\d+|^(?:Dockerfile|Makefile|Jenkinsfile|Procfile|Rakefile|Gemfile|Vagrantfile|Caddyfile|Justfile|LICENSE|README|CONTRIBUTING|CODEOWNERS|AUTHORS|NOTICE|CHANGELOG)$)([a-zA-Z0-9_./\\-]+(?::\d+)?)$", re.IGNORECASE, @@ -134,15 +131,14 @@ def _strip_param_fences(s: str) -> str: return s[n:-n].strip() -def unescape_llm_text(s: str) -> str: +def _repair_param_value(s: str) -> str: """ - Standardize and repair LLM-generated text fragments (unescaping, link normalization) + Standardize and repair LLM-generated parameter values to ensure compatibility with specialized clients like Roo Code. """ if not s: return "" - s = COMMONMARK_UNESCAPE_RE.sub(r"\1", s) s = GOOGLE_SEARCH_PATTERN.sub(_strip_google_search, s) return s @@ -248,7 +244,7 @@ def strip_system_hints(text: str) -> str: cleaned = TOOL_CALL_RE.sub("", cleaned) cleaned = RESPONSE_BLOCK_RE.sub("", cleaned) cleaned = RESPONSE_ITEM_RE.sub("", cleaned) - cleaned = TAGGED_ARG_RE.sub("", cleaned) + cleaned = TAGGED_PARAM_RE.sub("", cleaned) cleaned = TAGGED_RESULT_RE.sub("", cleaned) return cleaned @@ -257,38 +253,37 @@ def strip_system_hints(text: str) -> str: def _process_tools_internal(text: str, extract: bool = True) -> tuple[str, list[ToolCall]]: """ Extract tool metadata and return text stripped of technical markers. - Arguments are parsed into JSON and assigned deterministic call IDs. + Parameters are parsed into JSON and assigned deterministic call IDs. """ if not text: return text, [] tool_calls: list[ToolCall] = [] - def _create_tool_call(name: str, raw_args: str) -> None: + def _create_tool_call(name: str, raw_params: str) -> None: if not extract: return + + name = name.strip() if not name: logger.warning("Encountered tool_call without a function name.") return - name = unescape_llm_text(name.strip()) - raw_args = unescape_llm_text(raw_args) - - arg_matches = TAGGED_ARG_RE.findall(raw_args) - if arg_matches: - args_dict = { - arg_name.strip(): _strip_param_fences(arg_value) - for arg_name, arg_value in arg_matches + param_matches = TAGGED_PARAM_RE.findall(raw_params) + if param_matches: + params_dict = { + param_name.strip(): _repair_param_value(_strip_param_fences(param_value)) + for param_name, param_value in param_matches } - arguments = orjson.dumps(args_dict).decode("utf-8") - logger.debug(f"Successfully parsed {len(args_dict)} arguments for tool: {name}") + arguments = orjson.dumps(params_dict).decode("utf-8") + logger.debug(f"Successfully parsed {len(params_dict)} parameters for tool: {name}") else: - cleaned_raw = raw_args.strip() + cleaned_raw = raw_params.strip() if not cleaned_raw: - logger.debug(f"Successfully parsed 0 arguments for tool: {name}") + logger.debug(f"Successfully parsed 0 parameters for tool: {name}") else: logger.warning( - f"Malformed arguments for tool '{name}'. Text found but no valid tags: {reprlib.repr(cleaned_raw)}" + f"Malformed parameters for tool '{name}'. Text found but no valid tags: {reprlib.repr(cleaned_raw)}" ) arguments = "{}" @@ -323,7 +318,7 @@ def extract_tool_calls(text: str) -> tuple[str, list[ToolCall]]: def text_from_message(message: Message) -> str: - """Concatenate text and tool arguments from a message for token estimation.""" + """Concatenate text and tool parameters from a message for token estimation.""" base_text = "" if isinstance(message.content, str): base_text = message.content @@ -335,8 +330,8 @@ def text_from_message(message: Message) -> str: base_text = "" if message.tool_calls: - tool_arg_text = "".join(call.function.arguments or "" for call in message.tool_calls) - base_text = f"{base_text}\n{tool_arg_text}" if base_text else tool_arg_text + tool_param_text = "".join(call.function.arguments or "" for call in message.tool_calls) + base_text = f"{base_text}\n{tool_param_text}" if base_text else tool_param_text return base_text diff --git a/app/utils/logging.py b/app/utils/logging.py index 87fcc7f..da417f1 100644 --- a/app/utils/logging.py +++ b/app/utils/logging.py @@ -15,7 +15,7 @@ def setup_logging( """ Setup loguru logging configuration to unify all project logging output - Args: + Params: level: Log level diagnose: Whether to enable diagnostic information backtrace: Whether to enable backtrace information From 8792948642f510ca294323c12e90232cb1926e06 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C6=B0u=20Quang=20V=C5=A9?= Date: Fri, 13 Feb 2026 22:35:39 +0700 Subject: [PATCH 122/291] Refactor: Remove all escape logic handlers. - Change tool call tags to snake_case. --- app/services/client.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/app/services/client.py b/app/services/client.py index 1c826e5..90474ad 100644 --- a/app/services/client.py +++ b/app/services/client.py @@ -125,11 +125,14 @@ async def process_message( if isinstance(parsed_params, dict): for k, v in parsed_params.items(): val_str = v if isinstance(v, str) else orjson.dumps(v).decode("utf-8") - formatted_params += f"[call_parameter:{k}]{val_str}[/call_parameter]\n" + # Wrap value in triple backticks to match the required protocol hint + formatted_params += ( + f"[call_parameter:{k}]\n```\n{val_str}\n```\n[/call_parameter]\n" + ) else: - formatted_params += params_text + formatted_params += f"```\n{params_text}\n```\n" except orjson.JSONDecodeError: - formatted_params += params_text + formatted_params += f"```\n{params_text}\n```\n" tool_blocks.append(f"[call:{call.function.name}]\n{formatted_params}[/call]") From 5482e0cd51fa8051a92dafb061fa4677e108b10f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C6=B0u=20Quang=20V=C5=A9?= Date: Fri, 13 Feb 2026 22:56:47 +0700 Subject: [PATCH 123/291] Refactor: Remove all escape logic handlers. - Change tool call tags to snake_case. --- app/services/client.py | 1 - app/utils/helper.py | 12 ++++++++---- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/app/services/client.py b/app/services/client.py index 90474ad..9a2742b 100644 --- a/app/services/client.py +++ b/app/services/client.py @@ -125,7 +125,6 @@ async def process_message( if isinstance(parsed_params, dict): for k, v in parsed_params.items(): val_str = v if isinstance(v, str) else orjson.dumps(v).decode("utf-8") - # Wrap value in triple backticks to match the required protocol hint formatted_params += ( f"[call_parameter:{k}]\n```\n{val_str}\n```\n[/call_parameter]\n" ) diff --git a/app/utils/helper.py b/app/utils/helper.py index 752aed9..3576667 100644 --- a/app/utils/helper.py +++ b/app/utils/helper.py @@ -58,6 +58,7 @@ CONTROL_TOKEN_RE = re.compile(r"<\|im_(?:start|end)\|>", re.IGNORECASE) CHATML_START_RE = re.compile(r"<\|im_start\|>\s*(\w+)\s*\n?", re.IGNORECASE) CHATML_END_RE = re.compile(r"<\|im_end\|>", re.IGNORECASE) +COMMONMARK_UNESCAPE_RE = re.compile(r"\\([!\"#$%&'()*+,\-./:;<=>?@\[\\\]^_`{|}~])") FILE_PATH_PATTERN = re.compile( r"^(?=.*[./\\]|.*:\d+|^(?:Dockerfile|Makefile|Jenkinsfile|Procfile|Rakefile|Gemfile|Vagrantfile|Caddyfile|Justfile|LICENSE|README|CONTRIBUTING|CODEOWNERS|AUTHORS|NOTICE|CHANGELOG)$)([a-zA-Z0-9_./\\-]+(?::\d+)?)$", re.IGNORECASE, @@ -131,14 +132,15 @@ def _strip_param_fences(s: str) -> str: return s[n:-n].strip() -def _repair_param_value(s: str) -> str: +def repair_param_value(s: str) -> str: """ - Standardize and repair LLM-generated parameter values + Standardize and repair LLM-generated values (unescaping, link normalization) to ensure compatibility with specialized clients like Roo Code. """ if not s: return "" + s = COMMONMARK_UNESCAPE_RE.sub(r"\1", s) s = GOOGLE_SEARCH_PATTERN.sub(_strip_google_search, s) return s @@ -264,7 +266,9 @@ def _create_tool_call(name: str, raw_params: str) -> None: if not extract: return - name = name.strip() + name = repair_param_value(name.strip()) + raw_params = repair_param_value(raw_params) + if not name: logger.warning("Encountered tool_call without a function name.") return @@ -272,7 +276,7 @@ def _create_tool_call(name: str, raw_params: str) -> None: param_matches = TAGGED_PARAM_RE.findall(raw_params) if param_matches: params_dict = { - param_name.strip(): _repair_param_value(_strip_param_fences(param_value)) + param_name.strip(): _strip_param_fences(param_value) for param_name, param_value in param_matches } arguments = orjson.dumps(params_dict).decode("utf-8") From b324aef75d5a868d9ea409dffa2adc173f69c63b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C6=B0u=20Quang=20V=C5=A9?= Date: Fri, 13 Feb 2026 23:10:53 +0700 Subject: [PATCH 124/291] Revert "Refactor: Remove all escape logic handlers." This reverts commit ce43d63c --- app/server/chat.py | 61 ++++++++++++++++--------------- app/services/client.py | 34 +++++++++--------- app/services/lmdb.py | 22 ++++++------ app/utils/helper.py | 81 +++++++++++++++++++++--------------------- app/utils/logging.py | 2 +- 5 files changed, 103 insertions(+), 97 deletions(-) diff --git a/app/server/chat.py b/app/server/chat.py index 98277d6..881f7c9 100644 --- a/app/server/chat.py +++ b/app/server/chat.py @@ -121,18 +121,18 @@ def _calculate_usage( ) -> tuple[int, int, int]: """Calculate prompt, completion and total tokens consistently.""" prompt_tokens = sum(estimate_tokens(text_from_message(msg)) for msg in messages) - tool_params_text = "" + tool_args_text = "" if tool_calls: for call in tool_calls: if hasattr(call, "function"): - tool_params_text += call.function.arguments or "" + tool_args_text += call.function.arguments or "" elif isinstance(call, dict): - tool_params_text += call.get("function", {}).get("arguments", "") + tool_args_text += call.get("function", {}).get("arguments", "") completion_basis = assistant_text or "" - if tool_params_text: + if tool_args_text: completion_basis = ( - f"{completion_basis}\n{tool_params_text}" if completion_basis else tool_params_text + f"{completion_basis}\n{tool_args_text}" if completion_basis else tool_args_text ) completion_tokens = estimate_tokens(completion_basis) @@ -343,7 +343,7 @@ def _build_tool_prompt( tools: list[Tool], tool_choice: str | ToolChoiceFunction | None, ) -> str: - """Generate a system prompt describing available tools and the snake_case protocol.""" + """Generate a system prompt describing available tools and the PascalCase protocol.""" if not tools: return "" @@ -359,10 +359,10 @@ def _build_tool_prompt( schema_text = orjson.dumps(function.parameters, option=orjson.OPT_SORT_KEYS).decode( "utf-8" ) - lines.append("Parameters JSON schema:") + lines.append("Arguments JSON schema:") lines.append(schema_text) else: - lines.append("Parameters JSON schema: {}") + lines.append("Arguments JSON schema: {}") if tool_choice == "none": lines.append( @@ -760,28 +760,28 @@ def __init__(self): self.STATE_MARKERS = { "TOOL": { - "starts": ["[tool_calls]"], - "ends": ["[/tool_calls]"], + "starts": ["[ToolCalls]", "\\[ToolCalls\\]"], + "ends": ["[/ToolCalls]", "\\[/ToolCalls\\]"], }, "ORPHAN": { - "starts": ["[call:"], - "ends": ["[/call]"], + "starts": ["[Call:", "\\[Call:", "\\[Call\\:"], + "ends": ["[/Call]", "\\[/Call\\]"], }, "RESP": { - "starts": ["[tool_results]"], - "ends": ["[/tool_results]"], + "starts": ["[ToolResults]", "\\[ToolResults\\]"], + "ends": ["[/ToolResults]", "\\[/ToolResults\\]"], }, - "PARAM": { - "starts": ["[call_parameter:"], - "ends": ["[/call_parameter]"], + "ARG": { + "starts": ["[CallParameter:", "\\[CallParameter:", "\\[CallParameter\\:"], + "ends": ["[/CallParameter]", "\\[/CallParameter\\]"], }, "RESULT": { - "starts": ["[tool_result]"], - "ends": ["[/tool_result]"], + "starts": ["[ToolResult]", "\\[ToolResult\\]"], + "ends": ["[/ToolResult]", "\\[/ToolResult\\]"], }, "TAG": { - "starts": ["<|im_start|>"], - "ends": ["<|im_end|>"], + "starts": ["<|im_start|>", "\\<|im\\_start|\\>"], + "ends": ["<|im_end|>", "\\<|im\\_end|\\>"], }, } @@ -794,10 +794,15 @@ def __init__(self): self.ORPHAN_ENDS = [ "<|im_end|>", - "[/call]", - "[/tool_calls]", - "[/call_parameter]", - "[/tool_result]", + "\\<|im\\_end|\\>", + "[/Call]", + "\\[/Call\\]", + "[/ToolCalls]", + "\\[/ToolCalls\\]", + "[/CallParameter]", + "\\[/CallParameter\\]", + "[/ToolResult]", + "\\[/ToolResult\\]", ] self.WATCH_MARKERS = [] @@ -871,8 +876,8 @@ def process(self, chunk: str) -> str: self.buffer = self.buffer[-max_end_len:] break - elif self.state == "IN_PARAM": - cfg = self.STATE_MARKERS["PARAM"] + elif self.state == "IN_ARG": + cfg = self.STATE_MARKERS["ARG"] found_idx, found_len = -1, 0 for p in cfg["ends"]: idx = buf_low.find(p.lower()) @@ -998,7 +1003,7 @@ def process(self, chunk: str) -> str: def flush(self) -> str: """Release remaining buffer content and perform final cleanup at stream end.""" res = "" - if self.state in ("IN_TOOL", "IN_ORPHAN", "IN_RESP", "IN_HINT", "IN_PARAM", "IN_RESULT"): + if self.state in ("IN_TOOL", "IN_ORPHAN", "IN_RESP", "IN_HINT", "IN_ARG", "IN_RESULT"): res = "" elif self.state == "IN_BLOCK" and self.current_role != "tool": res = self.buffer diff --git a/app/services/client.py b/app/services/client.py index 9a2742b..6ab80cd 100644 --- a/app/services/client.py +++ b/app/services/client.py @@ -70,8 +70,8 @@ async def process_message( message: Message, tempdir: Path | None = None, tagged: bool = True, wrap_tool: bool = True ) -> tuple[str, list[Path | str]]: """ - Process a Message into Gemini API format using the snake_case technical protocol. - Extracts text, handles files, and appends tool_calls/tool_results blocks. + Process a Message into Gemini API format using the PascalCase technical protocol. + Extracts text, handles files, and appends ToolCalls/ToolResults blocks. """ files: list[Path | str] = [] text_fragments: list[str] = [] @@ -109,34 +109,34 @@ async def process_message( if message.role == "tool": tool_name = message.name or "unknown" combined_content = "\n".join(text_fragments).strip() - res_block = f"[result:{tool_name}]\n[tool_result]\n{combined_content}\n[/tool_result]\n[/result]" + res_block = ( + f"[Result:{tool_name}]\n[ToolResult]\n{combined_content}\n[/ToolResult]\n[/Result]" + ) if wrap_tool: - text_fragments = [f"[tool_results]\n{res_block}\n[/tool_results]"] + text_fragments = [f"[ToolResults]\n{res_block}\n[/ToolResults]"] else: text_fragments = [res_block] if message.tool_calls: tool_blocks: list[str] = [] for call in message.tool_calls: - params_text = call.function.arguments.strip() - formatted_params = "" + args_text = call.function.arguments.strip() + formatted_args = "@args\n" try: - parsed_params = orjson.loads(params_text) - if isinstance(parsed_params, dict): - for k, v in parsed_params.items(): + parsed_args = orjson.loads(args_text) + if isinstance(parsed_args, dict): + for k, v in parsed_args.items(): val_str = v if isinstance(v, str) else orjson.dumps(v).decode("utf-8") - formatted_params += ( - f"[call_parameter:{k}]\n```\n{val_str}\n```\n[/call_parameter]\n" - ) + formatted_args += f"[CallParameter:{k}]{val_str}[/CallParameter]\n" else: - formatted_params += f"```\n{params_text}\n```\n" + formatted_args += args_text except orjson.JSONDecodeError: - formatted_params += f"```\n{params_text}\n```\n" + formatted_args += args_text - tool_blocks.append(f"[call:{call.function.name}]\n{formatted_params}[/call]") + tool_blocks.append(f"[Call:{call.function.name}]\n{formatted_args}[/Call]") if tool_blocks: - tool_section = "[tool_calls]\n" + "\n".join(tool_blocks) + "\n[/tool_calls]" + tool_section = "[ToolCalls]\n" + "\n".join(tool_blocks) + "\n[/ToolCalls]" text_fragments.append(tool_section) model_input = "\n".join(fragment for fragment in text_fragments if fragment is not None) @@ -168,7 +168,7 @@ async def process_conversation( i += 1 combined_tool_content = "\n".join(tool_blocks) - wrapped_content = f"[tool_results]\n{combined_tool_content}\n[/tool_results]" + wrapped_content = f"[ToolResults]\n{combined_tool_content}\n[/ToolResults]" conversation.append(add_tag("tool", wrapped_content)) else: input_part, files_part = await GeminiClientWrapper.process_message( diff --git a/app/services/lmdb.py b/app/services/lmdb.py index 2f59662..ad92bbf 100644 --- a/app/services/lmdb.py +++ b/app/services/lmdb.py @@ -99,17 +99,17 @@ def _hash_message(message: Message, fuzzy: bool = False) -> str: if message.tool_calls: calls_data = [] for tc in message.tool_calls: - params = tc.function.arguments or "{}" + args = tc.function.arguments or "{}" try: - parsed = orjson.loads(params) - canon_params = orjson.dumps(parsed, option=orjson.OPT_SORT_KEYS).decode("utf-8") + parsed = orjson.loads(args) + canon_args = orjson.dumps(parsed, option=orjson.OPT_SORT_KEYS).decode("utf-8") except orjson.JSONDecodeError: - canon_params = params + canon_args = args calls_data.append( { "name": tc.function.name, - "arguments": canon_params, + "arguments": canon_args, } ) calls_data.sort(key=lambda x: (x["name"], x["arguments"])) @@ -149,7 +149,7 @@ def __init__( """ Initialize LMDB store. - Params: + Args: db_path: Path to LMDB database directory max_db_size: Maximum database size in bytes (default: 256 MB) retention_days: Number of days to retain conversations (default: 14, 0 disables cleanup) @@ -194,7 +194,7 @@ def _get_transaction(self, write: bool = False): """ Context manager for LMDB transactions. - Params: + Args: write: Whether the transaction should be writable. """ if not self._env: @@ -265,7 +265,7 @@ def store( """ Store a conversation model in LMDB. - Params: + Args: conv: Conversation model to store custom_key: Optional custom key, if not provided, hash will be used @@ -313,7 +313,7 @@ def get(self, key: str) -> Optional[ConversationInStore]: """ Retrieve conversation data by key. - Params: + Args: key: Storage key (hash or custom key) Returns: @@ -342,7 +342,7 @@ def find(self, model: str, messages: List[Message]) -> Optional[ConversationInSt Search conversation data by message list. Tries raw matching, then sanitized matching, and finally fuzzy matching. - Params: + Args: model: Model name messages: List of messages to match @@ -382,7 +382,7 @@ def _find_by_message_list( """ Internal find implementation based on a message list. - Params: + Args: model: Model name messages: Message list to hash fuzzy: Whether to use fuzzy hashing diff --git a/app/utils/helper.py b/app/utils/helper.py index 3576667..ae96f05 100644 --- a/app/utils/helper.py +++ b/app/utils/helper.py @@ -19,45 +19,47 @@ VALID_TAG_ROLES = {"user", "assistant", "system", "tool"} TOOL_WRAP_HINT = ( "\n\nSYSTEM INTERFACE: Tool calling protocol. You MUST follow these MANDATORY rules:\n\n" - "1. Respond ONLY with a single [tool_calls] block. NO conversational text, NO explanations, NO filler.\n" + "1. Respond ONLY with a single [ToolCalls] block. NO conversational text, NO explanations, NO filler.\n" "2. For ALL parameters, the value MUST be entirely enclosed in a single markdown code block (start/end with backticks) inside the tags. NO text allowed outside this block.\n" "3. Use a markdown fence longer than any backtick sequence in the value (e.g., use ```` if value has ```).\n\n" "EXACT SYNTAX TEMPLATE:\n" - "[tool_calls]\n" - "[call:tool_name]\n" - "[call_parameter:parameter_name]\n" + "[ToolCalls]\n" + "[Call:tool_name]\n" + "[CallParameter:arg_name]\n" "```\n" "value\n" "```\n" - "[/call_parameter]\n" - "[/call]\n" - "[/tool_calls]\n\n" + "[/CallParameter]\n" + "[/Call]\n" + "[/ToolCalls]\n\n" "CRITICAL: Every tag MUST be opened and closed accurately.\n\n" - "Multiple tools: List them sequentially inside one [tool_calls] block. No tool: respond naturally, NEVER use protocol tags.\n" + "Multiple tools: List them sequentially inside one [ToolCalls] block. No tool: respond naturally, NEVER use protocol tags.\n" +) +TOOL_BLOCK_RE = re.compile( + r"\\?\[ToolCalls\\?]\s*(.*?)\s*\\?\[/ToolCalls\\?]", re.DOTALL | re.IGNORECASE ) -TOOL_BLOCK_RE = re.compile(r"\[tool_calls]\s*(.*?)\s*\[/tool_calls]", re.DOTALL | re.IGNORECASE) TOOL_CALL_RE = re.compile( - r"\[call:((?:[^]\\]|\\.)+)]\s*(.*?)\s*\[/call]", re.DOTALL | re.IGNORECASE + r"\\?\[Call\\?:((?:[^]\\]|\\.)+)\\?]\s*(.*?)\s*\\?\[/Call\\?]", re.DOTALL | re.IGNORECASE ) RESPONSE_BLOCK_RE = re.compile( - r"\[tool_results]\s*(.*?)\s*\[/tool_results]", + r"\\?\[ToolResults\\?]\s*(.*?)\s*\\?\[/ToolResults\\?]", re.DOTALL | re.IGNORECASE, ) RESPONSE_ITEM_RE = re.compile( - r"\[result:((?:[^]\\]|\\.)+)]\s*(.*?)\s*\[/result]", + r"\\?\[Result\\?:((?:[^]\\]|\\.)+)\\?]\s*(.*?)\s*\\?\[/Result\\?]", re.DOTALL | re.IGNORECASE, ) -TAGGED_PARAM_RE = re.compile( - r"\[call_parameter:((?:[^]\\]|\\.)+)]\s*(.*?)\s*\[/call_parameter]", +TAGGED_ARG_RE = re.compile( + r"\\?\[CallParameter\\?:((?:[^]\\]|\\.)+)\\?]\s*(.*?)\s*\\?\[/CallParameter\\?]", re.DOTALL | re.IGNORECASE, ) TAGGED_RESULT_RE = re.compile( - r"\[tool_result]\s*(.*?)\s*\[/tool_result]", + r"\\?\[ToolResult\\?]\s*(.*?)\s*\\?\[/ToolResult\\?]", re.DOTALL | re.IGNORECASE, ) -CONTROL_TOKEN_RE = re.compile(r"<\|im_(?:start|end)\|>", re.IGNORECASE) -CHATML_START_RE = re.compile(r"<\|im_start\|>\s*(\w+)\s*\n?", re.IGNORECASE) -CHATML_END_RE = re.compile(r"<\|im_end\|>", re.IGNORECASE) +CONTROL_TOKEN_RE = re.compile(r"\\?<\|im\\?_(?:start|end)\|\\?>", re.IGNORECASE) +CHATML_START_RE = re.compile(r"\\?<\|im\\?_start\|\\?>\s*(\w+)\s*\n?", re.IGNORECASE) +CHATML_END_RE = re.compile(r"\\?<\|im\\?_end\|\\?>", re.IGNORECASE) COMMONMARK_UNESCAPE_RE = re.compile(r"\\([!\"#$%&'()*+,\-./:;<=>?@\[\\\]^_`{|}~])") FILE_PATH_PATTERN = re.compile( r"^(?=.*[./\\]|.*:\d+|^(?:Dockerfile|Makefile|Jenkinsfile|Procfile|Rakefile|Gemfile|Vagrantfile|Caddyfile|Justfile|LICENSE|README|CONTRIBUTING|CODEOWNERS|AUTHORS|NOTICE|CHANGELOG)$)([a-zA-Z0-9_./\\-]+(?::\d+)?)$", @@ -132,9 +134,9 @@ def _strip_param_fences(s: str) -> str: return s[n:-n].strip() -def repair_param_value(s: str) -> str: +def unescape_llm_text(s: str) -> str: """ - Standardize and repair LLM-generated values (unescaping, link normalization) + Standardize and repair LLM-generated text fragments (unescaping, link normalization) to ensure compatibility with specialized clients like Roo Code. """ if not s: @@ -246,7 +248,7 @@ def strip_system_hints(text: str) -> str: cleaned = TOOL_CALL_RE.sub("", cleaned) cleaned = RESPONSE_BLOCK_RE.sub("", cleaned) cleaned = RESPONSE_ITEM_RE.sub("", cleaned) - cleaned = TAGGED_PARAM_RE.sub("", cleaned) + cleaned = TAGGED_ARG_RE.sub("", cleaned) cleaned = TAGGED_RESULT_RE.sub("", cleaned) return cleaned @@ -255,39 +257,38 @@ def strip_system_hints(text: str) -> str: def _process_tools_internal(text: str, extract: bool = True) -> tuple[str, list[ToolCall]]: """ Extract tool metadata and return text stripped of technical markers. - Parameters are parsed into JSON and assigned deterministic call IDs. + Arguments are parsed into JSON and assigned deterministic call IDs. """ if not text: return text, [] tool_calls: list[ToolCall] = [] - def _create_tool_call(name: str, raw_params: str) -> None: + def _create_tool_call(name: str, raw_args: str) -> None: if not extract: return - - name = repair_param_value(name.strip()) - raw_params = repair_param_value(raw_params) - if not name: logger.warning("Encountered tool_call without a function name.") return - param_matches = TAGGED_PARAM_RE.findall(raw_params) - if param_matches: - params_dict = { - param_name.strip(): _strip_param_fences(param_value) - for param_name, param_value in param_matches + name = unescape_llm_text(name.strip()) + raw_args = unescape_llm_text(raw_args) + + arg_matches = TAGGED_ARG_RE.findall(raw_args) + if arg_matches: + args_dict = { + arg_name.strip(): _strip_param_fences(arg_value) + for arg_name, arg_value in arg_matches } - arguments = orjson.dumps(params_dict).decode("utf-8") - logger.debug(f"Successfully parsed {len(params_dict)} parameters for tool: {name}") + arguments = orjson.dumps(args_dict).decode("utf-8") + logger.debug(f"Successfully parsed {len(args_dict)} arguments for tool: {name}") else: - cleaned_raw = raw_params.strip() + cleaned_raw = raw_args.strip() if not cleaned_raw: - logger.debug(f"Successfully parsed 0 parameters for tool: {name}") + logger.debug(f"Successfully parsed 0 arguments for tool: {name}") else: logger.warning( - f"Malformed parameters for tool '{name}'. Text found but no valid tags: {reprlib.repr(cleaned_raw)}" + f"Malformed arguments for tool '{name}'. Text found but no valid tags: {reprlib.repr(cleaned_raw)}" ) arguments = "{}" @@ -322,7 +323,7 @@ def extract_tool_calls(text: str) -> tuple[str, list[ToolCall]]: def text_from_message(message: Message) -> str: - """Concatenate text and tool parameters from a message for token estimation.""" + """Concatenate text and tool arguments from a message for token estimation.""" base_text = "" if isinstance(message.content, str): base_text = message.content @@ -334,8 +335,8 @@ def text_from_message(message: Message) -> str: base_text = "" if message.tool_calls: - tool_param_text = "".join(call.function.arguments or "" for call in message.tool_calls) - base_text = f"{base_text}\n{tool_param_text}" if base_text else tool_param_text + tool_arg_text = "".join(call.function.arguments or "" for call in message.tool_calls) + base_text = f"{base_text}\n{tool_arg_text}" if base_text else tool_arg_text return base_text diff --git a/app/utils/logging.py b/app/utils/logging.py index da417f1..87fcc7f 100644 --- a/app/utils/logging.py +++ b/app/utils/logging.py @@ -15,7 +15,7 @@ def setup_logging( """ Setup loguru logging configuration to unify all project logging output - Params: + Args: level: Log level diagnose: Whether to enable diagnostic information backtrace: Whether to enable backtrace information From 8ef108d4827c29c617fbb968f2c09d7fe8d83fcd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C6=B0u=20Quang=20V=C5=A9?= Date: Fri, 13 Feb 2026 23:11:36 +0700 Subject: [PATCH 125/291] Refactor: Rewrite the function call format to match the client's complex argument structure --- app/services/client.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/services/client.py b/app/services/client.py index 6ab80cd..0b2aea5 100644 --- a/app/services/client.py +++ b/app/services/client.py @@ -121,7 +121,7 @@ async def process_message( tool_blocks: list[str] = [] for call in message.tool_calls: args_text = call.function.arguments.strip() - formatted_args = "@args\n" + formatted_args = "" try: parsed_args = orjson.loads(args_text) if isinstance(parsed_args, dict): From 30043e585650c11876312b6dadc2a52eaaacdf60 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C6=B0u=20Quang=20V=C5=A9?= Date: Fri, 13 Feb 2026 23:14:45 +0700 Subject: [PATCH 126/291] Refactor: Rewrite the function call format to match the client's complex argument structure --- app/utils/helper.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/utils/helper.py b/app/utils/helper.py index ae96f05..0b91993 100644 --- a/app/utils/helper.py +++ b/app/utils/helper.py @@ -25,7 +25,7 @@ "EXACT SYNTAX TEMPLATE:\n" "[ToolCalls]\n" "[Call:tool_name]\n" - "[CallParameter:arg_name]\n" + "[CallParameter:parameter_name]\n" "```\n" "value\n" "```\n" From bc888d1fbb8a076074486293531441553c956028 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C6=B0u=20Quang=20V=C5=A9?= Date: Fri, 13 Feb 2026 23:22:26 +0700 Subject: [PATCH 127/291] Refactor: Rewrite the function call format to match the client's complex argument structure --- app/services/client.py | 33 +++++++++++++++++++-------------- 1 file changed, 19 insertions(+), 14 deletions(-) diff --git a/app/services/client.py b/app/services/client.py index 0b2aea5..70dfce9 100644 --- a/app/services/client.py +++ b/app/services/client.py @@ -120,20 +120,25 @@ async def process_message( if message.tool_calls: tool_blocks: list[str] = [] for call in message.tool_calls: - args_text = call.function.arguments.strip() - formatted_args = "" - try: - parsed_args = orjson.loads(args_text) - if isinstance(parsed_args, dict): - for k, v in parsed_args.items(): - val_str = v if isinstance(v, str) else orjson.dumps(v).decode("utf-8") - formatted_args += f"[CallParameter:{k}]{val_str}[/CallParameter]\n" - else: - formatted_args += args_text - except orjson.JSONDecodeError: - formatted_args += args_text - - tool_blocks.append(f"[Call:{call.function.name}]\n{formatted_args}[/Call]") + params_text = call.function.arguments.strip() + formatted_params = "" + if params_text: + try: + parsed_params = orjson.loads(params_text) + if isinstance(parsed_params, dict): + for k, v in parsed_params.items(): + val_str = ( + v if isinstance(v, str) else orjson.dumps(v).decode("utf-8") + ) + formatted_params += ( + f"[CallParameter:{k}]\n```\n{val_str}\n```\n[/CallParameter]\n" + ) + else: + formatted_params += f"```\n{params_text}\n```\n" + except orjson.JSONDecodeError: + formatted_params += f"```\n{params_text}\n```\n" + + tool_blocks.append(f"[Call:{call.function.name}]\n{formatted_params}[/Call]") if tool_blocks: tool_section = "[ToolCalls]\n" + "\n".join(tool_blocks) + "\n[/ToolCalls]" From d5349a096f9564d99b7939c6a32fae1193db0b8d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C6=B0u=20Quang=20V=C5=A9?= Date: Fri, 13 Feb 2026 23:29:23 +0700 Subject: [PATCH 128/291] Refactor: Rewrite the function call format to match the client's complex argument structure --- app/utils/helper.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/app/utils/helper.py b/app/utils/helper.py index 0b91993..82bb562 100644 --- a/app/utils/helper.py +++ b/app/utils/helper.py @@ -134,7 +134,7 @@ def _strip_param_fences(s: str) -> str: return s[n:-n].strip() -def unescape_llm_text(s: str) -> str: +def _repair_param_value(s: str) -> str: """ Standardize and repair LLM-generated text fragments (unescaping, link normalization) to ensure compatibility with specialized clients like Roo Code. @@ -271,8 +271,8 @@ def _create_tool_call(name: str, raw_args: str) -> None: logger.warning("Encountered tool_call without a function name.") return - name = unescape_llm_text(name.strip()) - raw_args = unescape_llm_text(raw_args) + name = _repair_param_value(name.strip()) + raw_args = _repair_param_value(raw_args) arg_matches = TAGGED_ARG_RE.findall(raw_args) if arg_matches: From 7e217e917778211ad002511d1323739ed2b0e293 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C6=B0u=20Quang=20V=C5=A9?= Date: Sat, 14 Feb 2026 00:28:49 +0700 Subject: [PATCH 129/291] Reattempt changing tool call tags to `snake_case`. --- app/server/chat.py | 50 ++++++++++++++++++++++++------------------ app/services/client.py | 20 +++++++---------- app/utils/helper.py | 40 +++++++++++++++++++-------------- 3 files changed, 60 insertions(+), 50 deletions(-) diff --git a/app/server/chat.py b/app/server/chat.py index 881f7c9..1211c99 100644 --- a/app/server/chat.py +++ b/app/server/chat.py @@ -760,28 +760,32 @@ def __init__(self): self.STATE_MARKERS = { "TOOL": { - "starts": ["[ToolCalls]", "\\[ToolCalls\\]"], - "ends": ["[/ToolCalls]", "\\[/ToolCalls\\]"], + "starts": ["[tool_calls]", "\\[tool\\_calls\\]"], + "ends": ["[/tool_calls]", "\\[\\/tool\\_calls\\]"], }, "ORPHAN": { - "starts": ["[Call:", "\\[Call:", "\\[Call\\:"], - "ends": ["[/Call]", "\\[/Call\\]"], + "starts": ["[call:", "\\[call\\:"], + "ends": ["[/call]", "\\[\\/call\\]"], }, "RESP": { - "starts": ["[ToolResults]", "\\[ToolResults\\]"], - "ends": ["[/ToolResults]", "\\[/ToolResults\\]"], + "starts": ["[tool_results]", "\\[tool\\_results\\]"], + "ends": ["[/tool_results]", "\\[\\/tool\\_results\\]"], }, "ARG": { - "starts": ["[CallParameter:", "\\[CallParameter:", "\\[CallParameter\\:"], - "ends": ["[/CallParameter]", "\\[/CallParameter\\]"], + "starts": ["[call_parameter:", "\\[call\\_parameter\\:"], + "ends": ["[/call_parameter]", "\\[\\/call\\_parameter\\]"], }, "RESULT": { - "starts": ["[ToolResult]", "\\[ToolResult\\]"], - "ends": ["[/ToolResult]", "\\[/ToolResult\\]"], + "starts": ["[tool_result]", "\\[tool\\_result\\]"], + "ends": ["[/tool_result]", "\\[\\/tool\\_result\\]"], + }, + "ITEM": { + "starts": ["[result:", "\\[result\\:"], + "ends": ["[/result]", "\\[\\/result\\]"], }, "TAG": { - "starts": ["<|im_start|>", "\\<|im\\_start|\\>"], - "ends": ["<|im_end|>", "\\<|im\\_end|\\>"], + "starts": ["<|im_start|>", "\\<\\|im\\_start\\|\\>"], + "ends": ["<|im_end|>", "\\<\\|im\\_end\\|\\>"], }, } @@ -794,15 +798,19 @@ def __init__(self): self.ORPHAN_ENDS = [ "<|im_end|>", - "\\<|im\\_end|\\>", - "[/Call]", - "\\[/Call\\]", - "[/ToolCalls]", - "\\[/ToolCalls\\]", - "[/CallParameter]", - "\\[/CallParameter\\]", - "[/ToolResult]", - "\\[/ToolResult\\]", + "\\<\\|im\\_end\\|\\>", + "[/call]", + "\\[\\/call\\]", + "[/tool_calls]", + "\\[\\/tool\\_calls\\]", + "[/call_parameter]", + "\\[\\/call\\_parameter\\]", + "[/tool_result]", + "\\[\\/tool\\_result\\]", + "[/tool_results]", + "\\[\\/tool\\_results\\]", + "[/result]", + "\\[\\/result\\]", ] self.WATCH_MARKERS = [] diff --git a/app/services/client.py b/app/services/client.py index 70dfce9..05e7415 100644 --- a/app/services/client.py +++ b/app/services/client.py @@ -70,8 +70,8 @@ async def process_message( message: Message, tempdir: Path | None = None, tagged: bool = True, wrap_tool: bool = True ) -> tuple[str, list[Path | str]]: """ - Process a Message into Gemini API format using the PascalCase technical protocol. - Extracts text, handles files, and appends ToolCalls/ToolResults blocks. + Process a Message into Gemini API format using the snake_case technical protocol. + Extracts text, handles files, and appends tool_calls/tool_results blocks. """ files: list[Path | str] = [] text_fragments: list[str] = [] @@ -109,11 +109,9 @@ async def process_message( if message.role == "tool": tool_name = message.name or "unknown" combined_content = "\n".join(text_fragments).strip() - res_block = ( - f"[Result:{tool_name}]\n[ToolResult]\n{combined_content}\n[/ToolResult]\n[/Result]" - ) + res_block = f"[result:{tool_name}]\n[tool_result]\n{combined_content}\n[/tool_result]\n[/result]" if wrap_tool: - text_fragments = [f"[ToolResults]\n{res_block}\n[/ToolResults]"] + text_fragments = [f"[tool_results]\n{res_block}\n[/tool_results]"] else: text_fragments = [res_block] @@ -130,18 +128,16 @@ async def process_message( val_str = ( v if isinstance(v, str) else orjson.dumps(v).decode("utf-8") ) - formatted_params += ( - f"[CallParameter:{k}]\n```\n{val_str}\n```\n[/CallParameter]\n" - ) + formatted_params += f"[call_parameter:{k}]\n```\n{val_str}\n```\n[/call_parameter]\n" else: formatted_params += f"```\n{params_text}\n```\n" except orjson.JSONDecodeError: formatted_params += f"```\n{params_text}\n```\n" - tool_blocks.append(f"[Call:{call.function.name}]\n{formatted_params}[/Call]") + tool_blocks.append(f"[call:{call.function.name}]\n{formatted_params}[/call]") if tool_blocks: - tool_section = "[ToolCalls]\n" + "\n".join(tool_blocks) + "\n[/ToolCalls]" + tool_section = "[tool_calls]\n" + "\n".join(tool_blocks) + "\n[/tool_calls]" text_fragments.append(tool_section) model_input = "\n".join(fragment for fragment in text_fragments if fragment is not None) @@ -173,7 +169,7 @@ async def process_conversation( i += 1 combined_tool_content = "\n".join(tool_blocks) - wrapped_content = f"[ToolResults]\n{combined_tool_content}\n[/ToolResults]" + wrapped_content = f"[tool_results]\n{combined_tool_content}\n[/tool_results]" conversation.append(add_tag("tool", wrapped_content)) else: input_part, files_part = await GeminiClientWrapper.process_message( diff --git a/app/utils/helper.py b/app/utils/helper.py index 82bb562..a1292d3 100644 --- a/app/utils/helper.py +++ b/app/utils/helper.py @@ -19,47 +19,53 @@ VALID_TAG_ROLES = {"user", "assistant", "system", "tool"} TOOL_WRAP_HINT = ( "\n\nSYSTEM INTERFACE: Tool calling protocol. You MUST follow these MANDATORY rules:\n\n" - "1. Respond ONLY with a single [ToolCalls] block. NO conversational text, NO explanations, NO filler.\n" + "1. Respond ONLY with a single [tool_calls] block. NO conversational text, NO explanations, NO filler.\n" "2. For ALL parameters, the value MUST be entirely enclosed in a single markdown code block (start/end with backticks) inside the tags. NO text allowed outside this block.\n" "3. Use a markdown fence longer than any backtick sequence in the value (e.g., use ```` if value has ```).\n\n" "EXACT SYNTAX TEMPLATE:\n" - "[ToolCalls]\n" - "[Call:tool_name]\n" - "[CallParameter:parameter_name]\n" + "[tool_calls]\n" + "[call:tool_name]\n" + "[call_parameter:parameter_name]\n" "```\n" "value\n" "```\n" - "[/CallParameter]\n" - "[/Call]\n" - "[/ToolCalls]\n\n" + "[/call_parameter]\n" + "[/call]\n" + "[/tool_calls]\n\n" "CRITICAL: Every tag MUST be opened and closed accurately.\n\n" - "Multiple tools: List them sequentially inside one [ToolCalls] block. No tool: respond naturally, NEVER use protocol tags.\n" + "Multiple tools: List them sequentially inside one [tool_calls] block. No tool: respond naturally, NEVER use protocol tags.\n" ) TOOL_BLOCK_RE = re.compile( - r"\\?\[ToolCalls\\?]\s*(.*?)\s*\\?\[/ToolCalls\\?]", re.DOTALL | re.IGNORECASE + r"(?:\[tool_calls]|\\\[tool\\_calls\\])\s*(.*?)\s*(?:\[/tool_calls]|\\\[\\/tool\\_calls\\])", + re.DOTALL | re.IGNORECASE, ) TOOL_CALL_RE = re.compile( - r"\\?\[Call\\?:((?:[^]\\]|\\.)+)\\?]\s*(.*?)\s*\\?\[/Call\\?]", re.DOTALL | re.IGNORECASE + r"(?:\[call:|\\\[call\\:)(?P(?:[^]\\]|\\.)+)(?:]|\\])\s*(?P.*?)\s*(?:\[/call]|\\\[\\/call\\])", + re.DOTALL | re.IGNORECASE, ) RESPONSE_BLOCK_RE = re.compile( - r"\\?\[ToolResults\\?]\s*(.*?)\s*\\?\[/ToolResults\\?]", + r"(?:\[tool_results]|\\\[tool\\_results\\])\s*(.*?)\s*(?:\[/tool_results]|\\\[\\/tool\\_results\\])", re.DOTALL | re.IGNORECASE, ) RESPONSE_ITEM_RE = re.compile( - r"\\?\[Result\\?:((?:[^]\\]|\\.)+)\\?]\s*(.*?)\s*\\?\[/Result\\?]", + r"(?:\[result:|\\\[result\\:)(?P(?:[^]\\]|\\.)+)(?:]|\\])\s*(?P.*?)\s*(?:\[/result]|\\\[\\/result\\])", re.DOTALL | re.IGNORECASE, ) TAGGED_ARG_RE = re.compile( - r"\\?\[CallParameter\\?:((?:[^]\\]|\\.)+)\\?]\s*(.*?)\s*\\?\[/CallParameter\\?]", + r"(?:\[call_parameter:|\\\[call\\_parameter\\:)(?P(?:[^]\\]|\\.)+)(?:]|\\])\s*(?P.*?)\s*(?:\[/call_parameter]|\\\[\\/call\\_parameter\\])", re.DOTALL | re.IGNORECASE, ) TAGGED_RESULT_RE = re.compile( - r"\\?\[ToolResult\\?]\s*(.*?)\s*\\?\[/ToolResult\\?]", + r"(?:\[tool_result]|\\\[tool\\_result\\])\s*(.*?)\s*(?:\[/tool_result]|\\\[\\/tool\\_result\\])", re.DOTALL | re.IGNORECASE, ) -CONTROL_TOKEN_RE = re.compile(r"\\?<\|im\\?_(?:start|end)\|\\?>", re.IGNORECASE) -CHATML_START_RE = re.compile(r"\\?<\|im\\?_start\|\\?>\s*(\w+)\s*\n?", re.IGNORECASE) -CHATML_END_RE = re.compile(r"\\?<\|im\\?_end\|\\?>", re.IGNORECASE) +CONTROL_TOKEN_RE = re.compile( + r"<\|im_(?:start|end)\|>|\\<\\\|im\\_(?:start|end)\\\|\\>", re.IGNORECASE +) +CHATML_START_RE = re.compile( + r"(?:<\|im_start\|>|\\<\\\|im\\_start\\\|\\>)\s*(\w+)\s*\n?", re.IGNORECASE +) +CHATML_END_RE = re.compile(r"<\|im_end\|>|\\<\\\|im\\_end\\\|\\>", re.IGNORECASE) COMMONMARK_UNESCAPE_RE = re.compile(r"\\([!\"#$%&'()*+,\-./:;<=>?@\[\\\]^_`{|}~])") FILE_PATH_PATTERN = re.compile( r"^(?=.*[./\\]|.*:\d+|^(?:Dockerfile|Makefile|Jenkinsfile|Procfile|Rakefile|Gemfile|Vagrantfile|Caddyfile|Justfile|LICENSE|README|CONTRIBUTING|CODEOWNERS|AUTHORS|NOTICE|CHANGELOG)$)([a-zA-Z0-9_./\\-]+(?::\d+)?)$", From fe30a5d47001c4956f9e64a3eda4cf4c7c4fc9db Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C6=B0u=20Quang=20V=C5=A9?= Date: Sat, 14 Feb 2026 00:51:42 +0700 Subject: [PATCH 130/291] Revert "Reattempt changing tool call tags to `snake_case`." This reverts commit 7e217e917778211ad002511d1323739ed2b0e293. --- app/server/chat.py | 50 ++++++++++++++++++------------------------ app/services/client.py | 20 ++++++++++------- app/utils/helper.py | 40 ++++++++++++++------------------- 3 files changed, 50 insertions(+), 60 deletions(-) diff --git a/app/server/chat.py b/app/server/chat.py index 1211c99..881f7c9 100644 --- a/app/server/chat.py +++ b/app/server/chat.py @@ -760,32 +760,28 @@ def __init__(self): self.STATE_MARKERS = { "TOOL": { - "starts": ["[tool_calls]", "\\[tool\\_calls\\]"], - "ends": ["[/tool_calls]", "\\[\\/tool\\_calls\\]"], + "starts": ["[ToolCalls]", "\\[ToolCalls\\]"], + "ends": ["[/ToolCalls]", "\\[/ToolCalls\\]"], }, "ORPHAN": { - "starts": ["[call:", "\\[call\\:"], - "ends": ["[/call]", "\\[\\/call\\]"], + "starts": ["[Call:", "\\[Call:", "\\[Call\\:"], + "ends": ["[/Call]", "\\[/Call\\]"], }, "RESP": { - "starts": ["[tool_results]", "\\[tool\\_results\\]"], - "ends": ["[/tool_results]", "\\[\\/tool\\_results\\]"], + "starts": ["[ToolResults]", "\\[ToolResults\\]"], + "ends": ["[/ToolResults]", "\\[/ToolResults\\]"], }, "ARG": { - "starts": ["[call_parameter:", "\\[call\\_parameter\\:"], - "ends": ["[/call_parameter]", "\\[\\/call\\_parameter\\]"], + "starts": ["[CallParameter:", "\\[CallParameter:", "\\[CallParameter\\:"], + "ends": ["[/CallParameter]", "\\[/CallParameter\\]"], }, "RESULT": { - "starts": ["[tool_result]", "\\[tool\\_result\\]"], - "ends": ["[/tool_result]", "\\[\\/tool\\_result\\]"], - }, - "ITEM": { - "starts": ["[result:", "\\[result\\:"], - "ends": ["[/result]", "\\[\\/result\\]"], + "starts": ["[ToolResult]", "\\[ToolResult\\]"], + "ends": ["[/ToolResult]", "\\[/ToolResult\\]"], }, "TAG": { - "starts": ["<|im_start|>", "\\<\\|im\\_start\\|\\>"], - "ends": ["<|im_end|>", "\\<\\|im\\_end\\|\\>"], + "starts": ["<|im_start|>", "\\<|im\\_start|\\>"], + "ends": ["<|im_end|>", "\\<|im\\_end|\\>"], }, } @@ -798,19 +794,15 @@ def __init__(self): self.ORPHAN_ENDS = [ "<|im_end|>", - "\\<\\|im\\_end\\|\\>", - "[/call]", - "\\[\\/call\\]", - "[/tool_calls]", - "\\[\\/tool\\_calls\\]", - "[/call_parameter]", - "\\[\\/call\\_parameter\\]", - "[/tool_result]", - "\\[\\/tool\\_result\\]", - "[/tool_results]", - "\\[\\/tool\\_results\\]", - "[/result]", - "\\[\\/result\\]", + "\\<|im\\_end|\\>", + "[/Call]", + "\\[/Call\\]", + "[/ToolCalls]", + "\\[/ToolCalls\\]", + "[/CallParameter]", + "\\[/CallParameter\\]", + "[/ToolResult]", + "\\[/ToolResult\\]", ] self.WATCH_MARKERS = [] diff --git a/app/services/client.py b/app/services/client.py index 05e7415..70dfce9 100644 --- a/app/services/client.py +++ b/app/services/client.py @@ -70,8 +70,8 @@ async def process_message( message: Message, tempdir: Path | None = None, tagged: bool = True, wrap_tool: bool = True ) -> tuple[str, list[Path | str]]: """ - Process a Message into Gemini API format using the snake_case technical protocol. - Extracts text, handles files, and appends tool_calls/tool_results blocks. + Process a Message into Gemini API format using the PascalCase technical protocol. + Extracts text, handles files, and appends ToolCalls/ToolResults blocks. """ files: list[Path | str] = [] text_fragments: list[str] = [] @@ -109,9 +109,11 @@ async def process_message( if message.role == "tool": tool_name = message.name or "unknown" combined_content = "\n".join(text_fragments).strip() - res_block = f"[result:{tool_name}]\n[tool_result]\n{combined_content}\n[/tool_result]\n[/result]" + res_block = ( + f"[Result:{tool_name}]\n[ToolResult]\n{combined_content}\n[/ToolResult]\n[/Result]" + ) if wrap_tool: - text_fragments = [f"[tool_results]\n{res_block}\n[/tool_results]"] + text_fragments = [f"[ToolResults]\n{res_block}\n[/ToolResults]"] else: text_fragments = [res_block] @@ -128,16 +130,18 @@ async def process_message( val_str = ( v if isinstance(v, str) else orjson.dumps(v).decode("utf-8") ) - formatted_params += f"[call_parameter:{k}]\n```\n{val_str}\n```\n[/call_parameter]\n" + formatted_params += ( + f"[CallParameter:{k}]\n```\n{val_str}\n```\n[/CallParameter]\n" + ) else: formatted_params += f"```\n{params_text}\n```\n" except orjson.JSONDecodeError: formatted_params += f"```\n{params_text}\n```\n" - tool_blocks.append(f"[call:{call.function.name}]\n{formatted_params}[/call]") + tool_blocks.append(f"[Call:{call.function.name}]\n{formatted_params}[/Call]") if tool_blocks: - tool_section = "[tool_calls]\n" + "\n".join(tool_blocks) + "\n[/tool_calls]" + tool_section = "[ToolCalls]\n" + "\n".join(tool_blocks) + "\n[/ToolCalls]" text_fragments.append(tool_section) model_input = "\n".join(fragment for fragment in text_fragments if fragment is not None) @@ -169,7 +173,7 @@ async def process_conversation( i += 1 combined_tool_content = "\n".join(tool_blocks) - wrapped_content = f"[tool_results]\n{combined_tool_content}\n[/tool_results]" + wrapped_content = f"[ToolResults]\n{combined_tool_content}\n[/ToolResults]" conversation.append(add_tag("tool", wrapped_content)) else: input_part, files_part = await GeminiClientWrapper.process_message( diff --git a/app/utils/helper.py b/app/utils/helper.py index a1292d3..82bb562 100644 --- a/app/utils/helper.py +++ b/app/utils/helper.py @@ -19,53 +19,47 @@ VALID_TAG_ROLES = {"user", "assistant", "system", "tool"} TOOL_WRAP_HINT = ( "\n\nSYSTEM INTERFACE: Tool calling protocol. You MUST follow these MANDATORY rules:\n\n" - "1. Respond ONLY with a single [tool_calls] block. NO conversational text, NO explanations, NO filler.\n" + "1. Respond ONLY with a single [ToolCalls] block. NO conversational text, NO explanations, NO filler.\n" "2. For ALL parameters, the value MUST be entirely enclosed in a single markdown code block (start/end with backticks) inside the tags. NO text allowed outside this block.\n" "3. Use a markdown fence longer than any backtick sequence in the value (e.g., use ```` if value has ```).\n\n" "EXACT SYNTAX TEMPLATE:\n" - "[tool_calls]\n" - "[call:tool_name]\n" - "[call_parameter:parameter_name]\n" + "[ToolCalls]\n" + "[Call:tool_name]\n" + "[CallParameter:parameter_name]\n" "```\n" "value\n" "```\n" - "[/call_parameter]\n" - "[/call]\n" - "[/tool_calls]\n\n" + "[/CallParameter]\n" + "[/Call]\n" + "[/ToolCalls]\n\n" "CRITICAL: Every tag MUST be opened and closed accurately.\n\n" - "Multiple tools: List them sequentially inside one [tool_calls] block. No tool: respond naturally, NEVER use protocol tags.\n" + "Multiple tools: List them sequentially inside one [ToolCalls] block. No tool: respond naturally, NEVER use protocol tags.\n" ) TOOL_BLOCK_RE = re.compile( - r"(?:\[tool_calls]|\\\[tool\\_calls\\])\s*(.*?)\s*(?:\[/tool_calls]|\\\[\\/tool\\_calls\\])", - re.DOTALL | re.IGNORECASE, + r"\\?\[ToolCalls\\?]\s*(.*?)\s*\\?\[/ToolCalls\\?]", re.DOTALL | re.IGNORECASE ) TOOL_CALL_RE = re.compile( - r"(?:\[call:|\\\[call\\:)(?P(?:[^]\\]|\\.)+)(?:]|\\])\s*(?P.*?)\s*(?:\[/call]|\\\[\\/call\\])", - re.DOTALL | re.IGNORECASE, + r"\\?\[Call\\?:((?:[^]\\]|\\.)+)\\?]\s*(.*?)\s*\\?\[/Call\\?]", re.DOTALL | re.IGNORECASE ) RESPONSE_BLOCK_RE = re.compile( - r"(?:\[tool_results]|\\\[tool\\_results\\])\s*(.*?)\s*(?:\[/tool_results]|\\\[\\/tool\\_results\\])", + r"\\?\[ToolResults\\?]\s*(.*?)\s*\\?\[/ToolResults\\?]", re.DOTALL | re.IGNORECASE, ) RESPONSE_ITEM_RE = re.compile( - r"(?:\[result:|\\\[result\\:)(?P(?:[^]\\]|\\.)+)(?:]|\\])\s*(?P.*?)\s*(?:\[/result]|\\\[\\/result\\])", + r"\\?\[Result\\?:((?:[^]\\]|\\.)+)\\?]\s*(.*?)\s*\\?\[/Result\\?]", re.DOTALL | re.IGNORECASE, ) TAGGED_ARG_RE = re.compile( - r"(?:\[call_parameter:|\\\[call\\_parameter\\:)(?P(?:[^]\\]|\\.)+)(?:]|\\])\s*(?P.*?)\s*(?:\[/call_parameter]|\\\[\\/call\\_parameter\\])", + r"\\?\[CallParameter\\?:((?:[^]\\]|\\.)+)\\?]\s*(.*?)\s*\\?\[/CallParameter\\?]", re.DOTALL | re.IGNORECASE, ) TAGGED_RESULT_RE = re.compile( - r"(?:\[tool_result]|\\\[tool\\_result\\])\s*(.*?)\s*(?:\[/tool_result]|\\\[\\/tool\\_result\\])", + r"\\?\[ToolResult\\?]\s*(.*?)\s*\\?\[/ToolResult\\?]", re.DOTALL | re.IGNORECASE, ) -CONTROL_TOKEN_RE = re.compile( - r"<\|im_(?:start|end)\|>|\\<\\\|im\\_(?:start|end)\\\|\\>", re.IGNORECASE -) -CHATML_START_RE = re.compile( - r"(?:<\|im_start\|>|\\<\\\|im\\_start\\\|\\>)\s*(\w+)\s*\n?", re.IGNORECASE -) -CHATML_END_RE = re.compile(r"<\|im_end\|>|\\<\\\|im\\_end\\\|\\>", re.IGNORECASE) +CONTROL_TOKEN_RE = re.compile(r"\\?<\|im\\?_(?:start|end)\|\\?>", re.IGNORECASE) +CHATML_START_RE = re.compile(r"\\?<\|im\\?_start\|\\?>\s*(\w+)\s*\n?", re.IGNORECASE) +CHATML_END_RE = re.compile(r"\\?<\|im\\?_end\|\\?>", re.IGNORECASE) COMMONMARK_UNESCAPE_RE = re.compile(r"\\([!\"#$%&'()*+,\-./:;<=>?@\[\\\]^_`{|}~])") FILE_PATH_PATTERN = re.compile( r"^(?=.*[./\\]|.*:\d+|^(?:Dockerfile|Makefile|Jenkinsfile|Procfile|Rakefile|Gemfile|Vagrantfile|Caddyfile|Justfile|LICENSE|README|CONTRIBUTING|CODEOWNERS|AUTHORS|NOTICE|CHANGELOG)$)([a-zA-Z0-9_./\\-]+(?::\d+)?)$", From 93e9ccdad4a55e1ed99cb2037f45aeba10e32226 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C6=B0u=20Quang=20V=C5=A9?= Date: Sat, 14 Feb 2026 01:18:56 +0700 Subject: [PATCH 131/291] Refactor: Handle all escape tags --- app/server/chat.py | 38 +++++++++------- app/services/lmdb.py | 105 ++++++++++++++++++++++--------------------- app/utils/helper.py | 35 ++++++++++----- 3 files changed, 101 insertions(+), 77 deletions(-) diff --git a/app/server/chat.py b/app/server/chat.py index 881f7c9..934091b 100644 --- a/app/server/chat.py +++ b/app/server/chat.py @@ -270,7 +270,7 @@ def _persist_conversation( tool_calls=tool_calls or None, ) full_history = [*messages, current_assistant_message] - cleaned_history = db.sanitize_assistant_messages(full_history) + cleaned_history = db.sanitize_messages(full_history) conv = ConversationInStore( model=model_name, @@ -761,27 +761,31 @@ def __init__(self): self.STATE_MARKERS = { "TOOL": { "starts": ["[ToolCalls]", "\\[ToolCalls\\]"], - "ends": ["[/ToolCalls]", "\\[/ToolCalls\\]"], + "ends": ["[/ToolCalls]", "\\[\\/ToolCalls\\]"], }, "ORPHAN": { - "starts": ["[Call:", "\\[Call:", "\\[Call\\:"], - "ends": ["[/Call]", "\\[/Call\\]"], + "starts": ["[Call:", "\\[Call\\:"], + "ends": ["[/Call]", "\\[\\/Call\\]"], }, "RESP": { "starts": ["[ToolResults]", "\\[ToolResults\\]"], - "ends": ["[/ToolResults]", "\\[/ToolResults\\]"], + "ends": ["[/ToolResults]", "\\[\\/ToolResults\\]"], }, "ARG": { - "starts": ["[CallParameter:", "\\[CallParameter:", "\\[CallParameter\\:"], - "ends": ["[/CallParameter]", "\\[/CallParameter\\]"], + "starts": ["[CallParameter:", "\\[CallParameter\\:"], + "ends": ["[/CallParameter]", "\\[\\/CallParameter\\]"], }, "RESULT": { "starts": ["[ToolResult]", "\\[ToolResult\\]"], - "ends": ["[/ToolResult]", "\\[/ToolResult\\]"], + "ends": ["[/ToolResult]", "\\[\\/ToolResult\\]"], + }, + "ITEM": { + "starts": ["[Result:", "\\[Result\\:"], + "ends": ["[/Result]", "\\[\\/Result\\]"], }, "TAG": { - "starts": ["<|im_start|>", "\\<|im\\_start|\\>"], - "ends": ["<|im_end|>", "\\<|im\\_end|\\>"], + "starts": ["<|im_start|>", "\\<\\|im\\_start\\|\\>"], + "ends": ["<|im_end|>", "\\<\\|im\\_end\\|\\>"], }, } @@ -794,15 +798,19 @@ def __init__(self): self.ORPHAN_ENDS = [ "<|im_end|>", - "\\<|im\\_end|\\>", + "\\<\\|im\\_end\\|\\>", "[/Call]", - "\\[/Call\\]", + "\\[\\/Call\\]", "[/ToolCalls]", - "\\[/ToolCalls\\]", + "\\[\\/ToolCalls\\]", "[/CallParameter]", - "\\[/CallParameter\\]", + "\\[\\/CallParameter\\]", "[/ToolResult]", - "\\[/ToolResult\\]", + "\\[\\/ToolResult\\]", + "[/ToolResults]", + "\\[\\/ToolResults\\]", + "[/Result]", + "\\[\\/Result\\]", ] self.WATCH_MARKERS = [] diff --git a/app/services/lmdb.py b/app/services/lmdb.py index ad92bbf..dd4197a 100644 --- a/app/services/lmdb.py +++ b/app/services/lmdb.py @@ -16,6 +16,8 @@ extract_tool_calls, normalize_llm_text, remove_tool_call_blocks, + strip_system_hints, + unescape_text, ) from ..utils.singleton import Singleton @@ -38,6 +40,7 @@ def _normalize_text(text: str | None, fuzzy: bool = False) -> str | None: return None text = normalize_llm_text(text) + text = unescape_text(text) text = LMDBConversationStore.remove_think_tags(text) text = remove_tool_call_blocks(text) @@ -589,63 +592,61 @@ def remove_think_tags(text: str) -> str: return cleaned_content.strip() @staticmethod - def sanitize_assistant_messages(messages: list[Message]) -> list[Message]: - """Clean assistant messages of internal markers and move tool calls to metadata.""" + def sanitize_messages(messages: list[Message]) -> list[Message]: + """Clean all messages of internal markers, hints and normalize tool calls.""" cleaned_messages = [] for msg in messages: - 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() or None - - if normalized_content != msg.content or tool_calls != msg.tool_calls: - cleaned_msg = msg.model_copy( + if isinstance(msg.content, str): + text = LMDBConversationStore.remove_think_tags(msg.content) + tool_calls = msg.tool_calls + + if msg.role == "assistant" and not tool_calls: + text, tool_calls = extract_tool_calls(text) + else: + text = strip_system_hints(text) + + normalized_content = text.strip() or None + + if normalized_content != msg.content or tool_calls != msg.tool_calls: + cleaned_msg = msg.model_copy( + update={ + "content": normalized_content, + "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 msg.role == "assistant" and not msg.tool_calls: + text, extracted = extract_tool_calls(text) + if extracted: + all_extracted_calls.extend(extracted) + changed = True + else: + text = strip_system_hints(text) + + 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": normalized_content, - "tool_calls": tool_calls or None, + "content": new_content, + "tool_calls": all_extracted_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/helper.py b/app/utils/helper.py index 82bb562..9c75b45 100644 --- a/app/utils/helper.py +++ b/app/utils/helper.py @@ -36,30 +36,36 @@ "Multiple tools: List them sequentially inside one [ToolCalls] block. No tool: respond naturally, NEVER use protocol tags.\n" ) TOOL_BLOCK_RE = re.compile( - r"\\?\[ToolCalls\\?]\s*(.*?)\s*\\?\[/ToolCalls\\?]", re.DOTALL | re.IGNORECASE + r"(?:\[ToolCalls]|\\\[ToolCalls\\])\s*(.*?)\s*(?:\[/ToolCalls]|\\\[\\/ToolCalls\\])", + re.DOTALL | re.IGNORECASE, ) TOOL_CALL_RE = re.compile( - r"\\?\[Call\\?:((?:[^]\\]|\\.)+)\\?]\s*(.*?)\s*\\?\[/Call\\?]", re.DOTALL | re.IGNORECASE + r"(?:\[Call:|\\\[Call\\:)(?P(?:[^]\\]|\\.)+)(?:]|\\])\s*(?P.*?)\s*(?:\[/Call]|\\\[\\/Call\\])", + re.DOTALL | re.IGNORECASE, ) RESPONSE_BLOCK_RE = re.compile( - r"\\?\[ToolResults\\?]\s*(.*?)\s*\\?\[/ToolResults\\?]", + r"(?:\[ToolResults]|\\\[ToolResults\\])\s*(.*?)\s*(?:\[/ToolResults]|\\\[\\/ToolResults\\])", re.DOTALL | re.IGNORECASE, ) RESPONSE_ITEM_RE = re.compile( - r"\\?\[Result\\?:((?:[^]\\]|\\.)+)\\?]\s*(.*?)\s*\\?\[/Result\\?]", + r"(?:\[Result:|\\\[Result\\:)(?P(?:[^]\\]|\\.)+)(?:]|\\])\s*(?P.*?)\s*(?:\[/Result]|\\\[\\/Result\\])", re.DOTALL | re.IGNORECASE, ) TAGGED_ARG_RE = re.compile( - r"\\?\[CallParameter\\?:((?:[^]\\]|\\.)+)\\?]\s*(.*?)\s*\\?\[/CallParameter\\?]", + r"(?:\[CallParameter:|\\\[CallParameter\\:)(?P(?:[^]\\]|\\.)+)(?:]|\\])\s*(?P.*?)\s*(?:\[/CallParameter]|\\\[\\/CallParameter\\])", re.DOTALL | re.IGNORECASE, ) TAGGED_RESULT_RE = re.compile( - r"\\?\[ToolResult\\?]\s*(.*?)\s*\\?\[/ToolResult\\?]", + r"(?:\[ToolResult]|\\\[ToolResult\\])\s*(.*?)\s*(?:\[/ToolResult]|\\\[\\/ToolResult\\])", re.DOTALL | re.IGNORECASE, ) -CONTROL_TOKEN_RE = re.compile(r"\\?<\|im\\?_(?:start|end)\|\\?>", re.IGNORECASE) -CHATML_START_RE = re.compile(r"\\?<\|im\\?_start\|\\?>\s*(\w+)\s*\n?", re.IGNORECASE) -CHATML_END_RE = re.compile(r"\\?<\|im\\?_end\|\\?>", re.IGNORECASE) +CONTROL_TOKEN_RE = re.compile( + r"<\|im_(?:start|end)\|>|\\<\\\|im\\_(?:start|end)\\\|\\>", re.IGNORECASE +) +CHATML_START_RE = re.compile( + r"(?:<\|im_start\|>|\\<\\\|im\\_start\\\|\\>)\s*(\w+)\s*\n?", re.IGNORECASE +) +CHATML_END_RE = re.compile(r"<\|im_end\|>|\\<\\\|im\\_end\\\|\\>", re.IGNORECASE) COMMONMARK_UNESCAPE_RE = re.compile(r"\\([!\"#$%&'()*+,\-./:;<=>?@\[\\\]^_`{|}~])") FILE_PATH_PATTERN = re.compile( r"^(?=.*[./\\]|.*:\d+|^(?:Dockerfile|Makefile|Jenkinsfile|Procfile|Rakefile|Gemfile|Vagrantfile|Caddyfile|Justfile|LICENSE|README|CONTRIBUTING|CODEOWNERS|AUTHORS|NOTICE|CHANGELOG)$)([a-zA-Z0-9_./\\-]+(?::\d+)?)$", @@ -103,6 +109,13 @@ def normalize_llm_text(s: str) -> str: return s +def unescape_text(s: str) -> str: + """Remove CommonMark backslash escapes.""" + if not s: + return "" + return COMMONMARK_UNESCAPE_RE.sub(r"\1", s) + + def _strip_google_search(match: re.Match) -> str: """Extract raw text from Google Search links if it looks like a file path.""" text_to_check = match.group("text") if match.group("text") else unquote(match.group("query")) @@ -231,7 +244,9 @@ def strip_system_hints(text: str) -> str: if not text: return text - cleaned = text.replace(TOOL_WRAP_HINT, "").replace(TOOL_HINT_STRIPPED, "") + t_unescaped = unescape_text(text) + + cleaned = t_unescaped.replace(TOOL_WRAP_HINT, "").replace(TOOL_HINT_STRIPPED, "") if TOOL_HINT_LINE_START and TOOL_HINT_LINE_END: pattern = rf"\n?{re.escape(TOOL_HINT_LINE_START)}.*?{re.escape(TOOL_HINT_LINE_END)}\.?\n?" From 30f61257a951b19fa0492fce6efa3421a2b990cb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C6=B0u=20Quang=20V=C5=A9?= Date: Sat, 14 Feb 2026 01:39:11 +0700 Subject: [PATCH 132/291] Refactor: Handle all escape tags --- app/services/lmdb.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/services/lmdb.py b/app/services/lmdb.py index dd4197a..abf8859 100644 --- a/app/services/lmdb.py +++ b/app/services/lmdb.py @@ -279,7 +279,7 @@ def store( raise ValueError("Messages list cannot be empty") # Ensure consistent sanitization before hashing and storage - sanitized_messages = self.sanitize_assistant_messages(conv.messages) + sanitized_messages = self.sanitize_messages(conv.messages) conv.messages = sanitized_messages message_hash = _hash_conversation(conv.client_id, conv.model, conv.messages) @@ -359,7 +359,7 @@ def find(self, model: str, messages: List[Message]) -> Optional[ConversationInSt logger.debug(f"Session found for '{model}' with {len(messages)} raw messages.") return conv - cleaned_messages = self.sanitize_assistant_messages(messages) + cleaned_messages = self.sanitize_messages(messages) if cleaned_messages != messages: if conv := self._find_by_message_list(model, cleaned_messages): logger.debug( From a35525234ef206422570b168934d5e7c53a7a848 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C6=B0u=20Quang=20V=C5=A9?= Date: Sat, 14 Feb 2026 09:12:24 +0700 Subject: [PATCH 133/291] Refactor: Remove `_strip_google_search` as it's no longer needed --- app/utils/helper.py | 44 ++++---------------------------------------- 1 file changed, 4 insertions(+), 40 deletions(-) diff --git a/app/utils/helper.py b/app/utils/helper.py index 9c75b45..f6a3e77 100644 --- a/app/utils/helper.py +++ b/app/utils/helper.py @@ -8,7 +8,7 @@ import tempfile import unicodedata from pathlib import Path -from urllib.parse import unquote, urlparse +from urllib.parse import urlparse import httpx import orjson @@ -67,18 +67,6 @@ ) CHATML_END_RE = re.compile(r"<\|im_end\|>|\\<\\\|im\\_end\\\|\\>", re.IGNORECASE) COMMONMARK_UNESCAPE_RE = re.compile(r"\\([!\"#$%&'()*+,\-./:;<=>?@\[\\\]^_`{|}~])") -FILE_PATH_PATTERN = re.compile( - r"^(?=.*[./\\]|.*:\d+|^(?:Dockerfile|Makefile|Jenkinsfile|Procfile|Rakefile|Gemfile|Vagrantfile|Caddyfile|Justfile|LICENSE|README|CONTRIBUTING|CODEOWNERS|AUTHORS|NOTICE|CHANGELOG)$)([a-zA-Z0-9_./\\-]+(?::\d+)?)$", - re.IGNORECASE, -) -GOOGLE_SEARCH_PATTERN = re.compile( - r"(?P`?\[`?)?" - r"(?P[^]]+)?" - r"(?(md_start)`?]\()?" - r"https://www\.google\.com/search\?q=(?P[^&\s\"'<>)]+)" - r"(?(md_start)\)?`?)", - re.IGNORECASE, -) TOOL_HINT_STRIPPED = TOOL_WRAP_HINT.strip() _hint_lines = [line.strip() for line in TOOL_WRAP_HINT.split("\n") if line.strip()] TOOL_HINT_LINE_START = _hint_lines[0] if _hint_lines else "" @@ -110,22 +98,12 @@ def normalize_llm_text(s: str) -> str: def unescape_text(s: str) -> str: - """Remove CommonMark backslash escapes.""" + """Remove CommonMark backslash escapes from LLM-generated text.""" if not s: return "" return COMMONMARK_UNESCAPE_RE.sub(r"\1", s) -def _strip_google_search(match: re.Match) -> str: - """Extract raw text from Google Search links if it looks like a file path.""" - text_to_check = match.group("text") if match.group("text") else unquote(match.group("query")) - text_to_check = unquote(text_to_check.strip()) - - if FILE_PATH_PATTERN.match(text_to_check): - return text_to_check - return match.group(0) - - def _strip_param_fences(s: str) -> str: """ Remove one layer of outermost Markdown code fences, @@ -147,20 +125,6 @@ def _strip_param_fences(s: str) -> str: return s[n:-n].strip() -def _repair_param_value(s: str) -> str: - """ - Standardize and repair LLM-generated text fragments (unescaping, link normalization) - to ensure compatibility with specialized clients like Roo Code. - """ - if not s: - return "" - - s = COMMONMARK_UNESCAPE_RE.sub(r"\1", s) - s = GOOGLE_SEARCH_PATTERN.sub(_strip_google_search, s) - - return s - - def estimate_tokens(text: str | None) -> int: """Estimate the number of tokens heuristically based on character count.""" if not text: @@ -286,8 +250,8 @@ def _create_tool_call(name: str, raw_args: str) -> None: logger.warning("Encountered tool_call without a function name.") return - name = _repair_param_value(name.strip()) - raw_args = _repair_param_value(raw_args) + name = unescape_text(name.strip()) + raw_args = unescape_text(raw_args) arg_matches = TAGGED_ARG_RE.findall(raw_args) if arg_matches: From 45af127004adcec8a48c9f9432f0c0b54a581724 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C6=B0u=20Quang=20V=C5=A9?= Date: Sat, 14 Feb 2026 12:30:29 +0700 Subject: [PATCH 134/291] Update `TOOL_WRAP_HINT` to ensure Gemini strictly follows the instructions. --- app/utils/helper.py | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/app/utils/helper.py b/app/utils/helper.py index f6a3e77..64df4f7 100644 --- a/app/utils/helper.py +++ b/app/utils/helper.py @@ -18,11 +18,12 @@ VALID_TAG_ROLES = {"user", "assistant", "system", "tool"} TOOL_WRAP_HINT = ( - "\n\nSYSTEM INTERFACE: Tool calling protocol. You MUST follow these MANDATORY rules:\n\n" - "1. Respond ONLY with a single [ToolCalls] block. NO conversational text, NO explanations, NO filler.\n" - "2. For ALL parameters, the value MUST be entirely enclosed in a single markdown code block (start/end with backticks) inside the tags. NO text allowed outside this block.\n" - "3. Use a markdown fence longer than any backtick sequence in the value (e.g., use ```` if value has ```).\n\n" - "EXACT SYNTAX TEMPLATE:\n" + "\n\n### SYSTEM: TOOL CALLING PROTOCOL (MANDATORY) ###\n" + "If tool execution is required, you MUST adhere to this EXACT protocol. No exceptions.\n\n" + "1. OUTPUT RESTRICTION: Your response MUST contain ONLY the [ToolCalls] block. Conversational filler, preambles, or concluding remarks are STRICTLY PROHIBITED.\n" + "2. WRAPPING LOGIC: Every parameter value MUST be enclosed in a markdown code block. Use 3 backticks (```) by default. If the value contains backticks, the outer fence MUST be longer than any sequence inside (e.g., ````).\n" + "3. TAG SYMMETRY: All tags MUST be balanced and closed in the exact reverse order of opening. Incomplete or unclosed blocks are strictly prohibited.\n\n" + "REQUIRED SYNTAX:\n" "[ToolCalls]\n" "[Call:tool_name]\n" "[CallParameter:parameter_name]\n" @@ -32,8 +33,7 @@ "[/CallParameter]\n" "[/Call]\n" "[/ToolCalls]\n\n" - "CRITICAL: Every tag MUST be opened and closed accurately.\n\n" - "Multiple tools: List them sequentially inside one [ToolCalls] block. No tool: respond naturally, NEVER use protocol tags.\n" + "CRITICAL: Do NOT mix natural language with protocol tags. Either respond naturally OR provide the protocol block alone. There is no middle ground.\n" ) TOOL_BLOCK_RE = re.compile( r"(?:\[ToolCalls]|\\\[ToolCalls\\])\s*(.*?)\s*(?:\[/ToolCalls]|\\\[\\/ToolCalls\\])", From f144e1440f6812ae330438b316ad1e08eb052240 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C6=B0u=20Quang=20V=C5=A9?= Date: Sat, 14 Feb 2026 12:32:26 +0700 Subject: [PATCH 135/291] Update required dependencies --- pyproject.toml | 2 +- uv.lock | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 93dabab..0cae786 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,7 +6,7 @@ readme = "README.md" requires-python = "==3.12.*" dependencies = [ "fastapi>=0.129.0", - "gemini-webapi>=1.19.1", + "gemini-webapi>=1.19.2", "lmdb>=1.7.5", "loguru>=0.7.3", "orjson>=3.11.7", diff --git a/uv.lock b/uv.lock index 249e84b..5b687e4 100644 --- a/uv.lock +++ b/uv.lock @@ -107,7 +107,7 @@ dev = [ [package.metadata] requires-dist = [ { name = "fastapi", specifier = ">=0.129.0" }, - { name = "gemini-webapi", specifier = ">=1.19.1" }, + { name = "gemini-webapi", specifier = ">=1.19.2" }, { name = "lmdb", specifier = ">=1.7.5" }, { name = "loguru", specifier = ">=0.7.3" }, { name = "orjson", specifier = ">=3.11.7" }, @@ -123,7 +123,7 @@ dev = [{ name = "ruff", specifier = ">=0.15.1" }] [[package]] name = "gemini-webapi" -version = "1.19.1" +version = "1.19.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "httpx", extra = ["http2"] }, @@ -131,9 +131,9 @@ dependencies = [ { name = "orjson" }, { name = "pydantic" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/e5/d1/c61ee05ca6e20c70caa25a3f0f12e2a810bbc6b39e588ff937821de43690/gemini_webapi-1.19.1.tar.gz", hash = "sha256:a52afdfc2d9f6e87a6ae8cd926fb2ce5c562a0a99dc75ce97d8d50ffc2a3e133", size = 266761, upload-time = "2026-02-10T05:44:29.195Z" } +sdist = { url = "https://files.pythonhosted.org/packages/d4/d3/b4ff659bfb0fff378b16f934429d53b7f78451ac184406ab2f9ddda9357e/gemini_webapi-1.19.2.tar.gz", hash = "sha256:f6e96e28f3f1e78be6176fbb8b2eca25ad509aec6cfacf99c415559f27691b71", size = 266805, upload-time = "2026-02-14T05:26:04.103Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/4a/0b/7a73919ee8621f6559ae679a20d754959b989a3f09cf20478d89971f40b4/gemini_webapi-1.19.1-py3-none-any.whl", hash = "sha256:0dc4c7daa58d281722d52d6acf520f2e850c6c3c6020080fdbc5f77736c8be9a", size = 63500, upload-time = "2026-02-10T05:44:27.692Z" }, + { url = "https://files.pythonhosted.org/packages/2d/e7/a676f721980e3daa43e05abe94884a84648efdc6203889e7a0f5c8ca2e98/gemini_webapi-1.19.2-py3-none-any.whl", hash = "sha256:fdc088ca35361301f40ea807a58c4bec18886b17a54164a1a8f3d639eadc6a66", size = 63524, upload-time = "2026-02-14T05:26:02.173Z" }, ] [[package]] From 9d014b093512b72d022b57133c3f31224c8f271e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C6=B0u=20Quang=20V=C5=A9?= Date: Wed, 18 Feb 2026 14:29:41 +0700 Subject: [PATCH 136/291] Ignore github directory --- .github/workflows/docker.yaml | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/.github/workflows/docker.yaml b/.github/workflows/docker.yaml index 7231e08..995b60c 100644 --- a/.github/workflows/docker.yaml +++ b/.github/workflows/docker.yaml @@ -8,8 +8,7 @@ on: - "v*" paths-ignore: - "**/*.md" - - ".github/workflows/ruff.yaml" - - ".github/workflows/track.yml" + - ".github/*" env: REGISTRY: ghcr.io From 3fab502ab495868f20c725e3657b11829895204c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C6=B0u=20Quang=20V=C5=A9?= Date: Wed, 18 Feb 2026 16:41:55 +0700 Subject: [PATCH 137/291] Upgrade to fully support Python 3.13 --- .github/workflows/docker.yaml | 6 + .github/workflows/ruff.yaml | 4 +- Dockerfile | 18 ++- README.md | 4 +- README.zh.md | 4 +- app/main.py | 2 +- app/models/__init__.py | 58 +++++++++- app/models/models.py | 150 ++++++++++++------------- app/server/chat.py | 54 ++++----- app/server/health.py | 4 +- app/server/images.py | 2 +- app/server/middleware.py | 2 +- app/services/client.py | 11 +- app/services/lmdb.py | 55 ++++----- app/services/pool.py | 18 +-- app/utils/config.py | 12 +- app/utils/helper.py | 7 +- app/utils/singleton.py | 6 +- pyproject.toml | 50 +++++++-- scripts/dump_lmdb.py | 16 ++- uv.lock | 203 ++++++++++++++++++++++------------ 21 files changed, 427 insertions(+), 259 deletions(-) diff --git a/.github/workflows/docker.yaml b/.github/workflows/docker.yaml index 995b60c..775e9e4 100644 --- a/.github/workflows/docker.yaml +++ b/.github/workflows/docker.yaml @@ -9,6 +9,8 @@ on: paths-ignore: - "**/*.md" - ".github/*" + - "LICENSE" + - ".gitignore" env: REGISTRY: ghcr.io @@ -25,6 +27,9 @@ jobs: - name: Checkout repository uses: actions/checkout@v6 + - name: Set up QEMU + uses: docker/setup-qemu-action@v3 + - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 @@ -45,6 +50,7 @@ jobs: type=semver,pattern={{version}} type=semver,pattern={{major}}.{{minor}} type=semver,pattern={{major}} + type=sha,format=short type=raw,value=latest,enable={{is_default_branch}} - name: Build and push Docker image diff --git a/.github/workflows/ruff.yaml b/.github/workflows/ruff.yaml index 6b9e536..d451cdc 100644 --- a/.github/workflows/ruff.yaml +++ b/.github/workflows/ruff.yaml @@ -19,12 +19,12 @@ jobs: - name: Set up Python uses: actions/setup-python@v6 with: - python-version: "3.12" + python-version: "3.13" - name: Install Ruff run: | python -m pip install --upgrade pip - pip install "ruff>=0.11.7" + pip install "ruff>=0.15.1" - name: Run Ruff run: ruff check . diff --git a/Dockerfile b/Dockerfile index 938bc2f..ef7f41e 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,16 +1,26 @@ -FROM ghcr.io/astral-sh/uv:python3.12-bookworm-slim +FROM ghcr.io/astral-sh/uv:python3.13-trixie-slim LABEL org.opencontainers.image.description="Web-based Gemini models wrapped into an OpenAI-compatible API." WORKDIR /app -# Install dependencies +RUN apt-get update && apt-get install -y --no-install-recommends \ + tini \ + && rm -rf /var/lib/apt/lists/* + +ENV UV_COMPILE_BYTECODE=1 \ + PYTHONUNBUFFERED=1 \ + PYTHONDONTWRITEBYTECODE=1 + COPY pyproject.toml uv.lock ./ -RUN uv sync --no-cache --no-dev +RUN uv sync --no-cache --frozen --no-install-project --no-dev COPY app/ app/ COPY config/ config/ COPY run.py . -# Command to run the application +EXPOSE 8000 + +ENTRYPOINT ["/usr/bin/tini", "--"] + CMD ["uv", "run", "--no-dev", "run.py"] diff --git a/README.md b/README.md index 330e9c8..91f687c 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Gemini-FastAPI -[![Python 3.12](https://img.shields.io/badge/python-3.12+-blue.svg)](https://www.python.org/downloads/) +[![Python 3.13](https://img.shields.io/badge/python-3.13+-blue.svg)](https://www.python.org/downloads/) [![FastAPI](https://img.shields.io/badge/FastAPI-0.115+-green.svg)](https://fastapi.tiangolo.com/) [![License](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE) @@ -24,7 +24,7 @@ Web-based Gemini models wrapped into an OpenAI-compatible API. Powered by [Hanao ### Prerequisites -- Python 3.12 +- Python 3.13 - Google account with Gemini access on web - `secure_1psid` and `secure_1psidts` cookies from Gemini web interface diff --git a/README.zh.md b/README.zh.md index 2f9e1b5..d23bec1 100644 --- a/README.zh.md +++ b/README.zh.md @@ -1,6 +1,6 @@ # Gemini-FastAPI -[![Python 3.12](https://img.shields.io/badge/python-3.12+-blue.svg)](https://www.python.org/downloads/) +[![Python 3.13](https://img.shields.io/badge/python-3.13+-blue.svg)](https://www.python.org/downloads/) [![FastAPI](https://img.shields.io/badge/FastAPI-0.115+-green.svg)](https://fastapi.tiangolo.com/) [![License](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE) @@ -24,7 +24,7 @@ ### 前置条件 -- Python 3.12 +- Python 3.13 - 拥有网页版 Gemini 访问权限的 Google 账号 - 从 Gemini 网页获取的 `secure_1psid` 和 `secure_1psidts` Cookie diff --git a/app/main.py b/app/main.py index f4e6711..0634ce2 100644 --- a/app/main.py +++ b/app/main.py @@ -43,7 +43,7 @@ async def _run_retention_cleanup(stop_event: asyncio.Event) -> None: stop_event.wait(), timeout=RETENTION_CLEANUP_INTERVAL_SECONDS, ) - except asyncio.TimeoutError: + except TimeoutError: continue logger.info("LMDB retention cleanup task stopped.") diff --git a/app/models/__init__.py b/app/models/__init__.py index c6a3640..a72efdc 100644 --- a/app/models/__init__.py +++ b/app/models/__init__.py @@ -1 +1,57 @@ -from .models import * # noqa: F403 +from .models import ( + ChatCompletionRequest, + ChatCompletionResponse, + Choice, + ContentItem, + ConversationInStore, + FunctionCall, + HealthCheckResponse, + Message, + ModelData, + ModelListResponse, + ResponseCreateRequest, + ResponseCreateResponse, + ResponseImageGenerationCall, + ResponseImageTool, + ResponseInputContent, + ResponseInputItem, + ResponseOutputContent, + ResponseOutputMessage, + ResponseToolCall, + ResponseToolChoice, + Tool, + ToolCall, + ToolChoiceFunction, + ToolChoiceFunctionDetail, + ToolFunctionDefinition, + Usage, +) + +__all__ = [ + "ChatCompletionRequest", + "ChatCompletionResponse", + "Choice", + "ContentItem", + "ConversationInStore", + "FunctionCall", + "HealthCheckResponse", + "Message", + "ModelData", + "ModelListResponse", + "ResponseCreateRequest", + "ResponseCreateResponse", + "ResponseImageGenerationCall", + "ResponseImageTool", + "ResponseInputContent", + "ResponseInputItem", + "ResponseOutputContent", + "ResponseOutputMessage", + "ResponseToolCall", + "ResponseToolChoice", + "Tool", + "ToolCall", + "ToolChoiceFunction", + "ToolChoiceFunctionDetail", + "ToolFunctionDefinition", + "Usage", +] diff --git a/app/models/models.py b/app/models/models.py index 64ceaa9..ca206b7 100644 --- a/app/models/models.py +++ b/app/models/models.py @@ -1,7 +1,7 @@ from __future__ import annotations from datetime import datetime -from typing import Any, Dict, List, Literal, Optional, Union +from typing import Any, Literal from pydantic import BaseModel, Field, model_validator @@ -10,28 +10,28 @@ class ContentItem(BaseModel): """Individual content item (text, image, or file) within a message.""" type: Literal["text", "image_url", "file", "input_audio"] - text: Optional[str] = None - image_url: Optional[Dict[str, str]] = None - input_audio: Optional[Dict[str, Any]] = None - file: Optional[Dict[str, str]] = None - annotations: List[Dict[str, Any]] = Field(default_factory=list) + text: str | None = None + image_url: dict[str, str] | None = None + input_audio: dict[str, Any] | None = None + file: dict[str, str] | None = None + annotations: list[dict[str, Any]] = Field(default_factory=list) class Message(BaseModel): """Message model""" role: str - 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) + content: str | list[ContentItem] | None = None + name: str | None = None + tool_calls: list[ToolCall] | None = None + tool_call_id: str | None = None + refusal: str | None = None + reasoning_content: str | None = None + audio: dict[str, Any] | None = None + annotations: list[dict[str, Any]] = Field(default_factory=list) @model_validator(mode="after") - def normalize_role(self) -> "Message": + def normalize_role(self) -> Message: """Normalize 'developer' role to 'system' for Gemini compatibility.""" if self.role == "developer": self.role = "system" @@ -44,7 +44,7 @@ class Choice(BaseModel): index: int message: Message finish_reason: str - logprobs: Optional[Dict[str, Any]] = None + logprobs: dict[str, Any] | None = None class FunctionCall(BaseModel): @@ -66,8 +66,8 @@ class ToolFunctionDefinition(BaseModel): """Function definition for tool.""" name: str - description: Optional[str] = None - parameters: Optional[Dict[str, Any]] = None + description: str | None = None + parameters: dict[str, Any] | None = None class Tool(BaseModel): @@ -96,8 +96,8 @@ class Usage(BaseModel): prompt_tokens: int completion_tokens: int total_tokens: int - prompt_tokens_details: Optional[Dict[str, int]] = None - completion_tokens_details: Optional[Dict[str, int]] = None + prompt_tokens_details: dict[str, int] | None = None + completion_tokens_details: dict[str, int] | None = None class ModelData(BaseModel): @@ -113,17 +113,17 @@ class ChatCompletionRequest(BaseModel): """Chat completion request model""" model: str - messages: List[Message] - stream: Optional[bool] = False - user: Optional[str] = None - temperature: Optional[float] = 0.7 - top_p: Optional[float] = 1.0 - max_tokens: Optional[int] = None - tools: Optional[List["Tool"]] = None - tool_choice: Optional[ - Union[Literal["none"], Literal["auto"], Literal["required"], "ToolChoiceFunction"] - ] = None - response_format: Optional[Dict[str, Any]] = None + messages: list[Message] + stream: bool | None = False + user: str | None = None + temperature: float | None = 0.7 + top_p: float | None = 1.0 + max_tokens: int | None = None + tools: list[Tool] | None = None + tool_choice: ( + Literal["none"] | Literal["auto"] | Literal["required"] | ToolChoiceFunction | None + ) = None + response_format: dict[str, Any] | None = None class ChatCompletionResponse(BaseModel): @@ -133,7 +133,7 @@ class ChatCompletionResponse(BaseModel): object: str = "chat.completion" created: int model: str - choices: List[Choice] + choices: list[Choice] usage: Usage @@ -141,23 +141,23 @@ class ModelListResponse(BaseModel): """Model list model""" object: str = "list" - data: List[ModelData] + data: list[ModelData] class HealthCheckResponse(BaseModel): """Health check response model""" ok: bool - storage: Optional[Dict[str, str | int]] = None - clients: Optional[Dict[str, bool]] = None - error: Optional[str] = None + storage: dict[str, str | int] | None = None + clients: dict[str, bool] | None = None + error: str | None = None class ConversationInStore(BaseModel): """Conversation model for storing in the database.""" - created_at: Optional[datetime] = Field(default=None) - updated_at: Optional[datetime] = Field(default=None) + created_at: datetime | None = Field(default=None) + updated_at: datetime | None = Field(default=None) # Gemini Web API does not support changing models once a conversation is created. model: str = Field(..., description="Model used for the conversation") @@ -172,13 +172,13 @@ class ResponseInputContent(BaseModel): """Content item for Responses API input.""" type: Literal["input_text", "input_image", "input_file"] - text: Optional[str] = None - image_url: Optional[str] = None - detail: Optional[Literal["auto", "low", "high"]] = None - file_url: Optional[str] = None - file_data: Optional[str] = None - filename: Optional[str] = None - annotations: List[Dict[str, Any]] = Field(default_factory=list) + text: str | None = None + image_url: str | None = None + detail: Literal["auto", "low", "high"] | None = None + file_url: str | None = None + file_data: str | None = None + filename: str | None = None + annotations: list[dict[str, Any]] = Field(default_factory=list) @model_validator(mode="before") @classmethod @@ -192,42 +192,42 @@ def normalize_output_text(cls, data: Any) -> Any: class ResponseInputItem(BaseModel): """Single input item for Responses API.""" - type: Optional[Literal["message"]] = "message" + type: Literal["message"] | None = "message" role: Literal["user", "assistant", "system", "developer"] - content: Union[str, List[ResponseInputContent]] + content: str | list[ResponseInputContent] class ResponseToolChoice(BaseModel): """Tool choice enforcing a specific tool in Responses API.""" type: Literal["function", "image_generation"] - function: Optional[ToolChoiceFunctionDetail] = None + function: ToolChoiceFunctionDetail | None = None class ResponseImageTool(BaseModel): """Image generation tool specification for Responses API.""" type: Literal["image_generation"] - model: Optional[str] = None - output_format: Optional[str] = None + model: str | None = None + output_format: str | None = None class ResponseCreateRequest(BaseModel): """Responses API request payload.""" model: str - input: Union[str, List[ResponseInputItem]] - instructions: Optional[Union[str, List[ResponseInputItem]]] = None - temperature: Optional[float] = 0.7 - top_p: Optional[float] = 1.0 - max_output_tokens: Optional[int] = None - stream: Optional[bool] = False - tool_choice: Optional[Union[str, ResponseToolChoice]] = None - tools: Optional[List[Union[Tool, ResponseImageTool]]] = None - store: Optional[bool] = None - user: Optional[str] = None - response_format: Optional[Dict[str, Any]] = None - metadata: Optional[Dict[str, Any]] = None + input: str | list[ResponseInputItem] + instructions: str | list[ResponseInputItem] | None = None + temperature: float | None = 0.7 + top_p: float | None = 1.0 + max_output_tokens: int | None = None + stream: bool | None = False + tool_choice: str | ResponseToolChoice | None = None + tools: list[Tool | ResponseImageTool] | None = None + store: bool | None = None + user: str | None = None + response_format: dict[str, Any] | None = None + metadata: dict[str, Any] | None = None class ResponseUsage(BaseModel): @@ -242,8 +242,8 @@ class ResponseOutputContent(BaseModel): """Content item for Responses API output.""" type: Literal["output_text"] - text: Optional[str] = "" - annotations: List[Dict[str, Any]] = Field(default_factory=list) + text: str | None = "" + annotations: list[dict[str, Any]] = Field(default_factory=list) class ResponseOutputMessage(BaseModel): @@ -252,7 +252,7 @@ class ResponseOutputMessage(BaseModel): id: str type: Literal["message"] role: Literal["assistant"] - content: List[ResponseOutputContent] + content: list[ResponseOutputContent] class ResponseImageGenerationCall(BaseModel): @@ -261,10 +261,10 @@ class ResponseImageGenerationCall(BaseModel): id: str type: Literal["image_generation_call"] = "image_generation_call" status: Literal["completed", "in_progress", "generating", "failed"] = "completed" - result: Optional[str] = None - output_format: Optional[str] = None - size: Optional[str] = None - revised_prompt: Optional[str] = None + result: str | None = None + output_format: str | None = None + size: str | None = None + revised_prompt: str | None = None class ResponseToolCall(BaseModel): @@ -283,7 +283,7 @@ class ResponseCreateResponse(BaseModel): object: Literal["response"] = "response" created_at: int model: str - output: List[Union[ResponseOutputMessage, ResponseImageGenerationCall, ResponseToolCall]] + output: list[ResponseOutputMessage | ResponseImageGenerationCall | ResponseToolCall] status: Literal[ "in_progress", "completed", @@ -292,12 +292,12 @@ class ResponseCreateResponse(BaseModel): "cancelled", "requires_action", ] = "completed" - tool_choice: Optional[Union[str, ResponseToolChoice]] = None - tools: Optional[List[Union[Tool, ResponseImageTool]]] = None + tool_choice: str | ResponseToolChoice | None = None + tools: list[Tool | ResponseImageTool] | None = None usage: ResponseUsage - error: Optional[Dict[str, Any]] = None - metadata: Optional[Dict[str, Any]] = None - input: Optional[Union[str, List[ResponseInputItem]]] = None + error: dict[str, Any] | None = None + metadata: dict[str, Any] | None = None + input: str | list[ResponseInputItem] | None = None # Rebuild models with forward references diff --git a/app/server/chat.py b/app/server/chat.py index 934091b..3849af5 100644 --- a/app/server/chat.py +++ b/app/server/chat.py @@ -3,10 +3,11 @@ import io import reprlib import uuid +from collections.abc import AsyncGenerator from dataclasses import dataclass -from datetime import datetime, timezone +from datetime import UTC, datetime from pathlib import Path -from typing import Any, AsyncGenerator +from typing import Any import orjson from fastapi import APIRouter, Depends, HTTPException, Request, status @@ -17,7 +18,7 @@ from gemini_webapi.types.image import GeneratedImage, Image from loguru import logger -from ..models import ( +from app.models import ( ChatCompletionRequest, ContentItem, ConversationInStore, @@ -38,9 +39,15 @@ Tool, ToolChoiceFunction, ) -from ..services import GeminiClientPool, GeminiClientWrapper, LMDBConversationStore -from ..utils import g_config -from ..utils.helper import ( +from app.server.middleware import ( + get_image_store_dir, + get_image_token, + get_temp_dir, + verify_api_key, +) +from app.services import GeminiClientPool, GeminiClientWrapper, LMDBConversationStore +from app.utils import g_config +from app.utils.helper import ( TOOL_HINT_LINE_END, TOOL_HINT_LINE_START, TOOL_HINT_STRIPPED, @@ -53,7 +60,6 @@ strip_system_hints, text_from_message, ) -from .middleware import get_image_store_dir, get_image_token, get_temp_dir, verify_api_key MAX_CHARS_PER_REQUEST = int(g_config.gemini.max_chars_per_request * 0.9) METADATA_TTL_MINUTES = 15 @@ -98,11 +104,7 @@ async def _image_to_base64( if not suffix: detected_ext = detect_image_extension(data) - if detected_ext: - suffix = detected_ext - else: - # Fallback if detection fails - suffix = ".png" if isinstance(image, GeneratedImage) else ".jpg" + suffix = detected_ext or (".png" if isinstance(image, GeneratedImage) else ".jpg") random_name = f"img_{uuid.uuid4().hex}{suffix}" new_path = temp_dir / random_name @@ -628,7 +630,7 @@ def _get_model_by_name(name: str) -> Model: def _get_available_models() -> list[ModelData]: """Return a list of available models based on configuration strategy.""" - now = int(datetime.now(tz=timezone.utc).timestamp()) + now = int(datetime.now(tz=UTC).timestamp()) strategy = g_config.gemini.model_strategy models_data = [] @@ -712,7 +714,7 @@ async def _send_with_split( text: str, files: list[Path | str | io.BytesIO] | None = None, stream: bool = False, -) -> AsyncGenerator[ModelOutput, None] | ModelOutput: +) -> AsyncGenerator[ModelOutput] | ModelOutput: """Send text to Gemini, splitting or converting to attachment if too long.""" if len(text) <= MAX_CHARS_PER_REQUEST: try: @@ -1013,9 +1015,7 @@ def flush(self) -> str: res = "" if self.state in ("IN_TOOL", "IN_ORPHAN", "IN_RESP", "IN_HINT", "IN_ARG", "IN_RESULT"): res = "" - elif self.state == "IN_BLOCK" and self.current_role != "tool": - res = self.buffer - elif self.state == "NORMAL": + elif (self.state == "IN_BLOCK" and self.current_role != "tool") or self.state == "NORMAL": res = self.buffer self.buffer = "" @@ -1027,7 +1027,7 @@ def flush(self) -> str: def _create_real_streaming_response( - generator: AsyncGenerator[ModelOutput, None], + generator: AsyncGenerator[ModelOutput], completion_id: str, created_time: int, model_name: str, @@ -1221,7 +1221,7 @@ async def generate_stream(): def _create_responses_real_streaming_response( - generator: AsyncGenerator[ModelOutput, None], + generator: AsyncGenerator[ModelOutput], response_id: str, created_time: int, model_name: str, @@ -1455,10 +1455,12 @@ async def create_chat_completion( m_input, files = await GeminiClientWrapper.process_conversation(msgs, tmp_dir) except Exception as e: logger.exception("Error in preparing conversation") - raise HTTPException(status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail=str(e)) + raise HTTPException( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail=str(e) + ) from e completion_id = f"chatcmpl-{uuid.uuid4()}" - created_time = int(datetime.now(tz=timezone.utc).timestamp()) + created_time = int(datetime.now(tz=UTC).timestamp()) try: assert session and client @@ -1470,7 +1472,7 @@ async def create_chat_completion( ) except Exception as e: logger.exception("Gemini API error") - raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail=str(e)) + raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail=str(e)) from e if request.stream: return _create_real_streaming_response( @@ -1620,10 +1622,12 @@ async def create_response( m_input, files = await GeminiClientWrapper.process_conversation(messages, tmp_dir) except Exception as e: logger.exception("Error in preparing conversation") - raise HTTPException(status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail=str(e)) + raise HTTPException( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail=str(e) + ) from e response_id = f"resp_{uuid.uuid4().hex}" - created_time = int(datetime.now(tz=timezone.utc).timestamp()) + created_time = int(datetime.now(tz=UTC).timestamp()) try: assert session and client @@ -1635,7 +1639,7 @@ async def create_response( ) except Exception as e: logger.exception("Gemini API error") - raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail=str(e)) + raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail=str(e)) from e if request.stream: return _create_responses_real_streaming_response( diff --git a/app/server/health.py b/app/server/health.py index f521db1..444c938 100644 --- a/app/server/health.py +++ b/app/server/health.py @@ -1,8 +1,8 @@ from fastapi import APIRouter from loguru import logger -from ..models import HealthCheckResponse -from ..services import GeminiClientPool, LMDBConversationStore +from app.models import HealthCheckResponse +from app.services import GeminiClientPool, LMDBConversationStore router = APIRouter() diff --git a/app/server/images.py b/app/server/images.py index fe078f7..e1c161c 100644 --- a/app/server/images.py +++ b/app/server/images.py @@ -1,7 +1,7 @@ from fastapi import APIRouter, HTTPException, Query from fastapi.responses import FileResponse -from ..server.middleware import get_image_store_dir, verify_image_token +from app.server.middleware import get_image_store_dir, verify_image_token router = APIRouter() diff --git a/app/server/middleware.py b/app/server/middleware.py index 630e1f5..4bc358d 100644 --- a/app/server/middleware.py +++ b/app/server/middleware.py @@ -10,7 +10,7 @@ from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer from loguru import logger -from ..utils import g_config +from app.utils import g_config # Persistent directory for storing generated images IMAGE_STORE_DIR = Path(tempfile.gettempdir()) / "ai_generated_images" diff --git a/app/services/client.py b/app/services/client.py index 70dfce9..49d9e87 100644 --- a/app/services/client.py +++ b/app/services/client.py @@ -5,9 +5,9 @@ from gemini_webapi import GeminiClient, ModelOutput from loguru import logger -from ..models import Message -from ..utils import g_config -from ..utils.helper import ( +from app.models import Message +from app.utils import g_config +from app.utils.helper import ( add_tag, normalize_llm_text, save_file_to_tempfile, @@ -146,9 +146,8 @@ async def process_message( model_input = "\n".join(fragment for fragment in text_fragments if fragment is not None) - if model_input or message.role == "tool": - if tagged: - model_input = add_tag(message.role, model_input) + if (model_input or message.role == "tool") and tagged: + model_input = add_tag(message.role, model_input) return model_input, files diff --git a/app/services/lmdb.py b/app/services/lmdb.py index abf8859..87a1449 100644 --- a/app/services/lmdb.py +++ b/app/services/lmdb.py @@ -4,22 +4,22 @@ from contextlib import contextmanager from datetime import datetime, timedelta from pathlib import Path -from typing import Any, Dict, List, Optional +from typing import Any import lmdb import orjson from loguru import logger -from ..models import ContentItem, ConversationInStore, Message -from ..utils import g_config -from ..utils.helper import ( +from app.models import ContentItem, ConversationInStore, Message +from app.utils import g_config +from app.utils.helper import ( extract_tool_calls, normalize_llm_text, remove_tool_call_blocks, strip_system_hints, unescape_text, ) -from ..utils.singleton import Singleton +from app.utils.singleton import Singleton _VOLATILE_TRANS_TABLE = str.maketrans("", "", string.whitespace + string.punctuation) @@ -125,7 +125,7 @@ def _hash_message(message: Message, fuzzy: bool = False) -> str: def _hash_conversation( - client_id: str, model: str, messages: List[Message], fuzzy: bool = False + client_id: str, model: str, messages: list[Message], fuzzy: bool = False ) -> str: """Generate a hash for a list of messages and model name, tied to a specific client_id.""" combined_hash = hashlib.sha256() @@ -145,9 +145,9 @@ class LMDBConversationStore(metaclass=Singleton): def __init__( self, - db_path: Optional[str] = None, - max_db_size: Optional[int] = None, - retention_days: Optional[int] = None, + db_path: str | None = None, + max_db_size: int | None = None, + retention_days: int | None = None, ): """ Initialize LMDB store. @@ -219,7 +219,7 @@ def _get_transaction(self, write: bool = False): raise @staticmethod - def _decode_index_value(data: bytes) -> List[str]: + def _decode_index_value(data: bytes) -> list[str]: """Decode index value, handling both legacy single-string and new list-of-strings formats.""" if not data: return [] @@ -238,7 +238,7 @@ def _decode_index_value(data: bytes) -> List[str]: @staticmethod def _update_index(txn: lmdb.Transaction, prefix: str, hash_val: str, storage_key: str): """Add a storage key to the index for a given hash, avoiding duplicates.""" - idx_key = f"{prefix}{hash_val}".encode("utf-8") + idx_key = f"{prefix}{hash_val}".encode() existing = txn.get(idx_key) keys = LMDBConversationStore._decode_index_value(existing) if existing else [] if storage_key not in keys: @@ -248,7 +248,7 @@ def _update_index(txn: lmdb.Transaction, prefix: str, hash_val: str, storage_key @staticmethod def _remove_from_index(txn: lmdb.Transaction, prefix: str, hash_val: str, storage_key: str): """Remove a specific storage key from the index for a given hash.""" - idx_key = f"{prefix}{hash_val}".encode("utf-8") + idx_key = f"{prefix}{hash_val}".encode() existing = txn.get(idx_key) if not existing: return @@ -263,7 +263,7 @@ def _remove_from_index(txn: lmdb.Transaction, prefix: str, hash_val: str, storag def store( self, conv: ConversationInStore, - custom_key: Optional[str] = None, + custom_key: str | None = None, ) -> str: """ Store a conversation model in LMDB. @@ -312,7 +312,7 @@ def store( ) raise - def get(self, key: str) -> Optional[ConversationInStore]: + def get(self, key: str) -> ConversationInStore | None: """ Retrieve conversation data by key. @@ -340,7 +340,7 @@ def get(self, key: str) -> Optional[ConversationInStore]: logger.error(f"Unexpected error retrieving messages with key {key[:12]}: {e}") return None - def find(self, model: str, messages: List[Message]) -> Optional[ConversationInStore]: + def find(self, model: str, messages: list[Message]) -> ConversationInStore | None: """ Search conversation data by message list. Tries raw matching, then sanitized matching, and finally fuzzy matching. @@ -360,12 +360,13 @@ def find(self, model: str, messages: List[Message]) -> Optional[ConversationInSt return conv cleaned_messages = self.sanitize_messages(messages) - if cleaned_messages != messages: - if conv := self._find_by_message_list(model, cleaned_messages): - logger.debug( - f"Session found for '{model}' with {len(cleaned_messages)} cleaned messages." - ) - return conv + if cleaned_messages != messages and ( + conv := self._find_by_message_list(model, cleaned_messages) + ): + logger.debug( + f"Session found for '{model}' with {len(cleaned_messages)} cleaned messages." + ) + return conv if conv := self._find_by_message_list(model, messages, fuzzy=True): logger.debug( @@ -379,9 +380,9 @@ def find(self, model: str, messages: List[Message]) -> Optional[ConversationInSt def _find_by_message_list( self, model: str, - messages: List[Message], + messages: list[Message], fuzzy: bool = False, - ) -> Optional[ConversationInStore]: + ) -> ConversationInStore | None: """ Internal find implementation based on a message list. @@ -440,7 +441,7 @@ def exists(self, key: str) -> bool: logger.error(f"Failed to check existence of key {key}: {e}") return False - def delete(self, key: str) -> Optional[ConversationInStore]: + def delete(self, key: str) -> ConversationInStore | None: """Delete conversation model by key.""" try: with self._get_transaction(write=True) as txn: @@ -466,7 +467,7 @@ def delete(self, key: str) -> Optional[ConversationInStore]: logger.error(f"Failed to delete messages with key {key[:12]}: {e}") return None - def keys(self, prefix: str = "", limit: Optional[int] = None) -> List[str]: + def keys(self, prefix: str = "", limit: int | None = None) -> list[str]: """List all keys in the store, optionally filtered by prefix.""" keys = [] try: @@ -492,7 +493,7 @@ def keys(self, prefix: str = "", limit: Optional[int] = None) -> List[str]: logger.error(f"Failed to list keys: {e}") return keys - def cleanup_expired(self, retention_days: Optional[int] = None) -> int: + def cleanup_expired(self, retention_days: int | None = None) -> int: """Delete conversations older than the given retention period.""" retention_value = ( self.retention_days if retention_days is None else max(0, int(retention_days)) @@ -561,7 +562,7 @@ def cleanup_expired(self, retention_days: Optional[int] = None) -> int: return removed - def stats(self) -> Dict[str, Any]: + def stats(self) -> dict[str, Any]: """Get database statistics.""" if not self._env: logger.error("LMDB environment not initialized") diff --git a/app/services/pool.py b/app/services/pool.py index decc21a..3b4197c 100644 --- a/app/services/pool.py +++ b/app/services/pool.py @@ -1,11 +1,11 @@ import asyncio from collections import deque -from typing import Dict, List, Optional from loguru import logger -from ..utils import g_config -from ..utils.singleton import Singleton +from app.utils import g_config +from app.utils.singleton import Singleton + from .client import GeminiClientWrapper @@ -13,10 +13,10 @@ class GeminiClientPool(metaclass=Singleton): """Pool of GeminiClient instances identified by unique ids.""" def __init__(self) -> None: - self._clients: List[GeminiClientWrapper] = [] - self._id_map: Dict[str, GeminiClientWrapper] = {} + self._clients: list[GeminiClientWrapper] = [] + self._id_map: dict[str, GeminiClientWrapper] = {} self._round_robin: deque[GeminiClientWrapper] = deque() - self._restart_locks: Dict[str, asyncio.Lock] = {} + self._restart_locks: dict[str, asyncio.Lock] = {} if len(g_config.gemini.clients) == 0: raise ValueError("No Gemini clients configured") @@ -55,7 +55,7 @@ async def init(self) -> None: if success_count == 0: raise RuntimeError("Failed to initialize any Gemini clients") - async def acquire(self, client_id: Optional[str] = None) -> GeminiClientWrapper: + async def acquire(self, client_id: str | None = None) -> GeminiClientWrapper: """Return a healthy client by id or using round-robin.""" if not self._round_robin: raise RuntimeError("No Gemini clients configured") @@ -106,10 +106,10 @@ async def _ensure_client_ready(self, client: GeminiClientWrapper) -> bool: return False @property - def clients(self) -> List[GeminiClientWrapper]: + def clients(self) -> list[GeminiClientWrapper]: """Return managed clients.""" return self._clients - def status(self) -> Dict[str, bool]: + def status(self) -> dict[str, bool]: """Return running status for each client.""" return {client.id: client.running() for client in self._clients} diff --git a/app/utils/config.py b/app/utils/config.py index 4c1709f..21d2891 100644 --- a/app/utils/config.py +++ b/app/utils/config.py @@ -1,7 +1,7 @@ import ast import os import sys -from typing import Any, Literal, Optional +from typing import Any, Literal import orjson from loguru import logger @@ -28,7 +28,7 @@ class ServerConfig(BaseModel): host: str = Field(default="0.0.0.0", description="Server host address") port: int = Field(default=8000, ge=1, le=65535, description="Server port number") - api_key: Optional[str] = Field( + api_key: str | None = Field( default=None, description="API key for authentication, if set, will enable API key validation", ) @@ -41,11 +41,11 @@ class GeminiClientSettings(BaseModel): id: str = Field(..., description="Unique identifier for the client") secure_1psid: str = Field(..., description="Gemini Secure 1PSID") secure_1psidts: str = Field(..., description="Gemini Secure 1PSIDTS") - proxy: Optional[str] = Field(default=None, description="Proxy URL for this Gemini client") + proxy: str | None = Field(default=None, description="Proxy URL for this Gemini client") @field_validator("proxy", mode="before") @classmethod - def _blank_proxy_to_none(cls, value: Optional[str]) -> Optional[str]: + def _blank_proxy_to_none(cls, value: str | None) -> str | None: if value is None: return None stripped = value.strip() @@ -55,8 +55,8 @@ def _blank_proxy_to_none(cls, value: Optional[str]) -> Optional[str]: class GeminiModelConfig(BaseModel): """Configuration for a custom Gemini model.""" - model_name: Optional[str] = Field(default=None, description="Name of the model") - model_header: Optional[dict[str, Optional[str]]] = Field( + model_name: str | None = Field(default=None, description="Name of the model") + model_header: dict[str, str | None] | None = Field( default=None, description="Header for the model" ) diff --git a/app/utils/helper.py b/app/utils/helper.py index 64df4f7..002d401 100644 --- a/app/utils/helper.py +++ b/app/utils/helper.py @@ -14,7 +14,7 @@ import orjson from loguru import logger -from ..models import FunctionCall, Message, ToolCall +from app.models import FunctionCall, Message, ToolCall VALID_TAG_ROLES = {"user", "assistant", "system", "tool"} TOOL_WRAP_HINT = ( @@ -67,6 +67,7 @@ ) CHATML_END_RE = re.compile(r"<\|im_end\|>|\\<\\\|im\\_end\\\|\\>", re.IGNORECASE) COMMONMARK_UNESCAPE_RE = re.compile(r"\\([!\"#$%&'()*+,\-./:;<=>?@\[\\\]^_`{|}~])") +PARAM_FENCE_RE = re.compile(r"^(?P`{3,})") TOOL_HINT_STRIPPED = TOOL_WRAP_HINT.strip() _hint_lines = [line.strip() for line in TOOL_WRAP_HINT.split("\n") if line.strip()] TOOL_HINT_LINE_START = _hint_lines[0] if _hint_lines else "" @@ -113,7 +114,7 @@ def _strip_param_fences(s: str) -> str: if not s: return "" - match = re.match(r"^(?P`{3,})", s) + match = PARAM_FENCE_RE.match(s) if not match or not s.endswith(match.group("fence")): return s @@ -272,7 +273,7 @@ def _create_tool_call(name: str, raw_args: str) -> None: arguments = "{}" index = len(tool_calls) - seed = f"{name}:{arguments}:{index}".encode("utf-8") + seed = f"{name}:{arguments}:{index}".encode() call_id = f"call_{hashlib.sha256(seed).hexdigest()[:24]}" tool_calls.append( diff --git a/app/utils/singleton.py b/app/utils/singleton.py index 489e87e..2a258af 100644 --- a/app/utils/singleton.py +++ b/app/utils/singleton.py @@ -1,10 +1,10 @@ -from typing import ClassVar, Dict +from typing import ClassVar class Singleton(type): - _instances: ClassVar[Dict[type, object]] = {} + _instances: ClassVar[dict[type, object]] = {} def __call__(cls, *args, **kwargs): if cls not in cls._instances: - cls._instances[cls] = super(Singleton, cls).__call__(*args, **kwargs) + cls._instances[cls] = super().__call__(*args, **kwargs) return cls._instances[cls] diff --git a/pyproject.toml b/pyproject.toml index 0cae786..a1ae29d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -3,33 +3,61 @@ name = "gemini-fastapi" version = "1.0.0" description = "FastAPI Server built on Gemini Web API" readme = "README.md" -requires-python = "==3.12.*" +requires-python = "==3.13.*" dependencies = [ "fastapi>=0.129.0", "gemini-webapi>=1.19.2", + "httptools>=0.7.1", "lmdb>=1.7.5", "loguru>=0.7.3", "orjson>=3.11.7", - "pydantic-settings[yaml]>=2.12.0", - "uvicorn>=0.40.0", + "pydantic-settings[yaml]>=2.13.0", + "uvicorn>=0.41.0", "uvloop>=0.22.1; sys_platform != 'win32'", ] +[project.urls] +Repository = "https://github.com/Nativu5/Gemini-FastAPI" + [project.optional-dependencies] dev = [ - "ruff>=0.15.0", + "pytest>=9.0.2", + "ruff>=0.15.1", +] + +[dependency-groups] +dev = [ + "gemini-fastapi[dev]", ] [tool.ruff] line-length = 100 -lint.select = ["E", "F", "W", "I", "RUF"] -lint.ignore = ["E501"] +target-version = "py313" + +[tool.ruff.lint] +select = [ + "E", # pycodestyle errors + "F", # pyflakes + "W", # pycodestyle warnings + "I", # isort + "UP", # pyupgrade + "B", # flake8-bugbear + "C4", # flake8-comprehensions + "SIM", # flake8-simplify + "RUF", # ruff-specific rules + "TID", # flake8-tidy-imports +] +ignore = [ + "E501", # line too long +] + +[tool.ruff.lint.flake8-bugbear] +extend-immutable-calls = [ + "fastapi.Depends", + "fastapi.Query", + "fastapi.security.HTTPBearer", +] [tool.ruff.format] quote-style = "double" indent-style = "space" - -[dependency-groups] -dev = [ - "ruff>=0.15.1", -] diff --git a/scripts/dump_lmdb.py b/scripts/dump_lmdb.py index a331325..889af4f 100644 --- a/scripts/dump_lmdb.py +++ b/scripts/dump_lmdb.py @@ -1,6 +1,7 @@ import argparse +from collections.abc import Iterable from pathlib import Path -from typing import Any, Iterable, List +from typing import Any import lmdb import orjson @@ -14,17 +15,17 @@ def _decode_value(value: bytes) -> Any: return value.decode("utf-8", errors="replace") -def _dump_all(txn: lmdb.Transaction) -> List[dict[str, Any]]: +def _dump_all(txn: lmdb.Transaction) -> list[dict[str, Any]]: """Return all records from the database.""" - result: List[dict[str, Any]] = [] + result: list[dict[str, Any]] = [] for key, value in txn.cursor(): result.append({"key": key.decode("utf-8"), "value": _decode_value(value)}) return result -def _dump_selected(txn: lmdb.Transaction, keys: Iterable[str]) -> List[dict[str, Any]]: +def _dump_selected(txn: lmdb.Transaction, keys: Iterable[str]) -> list[dict[str, Any]]: """Return records for the provided keys.""" - result: List[dict[str, Any]] = [] + result: list[dict[str, Any]] = [] for key in keys: raw = txn.get(key.encode("utf-8")) if raw is not None: @@ -36,10 +37,7 @@ def dump_lmdb(path: Path, keys: Iterable[str] | None = None) -> None: """Print selected or all key-value pairs from the LMDB database.""" env = lmdb.open(str(path), readonly=True, lock=False) with env.begin() as txn: - if keys: - records = _dump_selected(txn, keys) - else: - records = _dump_all(txn) + records = _dump_selected(txn, keys) if keys else _dump_all(txn) env.close() print(orjson.dumps(records, option=orjson.OPT_INDENT_2).decode("utf-8")) diff --git a/uv.lock b/uv.lock index 5b687e4..4c819e7 100644 --- a/uv.lock +++ b/uv.lock @@ -1,6 +1,6 @@ version = 1 revision = 3 -requires-python = "==3.12.*" +requires-python = "==3.13.*" [[package]] name = "annotated-doc" @@ -26,7 +26,6 @@ version = "4.12.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "idna" }, - { name = "typing-extensions" }, ] 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 = [ @@ -86,6 +85,7 @@ source = { virtual = "." } dependencies = [ { name = "fastapi" }, { name = "gemini-webapi" }, + { name = "httptools" }, { name = "lmdb" }, { name = "loguru" }, { name = "orjson" }, @@ -96,30 +96,33 @@ dependencies = [ [package.optional-dependencies] dev = [ + { name = "pytest" }, { name = "ruff" }, ] [package.dev-dependencies] dev = [ - { name = "ruff" }, + { name = "gemini-fastapi", extra = ["dev"] }, ] [package.metadata] requires-dist = [ { name = "fastapi", specifier = ">=0.129.0" }, { name = "gemini-webapi", specifier = ">=1.19.2" }, + { name = "httptools", specifier = ">=0.7.1" }, { name = "lmdb", specifier = ">=1.7.5" }, { name = "loguru", specifier = ">=0.7.3" }, { name = "orjson", specifier = ">=3.11.7" }, - { name = "pydantic-settings", extras = ["yaml"], specifier = ">=2.12.0" }, - { name = "ruff", marker = "extra == 'dev'", specifier = ">=0.15.0" }, - { name = "uvicorn", specifier = ">=0.40.0" }, + { name = "pydantic-settings", extras = ["yaml"], specifier = ">=2.13.0" }, + { name = "pytest", marker = "extra == 'dev'", specifier = ">=9.0.2" }, + { name = "ruff", marker = "extra == 'dev'", specifier = ">=0.15.1" }, + { name = "uvicorn", specifier = ">=0.41.0" }, { name = "uvloop", marker = "sys_platform != 'win32'", specifier = ">=0.22.1" }, ] provides-extras = ["dev"] [package.metadata.requires-dev] -dev = [{ name = "ruff", specifier = ">=0.15.1" }] +dev = [{ name = "gemini-fastapi", extras = ["dev"] }] [[package]] name = "gemini-webapi" @@ -180,6 +183,21 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, ] +[[package]] +name = "httptools" +version = "0.7.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b5/46/120a669232c7bdedb9d52d4aeae7e6c7dfe151e99dc70802e2fc7a5e1993/httptools-0.7.1.tar.gz", hash = "sha256:abd72556974f8e7c74a259655924a717a2365b236c882c3f6f8a45fe94703ac9", size = 258961, upload-time = "2025-10-10T03:55:08.559Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/09/8f/c77b1fcbfd262d422f12da02feb0d218fa228d52485b77b953832105bb90/httptools-0.7.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:6babce6cfa2a99545c60bfef8bee0cc0545413cb0018f617c8059a30ad985de3", size = 202889, upload-time = "2025-10-10T03:54:47.089Z" }, + { url = "https://files.pythonhosted.org/packages/0a/1a/22887f53602feaa066354867bc49a68fc295c2293433177ee90870a7d517/httptools-0.7.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:601b7628de7504077dd3dcb3791c6b8694bbd967148a6d1f01806509254fb1ca", size = 108180, upload-time = "2025-10-10T03:54:48.052Z" }, + { url = "https://files.pythonhosted.org/packages/32/6a/6aaa91937f0010d288d3d124ca2946d48d60c3a5ee7ca62afe870e3ea011/httptools-0.7.1-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:04c6c0e6c5fb0739c5b8a9eb046d298650a0ff38cf42537fc372b28dc7e4472c", size = 478596, upload-time = "2025-10-10T03:54:48.919Z" }, + { url = "https://files.pythonhosted.org/packages/6d/70/023d7ce117993107be88d2cbca566a7c1323ccbaf0af7eabf2064fe356f6/httptools-0.7.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:69d4f9705c405ae3ee83d6a12283dc9feba8cc6aaec671b412917e644ab4fa66", size = 473268, upload-time = "2025-10-10T03:54:49.993Z" }, + { url = "https://files.pythonhosted.org/packages/32/4d/9dd616c38da088e3f436e9a616e1d0cc66544b8cdac405cc4e81c8679fc7/httptools-0.7.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:44c8f4347d4b31269c8a9205d8a5ee2df5322b09bbbd30f8f862185bb6b05346", size = 455517, upload-time = "2025-10-10T03:54:51.066Z" }, + { url = "https://files.pythonhosted.org/packages/1d/3a/a6c595c310b7df958e739aae88724e24f9246a514d909547778d776799be/httptools-0.7.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:465275d76db4d554918aba40bf1cbebe324670f3dfc979eaffaa5d108e2ed650", size = 458337, upload-time = "2025-10-10T03:54:52.196Z" }, + { url = "https://files.pythonhosted.org/packages/fd/82/88e8d6d2c51edc1cc391b6e044c6c435b6aebe97b1abc33db1b0b24cd582/httptools-0.7.1-cp313-cp313-win_amd64.whl", hash = "sha256:322d00c2068d125bd570f7bf78b2d367dad02b919d8581d7476d8b75b294e3e6", size = 85743, upload-time = "2025-10-10T03:54:53.448Z" }, +] + [[package]] name = "httpx" version = "0.28.1" @@ -218,18 +236,27 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" }, ] +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + [[package]] name = "lmdb" version = "1.7.5" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/c7/a3/3756f2c6adba4a1413dba55e6c81a20b38a868656517308533e33cb59e1c/lmdb-1.7.5.tar.gz", hash = "sha256:f0604751762cb097059d5412444c4057b95f386c7ed958363cf63f453e5108da", size = 883490, upload-time = "2025-10-15T03:39:44.038Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/34/b4/8b862c4d7fd6f68cb33e2a919169fda8924121dc5ff61e3cc105304a6dd4/lmdb-1.7.5-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b48c2359eea876d7b634b49f84019ecc8c1626da97c795fc7b39a793676815df", size = 100910, upload-time = "2025-10-15T03:39:00.727Z" }, - { url = "https://files.pythonhosted.org/packages/27/64/8ab5da48180d5f13a293ea00a9f8758b1bee080e76ea0ab0d6be0d51b55f/lmdb-1.7.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2f84793baeb430ba984eb6c1b4e08c0a508b1c03e79ce79fcda0f29ecc06a95a", size = 99376, upload-time = "2025-10-15T03:39:01.791Z" }, - { url = "https://files.pythonhosted.org/packages/43/e0/51bc942fe5ed3fce69c631b54f52d97785de3d94487376139be6de1e199a/lmdb-1.7.5-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:68cc21314a33faac1b749645a976b7655e7fa7cc104a72365d2429d2db7f6342", size = 298556, upload-time = "2025-10-15T03:39:02.787Z" }, - { url = "https://files.pythonhosted.org/packages/66/c5/19ea75c88b91d12da5c6f4bbe2aca633047b6b270fd613d557583d32cc5c/lmdb-1.7.5-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f2d9b7e102fcfe5e0cfb3acdebd403eb55ccbe5f7202d8f49d60bdafb1546d1e", size = 299449, upload-time = "2025-10-15T03:39:03.903Z" }, - { url = "https://files.pythonhosted.org/packages/1b/74/365194203dbff47d3a1621366d6a1133cdcce261f4ac0e1d0496f01e6ace/lmdb-1.7.5-cp312-cp312-win_amd64.whl", hash = "sha256:69de89cc79e03e191fc6f95797f1bef91b45c415d1ea9d38872b00b2d989a50f", size = 99328, upload-time = "2025-10-15T03:39:04.949Z" }, - { url = "https://files.pythonhosted.org/packages/3f/3a/a441afebff5bd761f7f58d194fed7ac265279964957479a5c8a51c42f9ad/lmdb-1.7.5-cp312-cp312-win_arm64.whl", hash = "sha256:0c880ee4b309e900f2d58a710701f5e6316a351878588c6a95a9c0bcb640680b", size = 94191, upload-time = "2025-10-15T03:39:05.975Z" }, + { url = "https://files.pythonhosted.org/packages/38/f8/03275084218eacdbdf7e185d693e1db4cb79c35d18fac47fa0d388522a0d/lmdb-1.7.5-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:66ae02fa6179e46bb69fe446b7e956afe8706ae17ec1d4cd9f7056e161019156", size = 101508, upload-time = "2025-10-15T03:39:07.228Z" }, + { url = "https://files.pythonhosted.org/packages/20/b9/bc33ae2e4940359ba2fc412e6a755a2f126bc5062b4aaf35edd3a791f9a5/lmdb-1.7.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:bf65c573311ac8330c7908257f76b28ae3576020123400a81a6b650990dc028c", size = 100105, upload-time = "2025-10-15T03:39:08.491Z" }, + { url = "https://files.pythonhosted.org/packages/fa/f6/22f84b776a64d3992f052ecb637c35f1764a39df4f2190ecc5a3a1295bd7/lmdb-1.7.5-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:97bcb3fc12841a8828db918e494fe0fd016a73d2680ad830d75719bb3bf4e76a", size = 301500, upload-time = "2025-10-15T03:39:09.463Z" }, + { url = "https://files.pythonhosted.org/packages/2a/4d/8e6be8d7d5a30d47fa0ce4b55e3a8050ad689556e6e979d206b4ac67b733/lmdb-1.7.5-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:865f374f6206ab4aacb92ffb1dc612ee1a31a421db7c89733abe06b81ac87cb0", size = 302285, upload-time = "2025-10-15T03:39:10.856Z" }, + { url = "https://files.pythonhosted.org/packages/5e/dc/7e04fb31a8f88951db81ac677e3ccb3e09248eda40e6ad52f74fd9370c32/lmdb-1.7.5-cp313-cp313-win_amd64.whl", hash = "sha256:82a04d5ca2a6a799c8db7f209354c48aebb49ff338530f5813721fc4c68e4450", size = 99447, upload-time = "2025-10-15T03:39:12.151Z" }, + { url = "https://files.pythonhosted.org/packages/5b/50/e3f97efab17b3fad4afde99b3c957ecac4ffbefada6874a57ad0c695660a/lmdb-1.7.5-cp313-cp313-win_arm64.whl", hash = "sha256:0ad85a15acbfe8a42fdef92ee5e869610286d38507e976755f211be0fc905ca7", size = 94145, upload-time = "2025-10-15T03:39:13.461Z" }, { url = "https://files.pythonhosted.org/packages/bd/2c/982cb5afed533d0cb8038232b40c19b5b85a2d887dec74dfd39e8351ef4b/lmdb-1.7.5-py3-none-any.whl", hash = "sha256:fc344bb8bc0786c87c4ccb19b31f09a38c08bd159ada6f037d669426fea06f03", size = 148539, upload-time = "2025-10-15T03:39:42.982Z" }, ] @@ -252,21 +279,39 @@ version = "3.11.7" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/53/45/b268004f745ede84e5798b48ee12b05129d19235d0e15267aa57dcdb400b/orjson-3.11.7.tar.gz", hash = "sha256:9b1a67243945819ce55d24a30b59d6a168e86220452d2c96f4d1f093e71c0c49", size = 6144992, upload-time = "2026-02-02T15:38:49.29Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/80/bf/76f4f1665f6983385938f0e2a5d7efa12a58171b8456c252f3bae8a4cf75/orjson-3.11.7-cp312-cp312-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:bd03ea7606833655048dab1a00734a2875e3e86c276e1d772b2a02556f0d895f", size = 228545, upload-time = "2026-02-02T15:37:46.376Z" }, - { url = "https://files.pythonhosted.org/packages/79/53/6c72c002cb13b5a978a068add59b25a8bdf2800ac1c9c8ecdb26d6d97064/orjson-3.11.7-cp312-cp312-macosx_15_0_arm64.whl", hash = "sha256:89e440ebc74ce8ab5c7bc4ce6757b4a6b1041becb127df818f6997b5c71aa60b", size = 125224, upload-time = "2026-02-02T15:37:47.697Z" }, - { url = "https://files.pythonhosted.org/packages/2c/83/10e48852865e5dd151bdfe652c06f7da484578ed02c5fca938e3632cb0b8/orjson-3.11.7-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5ede977b5fe5ac91b1dffc0a517ca4542d2ec8a6a4ff7b2652d94f640796342a", size = 128154, upload-time = "2026-02-02T15:37:48.954Z" }, - { url = "https://files.pythonhosted.org/packages/6e/52/a66e22a2b9abaa374b4a081d410edab6d1e30024707b87eab7c734afe28d/orjson-3.11.7-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b7b1dae39230a393df353827c855a5f176271c23434cfd2db74e0e424e693e10", size = 123548, upload-time = "2026-02-02T15:37:50.187Z" }, - { url = "https://files.pythonhosted.org/packages/de/38/605d371417021359f4910c496f764c48ceb8997605f8c25bf1dfe58c0ebe/orjson-3.11.7-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ed46f17096e28fb28d2975834836a639af7278aa87c84f68ab08fbe5b8bd75fa", size = 129000, upload-time = "2026-02-02T15:37:51.426Z" }, - { url = "https://files.pythonhosted.org/packages/44/98/af32e842b0ffd2335c89714d48ca4e3917b42f5d6ee5537832e069a4b3ac/orjson-3.11.7-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3726be79e36e526e3d9c1aceaadbfb4a04ee80a72ab47b3f3c17fefb9812e7b8", size = 141686, upload-time = "2026-02-02T15:37:52.607Z" }, - { url = "https://files.pythonhosted.org/packages/96/0b/fc793858dfa54be6feee940c1463370ece34b3c39c1ca0aa3845f5ba9892/orjson-3.11.7-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0724e265bc548af1dedebd9cb3d24b4e1c1e685a343be43e87ba922a5c5fff2f", size = 130812, upload-time = "2026-02-02T15:37:53.944Z" }, - { url = "https://files.pythonhosted.org/packages/dc/91/98a52415059db3f374757d0b7f0f16e3b5cd5976c90d1c2b56acaea039e6/orjson-3.11.7-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e7745312efa9e11c17fbd3cb3097262d079da26930ae9ae7ba28fb738367cbad", size = 133440, upload-time = "2026-02-02T15:37:55.615Z" }, - { url = "https://files.pythonhosted.org/packages/dc/b6/cb540117bda61791f46381f8c26c8f93e802892830a6055748d3bb1925ab/orjson-3.11.7-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:f904c24bdeabd4298f7a977ef14ca2a022ca921ed670b92ecd16ab6f3d01f867", size = 138386, upload-time = "2026-02-02T15:37:56.814Z" }, - { url = "https://files.pythonhosted.org/packages/63/1a/50a3201c334a7f17c231eee5f841342190723794e3b06293f26e7cf87d31/orjson-3.11.7-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:b9fc4d0f81f394689e0814617aadc4f2ea0e8025f38c226cbf22d3b5ddbf025d", size = 408853, upload-time = "2026-02-02T15:37:58.291Z" }, - { url = "https://files.pythonhosted.org/packages/87/cd/8de1c67d0be44fdc22701e5989c0d015a2adf391498ad42c4dc589cd3013/orjson-3.11.7-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:849e38203e5be40b776ed2718e587faf204d184fc9a008ae441f9442320c0cab", size = 144130, upload-time = "2026-02-02T15:38:00.163Z" }, - { url = "https://files.pythonhosted.org/packages/0f/fe/d605d700c35dd55f51710d159fc54516a280923cd1b7e47508982fbb387d/orjson-3.11.7-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:4682d1db3bcebd2b64757e0ddf9e87ae5f00d29d16c5cdf3a62f561d08cc3dd2", size = 134818, upload-time = "2026-02-02T15:38:01.507Z" }, - { url = "https://files.pythonhosted.org/packages/e4/e4/15ecc67edb3ddb3e2f46ae04475f2d294e8b60c1825fbe28a428b93b3fbd/orjson-3.11.7-cp312-cp312-win32.whl", hash = "sha256:f4f7c956b5215d949a1f65334cf9d7612dde38f20a95f2315deef167def91a6f", size = 127923, upload-time = "2026-02-02T15:38:02.75Z" }, - { url = "https://files.pythonhosted.org/packages/34/70/2e0855361f76198a3965273048c8e50a9695d88cd75811a5b46444895845/orjson-3.11.7-cp312-cp312-win_amd64.whl", hash = "sha256:bf742e149121dc5648ba0a08ea0871e87b660467ef168a3a5e53bc1fbd64bb74", size = 125007, upload-time = "2026-02-02T15:38:04.032Z" }, - { url = "https://files.pythonhosted.org/packages/68/40/c2051bd19fc467610fed469dc29e43ac65891571138f476834ca192bc290/orjson-3.11.7-cp312-cp312-win_arm64.whl", hash = "sha256:26c3b9132f783b7d7903bf1efb095fed8d4a3a85ec0d334ee8beff3d7a4749d5", size = 126089, upload-time = "2026-02-02T15:38:05.297Z" }, + { url = "https://files.pythonhosted.org/packages/89/25/6e0e52cac5aab51d7b6dcd257e855e1dec1c2060f6b28566c509b4665f62/orjson-3.11.7-cp313-cp313-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:1d98b30cc1313d52d4af17d9c3d307b08389752ec5f2e5febdfada70b0f8c733", size = 228390, upload-time = "2026-02-02T15:38:06.8Z" }, + { url = "https://files.pythonhosted.org/packages/a5/29/a77f48d2fc8a05bbc529e5ff481fb43d914f9e383ea2469d4f3d51df3d00/orjson-3.11.7-cp313-cp313-macosx_15_0_arm64.whl", hash = "sha256:d897e81f8d0cbd2abb82226d1860ad2e1ab3ff16d7b08c96ca00df9d45409ef4", size = 125189, upload-time = "2026-02-02T15:38:08.181Z" }, + { url = "https://files.pythonhosted.org/packages/89/25/0a16e0729a0e6a1504f9d1a13cdd365f030068aab64cec6958396b9969d7/orjson-3.11.7-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:814be4b49b228cfc0b3c565acf642dd7d13538f966e3ccde61f4f55be3e20785", size = 128106, upload-time = "2026-02-02T15:38:09.41Z" }, + { url = "https://files.pythonhosted.org/packages/66/da/a2e505469d60666a05ab373f1a6322eb671cb2ba3a0ccfc7d4bc97196787/orjson-3.11.7-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d06e5c5fed5caedd2e540d62e5b1c25e8c82431b9e577c33537e5fa4aa909539", size = 123363, upload-time = "2026-02-02T15:38:10.73Z" }, + { url = "https://files.pythonhosted.org/packages/23/bf/ed73f88396ea35c71b38961734ea4a4746f7ca0768bf28fd551d37e48dd0/orjson-3.11.7-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:31c80ce534ac4ea3739c5ee751270646cbc46e45aea7576a38ffec040b4029a1", size = 129007, upload-time = "2026-02-02T15:38:12.138Z" }, + { url = "https://files.pythonhosted.org/packages/73/3c/b05d80716f0225fc9008fbf8ab22841dcc268a626aa550561743714ce3bf/orjson-3.11.7-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f50979824bde13d32b4320eedd513431c921102796d86be3eee0b58e58a3ecd1", size = 141667, upload-time = "2026-02-02T15:38:13.398Z" }, + { url = "https://files.pythonhosted.org/packages/61/e8/0be9b0addd9bf86abfc938e97441dcd0375d494594b1c8ad10fe57479617/orjson-3.11.7-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9e54f3808e2b6b945078c41aa8d9b5834b28c50843846e97807e5adb75fa9705", size = 130832, upload-time = "2026-02-02T15:38:14.698Z" }, + { url = "https://files.pythonhosted.org/packages/c9/ec/c68e3b9021a31d9ec15a94931db1410136af862955854ed5dd7e7e4f5bff/orjson-3.11.7-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a12b80df61aab7b98b490fe9e4879925ba666fccdfcd175252ce4d9035865ace", size = 133373, upload-time = "2026-02-02T15:38:16.109Z" }, + { url = "https://files.pythonhosted.org/packages/d2/45/f3466739aaafa570cc8e77c6dbb853c48bf56e3b43738020e2661e08b0ac/orjson-3.11.7-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:996b65230271f1a97026fd0e6a753f51fbc0c335d2ad0c6201f711b0da32693b", size = 138307, upload-time = "2026-02-02T15:38:17.453Z" }, + { url = "https://files.pythonhosted.org/packages/e1/84/9f7f02288da1ffb31405c1be07657afd1eecbcb4b64ee2817b6fe0f785fa/orjson-3.11.7-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:ab49d4b2a6a1d415ddb9f37a21e02e0d5dbfe10b7870b21bf779fc21e9156157", size = 408695, upload-time = "2026-02-02T15:38:18.831Z" }, + { url = "https://files.pythonhosted.org/packages/18/07/9dd2f0c0104f1a0295ffbe912bc8d63307a539b900dd9e2c48ef7810d971/orjson-3.11.7-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:390a1dce0c055ddf8adb6aa94a73b45a4a7d7177b5c584b8d1c1947f2ba60fb3", size = 144099, upload-time = "2026-02-02T15:38:20.28Z" }, + { url = "https://files.pythonhosted.org/packages/a5/66/857a8e4a3292e1f7b1b202883bcdeb43a91566cf59a93f97c53b44bd6801/orjson-3.11.7-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1eb80451a9c351a71dfaf5b7ccc13ad065405217726b59fdbeadbcc544f9d223", size = 134806, upload-time = "2026-02-02T15:38:22.186Z" }, + { url = "https://files.pythonhosted.org/packages/0a/5b/6ebcf3defc1aab3a338ca777214966851e92efb1f30dc7fc8285216e6d1b/orjson-3.11.7-cp313-cp313-win32.whl", hash = "sha256:7477aa6a6ec6139c5cb1cc7b214643592169a5494d200397c7fc95d740d5fcf3", size = 127914, upload-time = "2026-02-02T15:38:23.511Z" }, + { url = "https://files.pythonhosted.org/packages/00/04/c6f72daca5092e3117840a1b1e88dfc809cc1470cf0734890d0366b684a1/orjson-3.11.7-cp313-cp313-win_amd64.whl", hash = "sha256:b9f95dcdea9d4f805daa9ddf02617a89e484c6985fa03055459f90e87d7a0757", size = 124986, upload-time = "2026-02-02T15:38:24.836Z" }, + { url = "https://files.pythonhosted.org/packages/03/ba/077a0f6f1085d6b806937246860fafbd5b17f3919c70ee3f3d8d9c713f38/orjson-3.11.7-cp313-cp313-win_arm64.whl", hash = "sha256:800988273a014a0541483dc81021247d7eacb0c845a9d1a34a422bc718f41539", size = 126045, upload-time = "2026-02-02T15:38:26.216Z" }, +] + +[[package]] +name = "packaging" +version = "26.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/65/ee/299d360cdc32edc7d2cf530f3accf79c4fca01e96ffc950d8a52213bd8e4/packaging-26.0.tar.gz", hash = "sha256:00243ae351a257117b6a241061796684b084ed1c516a08c48a3f7e147a9d80b4", size = 143416, upload-time = "2026-01-21T20:50:39.064Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/b9/c538f279a4e237a006a2c98387d081e9eb060d203d8ed34467cc0f0b9b53/packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529", size = 74366, upload-time = "2026-01-21T20:50:37.788Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, ] [[package]] @@ -293,38 +338,34 @@ dependencies = [ ] sdist = { url = "https://files.pythonhosted.org/packages/71/70/23b021c950c2addd24ec408e9ab05d59b035b39d97cdc1130e1bce647bb6/pydantic_core-2.41.5.tar.gz", hash = "sha256:08daa51ea16ad373ffd5e7606252cc32f07bc72b28284b6bc9c6df804816476e", size = 460952, upload-time = "2025-11-04T13:43:49.098Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/5f/5d/5f6c63eebb5afee93bcaae4ce9a898f3373ca23df3ccaef086d0233a35a7/pydantic_core-2.41.5-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:f41a7489d32336dbf2199c8c0a215390a751c5b014c2c1c5366e817202e9cdf7", size = 2110990, upload-time = "2025-11-04T13:39:58.079Z" }, - { url = "https://files.pythonhosted.org/packages/aa/32/9c2e8ccb57c01111e0fd091f236c7b371c1bccea0fa85247ac55b1e2b6b6/pydantic_core-2.41.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:070259a8818988b9a84a449a2a7337c7f430a22acc0859c6b110aa7212a6d9c0", size = 1896003, upload-time = "2025-11-04T13:39:59.956Z" }, - { url = "https://files.pythonhosted.org/packages/68/b8/a01b53cb0e59139fbc9e4fda3e9724ede8de279097179be4ff31f1abb65a/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e96cea19e34778f8d59fe40775a7a574d95816eb150850a85a7a4c8f4b94ac69", size = 1919200, upload-time = "2025-11-04T13:40:02.241Z" }, - { url = "https://files.pythonhosted.org/packages/38/de/8c36b5198a29bdaade07b5985e80a233a5ac27137846f3bc2d3b40a47360/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ed2e99c456e3fadd05c991f8f437ef902e00eedf34320ba2b0842bd1c3ca3a75", size = 2052578, upload-time = "2025-11-04T13:40:04.401Z" }, - { url = "https://files.pythonhosted.org/packages/00/b5/0e8e4b5b081eac6cb3dbb7e60a65907549a1ce035a724368c330112adfdd/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:65840751b72fbfd82c3c640cff9284545342a4f1eb1586ad0636955b261b0b05", size = 2208504, upload-time = "2025-11-04T13:40:06.072Z" }, - { url = "https://files.pythonhosted.org/packages/77/56/87a61aad59c7c5b9dc8caad5a41a5545cba3810c3e828708b3d7404f6cef/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e536c98a7626a98feb2d3eaf75944ef6f3dbee447e1f841eae16f2f0a72d8ddc", size = 2335816, upload-time = "2025-11-04T13:40:07.835Z" }, - { url = "https://files.pythonhosted.org/packages/0d/76/941cc9f73529988688a665a5c0ecff1112b3d95ab48f81db5f7606f522d3/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eceb81a8d74f9267ef4081e246ffd6d129da5d87e37a77c9bde550cb04870c1c", size = 2075366, upload-time = "2025-11-04T13:40:09.804Z" }, - { url = "https://files.pythonhosted.org/packages/d3/43/ebef01f69baa07a482844faaa0a591bad1ef129253ffd0cdaa9d8a7f72d3/pydantic_core-2.41.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d38548150c39b74aeeb0ce8ee1d8e82696f4a4e16ddc6de7b1d8823f7de4b9b5", size = 2171698, upload-time = "2025-11-04T13:40:12.004Z" }, - { url = "https://files.pythonhosted.org/packages/b1/87/41f3202e4193e3bacfc2c065fab7706ebe81af46a83d3e27605029c1f5a6/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:c23e27686783f60290e36827f9c626e63154b82b116d7fe9adba1fda36da706c", size = 2132603, upload-time = "2025-11-04T13:40:13.868Z" }, - { url = "https://files.pythonhosted.org/packages/49/7d/4c00df99cb12070b6bccdef4a195255e6020a550d572768d92cc54dba91a/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:482c982f814460eabe1d3bb0adfdc583387bd4691ef00b90575ca0d2b6fe2294", size = 2329591, upload-time = "2025-11-04T13:40:15.672Z" }, - { url = "https://files.pythonhosted.org/packages/cc/6a/ebf4b1d65d458f3cda6a7335d141305dfa19bdc61140a884d165a8a1bbc7/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:bfea2a5f0b4d8d43adf9d7b8bf019fb46fdd10a2e5cde477fbcb9d1fa08c68e1", size = 2319068, upload-time = "2025-11-04T13:40:17.532Z" }, - { url = "https://files.pythonhosted.org/packages/49/3b/774f2b5cd4192d5ab75870ce4381fd89cf218af999515baf07e7206753f0/pydantic_core-2.41.5-cp312-cp312-win32.whl", hash = "sha256:b74557b16e390ec12dca509bce9264c3bbd128f8a2c376eaa68003d7f327276d", size = 1985908, upload-time = "2025-11-04T13:40:19.309Z" }, - { url = "https://files.pythonhosted.org/packages/86/45/00173a033c801cacf67c190fef088789394feaf88a98a7035b0e40d53dc9/pydantic_core-2.41.5-cp312-cp312-win_amd64.whl", hash = "sha256:1962293292865bca8e54702b08a4f26da73adc83dd1fcf26fbc875b35d81c815", size = 2020145, upload-time = "2025-11-04T13:40:21.548Z" }, - { url = "https://files.pythonhosted.org/packages/f9/22/91fbc821fa6d261b376a3f73809f907cec5ca6025642c463d3488aad22fb/pydantic_core-2.41.5-cp312-cp312-win_arm64.whl", hash = "sha256:1746d4a3d9a794cacae06a5eaaccb4b8643a131d45fbc9af23e353dc0a5ba5c3", size = 1976179, upload-time = "2025-11-04T13:40:23.393Z" }, - { url = "https://files.pythonhosted.org/packages/09/32/59b0c7e63e277fa7911c2fc70ccfb45ce4b98991e7ef37110663437005af/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:7da7087d756b19037bc2c06edc6c170eeef3c3bafcb8f532ff17d64dc427adfd", size = 2110495, upload-time = "2025-11-04T13:42:49.689Z" }, - { url = "https://files.pythonhosted.org/packages/aa/81/05e400037eaf55ad400bcd318c05bb345b57e708887f07ddb2d20e3f0e98/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:aabf5777b5c8ca26f7824cb4a120a740c9588ed58df9b2d196ce92fba42ff8dc", size = 1915388, upload-time = "2025-11-04T13:42:52.215Z" }, - { url = "https://files.pythonhosted.org/packages/6e/0d/e3549b2399f71d56476b77dbf3cf8937cec5cd70536bdc0e374a421d0599/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c007fe8a43d43b3969e8469004e9845944f1a80e6acd47c150856bb87f230c56", size = 1942879, upload-time = "2025-11-04T13:42:56.483Z" }, - { url = "https://files.pythonhosted.org/packages/f7/07/34573da085946b6a313d7c42f82f16e8920bfd730665de2d11c0c37a74b5/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:76d0819de158cd855d1cbb8fcafdf6f5cf1eb8e470abe056d5d161106e38062b", size = 2139017, upload-time = "2025-11-04T13:42:59.471Z" }, + { url = "https://files.pythonhosted.org/packages/87/06/8806241ff1f70d9939f9af039c6c35f2360cf16e93c2ca76f184e76b1564/pydantic_core-2.41.5-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:941103c9be18ac8daf7b7adca8228f8ed6bb7a1849020f643b3a14d15b1924d9", size = 2120403, upload-time = "2025-11-04T13:40:25.248Z" }, + { url = "https://files.pythonhosted.org/packages/94/02/abfa0e0bda67faa65fef1c84971c7e45928e108fe24333c81f3bfe35d5f5/pydantic_core-2.41.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:112e305c3314f40c93998e567879e887a3160bb8689ef3d2c04b6cc62c33ac34", size = 1896206, upload-time = "2025-11-04T13:40:27.099Z" }, + { url = "https://files.pythonhosted.org/packages/15/df/a4c740c0943e93e6500f9eb23f4ca7ec9bf71b19e608ae5b579678c8d02f/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0cbaad15cb0c90aa221d43c00e77bb33c93e8d36e0bf74760cd00e732d10a6a0", size = 1919307, upload-time = "2025-11-04T13:40:29.806Z" }, + { url = "https://files.pythonhosted.org/packages/9a/e3/6324802931ae1d123528988e0e86587c2072ac2e5394b4bc2bc34b61ff6e/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:03ca43e12fab6023fc79d28ca6b39b05f794ad08ec2feccc59a339b02f2b3d33", size = 2063258, upload-time = "2025-11-04T13:40:33.544Z" }, + { url = "https://files.pythonhosted.org/packages/c9/d4/2230d7151d4957dd79c3044ea26346c148c98fbf0ee6ebd41056f2d62ab5/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:dc799088c08fa04e43144b164feb0c13f9a0bc40503f8df3e9fde58a3c0c101e", size = 2214917, upload-time = "2025-11-04T13:40:35.479Z" }, + { url = "https://files.pythonhosted.org/packages/e6/9f/eaac5df17a3672fef0081b6c1bb0b82b33ee89aa5cec0d7b05f52fd4a1fa/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:97aeba56665b4c3235a0e52b2c2f5ae9cd071b8a8310ad27bddb3f7fb30e9aa2", size = 2332186, upload-time = "2025-11-04T13:40:37.436Z" }, + { url = "https://files.pythonhosted.org/packages/cf/4e/35a80cae583a37cf15604b44240e45c05e04e86f9cfd766623149297e971/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:406bf18d345822d6c21366031003612b9c77b3e29ffdb0f612367352aab7d586", size = 2073164, upload-time = "2025-11-04T13:40:40.289Z" }, + { url = "https://files.pythonhosted.org/packages/bf/e3/f6e262673c6140dd3305d144d032f7bd5f7497d3871c1428521f19f9efa2/pydantic_core-2.41.5-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:b93590ae81f7010dbe380cdeab6f515902ebcbefe0b9327cc4804d74e93ae69d", size = 2179146, upload-time = "2025-11-04T13:40:42.809Z" }, + { url = "https://files.pythonhosted.org/packages/75/c7/20bd7fc05f0c6ea2056a4565c6f36f8968c0924f19b7d97bbfea55780e73/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:01a3d0ab748ee531f4ea6c3e48ad9dac84ddba4b0d82291f87248f2f9de8d740", size = 2137788, upload-time = "2025-11-04T13:40:44.752Z" }, + { url = "https://files.pythonhosted.org/packages/3a/8d/34318ef985c45196e004bc46c6eab2eda437e744c124ef0dbe1ff2c9d06b/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:6561e94ba9dacc9c61bce40e2d6bdc3bfaa0259d3ff36ace3b1e6901936d2e3e", size = 2340133, upload-time = "2025-11-04T13:40:46.66Z" }, + { url = "https://files.pythonhosted.org/packages/9c/59/013626bf8c78a5a5d9350d12e7697d3d4de951a75565496abd40ccd46bee/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:915c3d10f81bec3a74fbd4faebe8391013ba61e5a1a8d48c4455b923bdda7858", size = 2324852, upload-time = "2025-11-04T13:40:48.575Z" }, + { url = "https://files.pythonhosted.org/packages/1a/d9/c248c103856f807ef70c18a4f986693a46a8ffe1602e5d361485da502d20/pydantic_core-2.41.5-cp313-cp313-win32.whl", hash = "sha256:650ae77860b45cfa6e2cdafc42618ceafab3a2d9a3811fcfbd3bbf8ac3c40d36", size = 1994679, upload-time = "2025-11-04T13:40:50.619Z" }, + { url = "https://files.pythonhosted.org/packages/9e/8b/341991b158ddab181cff136acd2552c9f35bd30380422a639c0671e99a91/pydantic_core-2.41.5-cp313-cp313-win_amd64.whl", hash = "sha256:79ec52ec461e99e13791ec6508c722742ad745571f234ea6255bed38c6480f11", size = 2019766, upload-time = "2025-11-04T13:40:52.631Z" }, + { url = "https://files.pythonhosted.org/packages/73/7d/f2f9db34af103bea3e09735bb40b021788a5e834c81eedb541991badf8f5/pydantic_core-2.41.5-cp313-cp313-win_arm64.whl", hash = "sha256:3f84d5c1b4ab906093bdc1ff10484838aca54ef08de4afa9de0f5f14d69639cd", size = 1981005, upload-time = "2025-11-04T13:40:54.734Z" }, ] [[package]] name = "pydantic-settings" -version = "2.12.0" +version = "2.13.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pydantic" }, { name = "python-dotenv" }, { name = "typing-inspection" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/43/4b/ac7e0aae12027748076d72a8764ff1c9d82ca75a7a52622e67ed3f765c54/pydantic_settings-2.12.0.tar.gz", hash = "sha256:005538ef951e3c2a68e1c08b292b5f2e71490def8589d4221b95dab00dafcfd0", size = 194184, upload-time = "2025-11-10T14:25:47.013Z" } +sdist = { url = "https://files.pythonhosted.org/packages/96/a1/ae859ffac5a3338a66b74c5e29e244fd3a3cc483c89feaf9f56c39898d75/pydantic_settings-2.13.0.tar.gz", hash = "sha256:95d875514610e8595672800a5c40b073e99e4aae467fa7c8f9c263061ea2e1fe", size = 222450, upload-time = "2026-02-15T12:11:23.476Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/c1/60/5d4751ba3f4a40a6891f24eec885f51afd78d208498268c734e256fb13c4/pydantic_settings-2.12.0-py3-none-any.whl", hash = "sha256:fddb9fd99a5b18da837b29710391e945b1e30c135477f484084ee513adb93809", size = 51880, upload-time = "2025-11-10T14:25:45.546Z" }, + { url = "https://files.pythonhosted.org/packages/b0/1a/dd1b9d7e627486cf8e7523d09b70010e05a4bc41414f4ae6ce184cf0afb6/pydantic_settings-2.13.0-py3-none-any.whl", hash = "sha256:d67b576fff39cd086b595441bf9c75d4193ca9c0ed643b90360694d0f1240246", size = 58429, upload-time = "2026-02-15T12:11:22.133Z" }, ] [package.optional-dependencies] @@ -332,6 +373,31 @@ yaml = [ { name = "pyyaml" }, ] +[[package]] +name = "pygments" +version = "2.19.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" }, +] + +[[package]] +name = "pytest" +version = "9.0.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d1/db/7ef3487e0fb0049ddb5ce41d3a49c235bf9ad299b6a25d5780a89f19230f/pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11", size = 1568901, upload-time = "2025-12-06T21:30:51.014Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801, upload-time = "2025-12-06T21:30:49.154Z" }, +] + [[package]] name = "python-dotenv" version = "1.2.1" @@ -347,16 +413,16 @@ version = "6.0.3" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" }, - { url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" }, - { url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" }, - { url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" }, - { url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" }, - { url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" }, - { url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" }, - { url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" }, - { url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" }, - { url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" }, + { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" }, + { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" }, + { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" }, + { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" }, + { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" }, + { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" }, + { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" }, + { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" }, + { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" }, + { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" }, ] [[package]] @@ -390,7 +456,6 @@ version = "0.52.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, - { name = "typing-extensions" }, ] sdist = { url = "https://files.pythonhosted.org/packages/c4/68/79977123bb7be889ad680d79a40f339082c1978b5cfcf62c2d8d196873ac/starlette-0.52.1.tar.gz", hash = "sha256:834edd1b0a23167694292e94f597773bc3f89f362be6effee198165a35d62933", size = 2653702, upload-time = "2026-01-18T13:34:11.062Z" } wheels = [ @@ -420,15 +485,15 @@ wheels = [ [[package]] name = "uvicorn" -version = "0.40.0" +version = "0.41.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "click" }, { name = "h11" }, ] -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" } +sdist = { url = "https://files.pythonhosted.org/packages/32/ce/eeb58ae4ac36fe09e3842eb02e0eb676bf2c53ae062b98f1b2531673efdd/uvicorn-0.41.0.tar.gz", hash = "sha256:09d11cf7008da33113824ee5a1c6422d89fbc2ff476540d69a34c87fab8b571a", size = 82633, upload-time = "2026-02-16T23:07:24.1Z" } wheels = [ - { 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" }, + { url = "https://files.pythonhosted.org/packages/83/e4/d04a086285c20886c0daad0e026f250869201013d18f81d9ff5eada73a88/uvicorn-0.41.0-py3-none-any.whl", hash = "sha256:29e35b1d2c36a04b9e180d4007ede3bcb32a85fbdfd6c6aeb3f26839de088187", size = 68783, upload-time = "2026-02-16T23:07:22.357Z" }, ] [[package]] @@ -437,12 +502,12 @@ version = "0.22.1" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/06/f0/18d39dbd1971d6d62c4629cc7fa67f74821b0dc1f5a77af43719de7936a7/uvloop-0.22.1.tar.gz", hash = "sha256:6c84bae345b9147082b17371e3dd5d42775bddce91f885499017f4607fdaf39f", size = 2443250, upload-time = "2025-10-16T22:17:19.342Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/3d/ff/7f72e8170be527b4977b033239a83a68d5c881cc4775fca255c677f7ac5d/uvloop-0.22.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:fe94b4564e865d968414598eea1a6de60adba0c040ba4ed05ac1300de402cd42", size = 1359936, upload-time = "2025-10-16T22:16:29.436Z" }, - { url = "https://files.pythonhosted.org/packages/c3/c6/e5d433f88fd54d81ef4be58b2b7b0cea13c442454a1db703a1eea0db1a59/uvloop-0.22.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:51eb9bd88391483410daad430813d982010f9c9c89512321f5b60e2cddbdddd6", size = 752769, upload-time = "2025-10-16T22:16:30.493Z" }, - { url = "https://files.pythonhosted.org/packages/24/68/a6ac446820273e71aa762fa21cdcc09861edd3536ff47c5cd3b7afb10eeb/uvloop-0.22.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:700e674a166ca5778255e0e1dc4e9d79ab2acc57b9171b79e65feba7184b3370", size = 4317413, upload-time = "2025-10-16T22:16:31.644Z" }, - { url = "https://files.pythonhosted.org/packages/5f/6f/e62b4dfc7ad6518e7eff2516f680d02a0f6eb62c0c212e152ca708a0085e/uvloop-0.22.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7b5b1ac819a3f946d3b2ee07f09149578ae76066d70b44df3fa990add49a82e4", size = 4426307, upload-time = "2025-10-16T22:16:32.917Z" }, - { url = "https://files.pythonhosted.org/packages/90/60/97362554ac21e20e81bcef1150cb2a7e4ffdaf8ea1e5b2e8bf7a053caa18/uvloop-0.22.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e047cc068570bac9866237739607d1313b9253c3051ad84738cbb095be0537b2", size = 4131970, upload-time = "2025-10-16T22:16:34.015Z" }, - { url = "https://files.pythonhosted.org/packages/99/39/6b3f7d234ba3964c428a6e40006340f53ba37993f46ed6e111c6e9141d18/uvloop-0.22.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:512fec6815e2dd45161054592441ef76c830eddaad55c8aa30952e6fe1ed07c0", size = 4296343, upload-time = "2025-10-16T22:16:35.149Z" }, + { url = "https://files.pythonhosted.org/packages/89/8c/182a2a593195bfd39842ea68ebc084e20c850806117213f5a299dfc513d9/uvloop-0.22.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:561577354eb94200d75aca23fbde86ee11be36b00e52a4eaf8f50fb0c86b7705", size = 1358611, upload-time = "2025-10-16T22:16:36.833Z" }, + { url = "https://files.pythonhosted.org/packages/d2/14/e301ee96a6dc95224b6f1162cd3312f6d1217be3907b79173b06785f2fe7/uvloop-0.22.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:1cdf5192ab3e674ca26da2eada35b288d2fa49fdd0f357a19f0e7c4e7d5077c8", size = 751811, upload-time = "2025-10-16T22:16:38.275Z" }, + { url = "https://files.pythonhosted.org/packages/b7/02/654426ce265ac19e2980bfd9ea6590ca96a56f10c76e63801a2df01c0486/uvloop-0.22.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6e2ea3d6190a2968f4a14a23019d3b16870dd2190cd69c8180f7c632d21de68d", size = 4288562, upload-time = "2025-10-16T22:16:39.375Z" }, + { url = "https://files.pythonhosted.org/packages/15/c0/0be24758891ef825f2065cd5db8741aaddabe3e248ee6acc5e8a80f04005/uvloop-0.22.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0530a5fbad9c9e4ee3f2b33b148c6a64d47bbad8000ea63704fa8260f4cf728e", size = 4366890, upload-time = "2025-10-16T22:16:40.547Z" }, + { url = "https://files.pythonhosted.org/packages/d2/53/8369e5219a5855869bcee5f4d317f6da0e2c669aecf0ef7d371e3d084449/uvloop-0.22.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bc5ef13bbc10b5335792360623cc378d52d7e62c2de64660616478c32cd0598e", size = 4119472, upload-time = "2025-10-16T22:16:41.694Z" }, + { url = "https://files.pythonhosted.org/packages/f8/ba/d69adbe699b768f6b29a5eec7b47dd610bd17a69de51b251126a801369ea/uvloop-0.22.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1f38ec5e3f18c8a10ded09742f7fb8de0108796eb673f30ce7762ce1b8550cad", size = 4239051, upload-time = "2025-10-16T22:16:43.224Z" }, ] [[package]] From 35864f65d722b40ce8bbef15b5a5e17944adf96d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C6=B0u=20Quang=20V=C5=A9?= Date: Wed, 18 Feb 2026 16:54:37 +0700 Subject: [PATCH 138/291] Upgrade to fully support Python 3.13 --- .github/workflows/docker.yaml | 2 +- app/models/__init__.py | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/docker.yaml b/.github/workflows/docker.yaml index 775e9e4..11caa57 100644 --- a/.github/workflows/docker.yaml +++ b/.github/workflows/docker.yaml @@ -50,7 +50,7 @@ jobs: type=semver,pattern={{version}} type=semver,pattern={{major}}.{{minor}} type=semver,pattern={{major}} - type=sha,format=short + type=raw,value={{date 'YYYYMMDD'}} type=raw,value=latest,enable={{is_default_branch}} - name: Build and push Docker image diff --git a/app/models/__init__.py b/app/models/__init__.py index a72efdc..3896de1 100644 --- a/app/models/__init__.py +++ b/app/models/__init__.py @@ -19,6 +19,7 @@ ResponseOutputMessage, ResponseToolCall, ResponseToolChoice, + ResponseUsage, Tool, ToolCall, ToolChoiceFunction, @@ -48,6 +49,7 @@ "ResponseOutputMessage", "ResponseToolCall", "ResponseToolChoice", + "ResponseUsage", "Tool", "ToolCall", "ToolChoiceFunction", From a48c38d16fab0a0c27a716ee25bf12842c8bd9a7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C6=B0u=20Quang=20V=C5=A9?= Date: Wed, 18 Feb 2026 17:19:51 +0700 Subject: [PATCH 139/291] Upgrade to fully support Python 3.13 --- .github/workflows/docker.yaml | 2 +- app/models/models.py | 130 +++++++++++++++++----------------- 2 files changed, 67 insertions(+), 65 deletions(-) diff --git a/.github/workflows/docker.yaml b/.github/workflows/docker.yaml index 11caa57..1c5a2ee 100644 --- a/.github/workflows/docker.yaml +++ b/.github/workflows/docker.yaml @@ -50,7 +50,7 @@ jobs: type=semver,pattern={{version}} type=semver,pattern={{major}}.{{minor}} type=semver,pattern={{major}} - type=raw,value={{date 'YYYYMMDD'}} + type=raw,value={{date 'YYYYMMDD'}}-{{sha}} type=raw,value=latest,enable={{is_default_branch}} - name: Build and push Docker image diff --git a/app/models/models.py b/app/models/models.py index ca206b7..3b3e627 100644 --- a/app/models/models.py +++ b/app/models/models.py @@ -10,10 +10,10 @@ class ContentItem(BaseModel): """Individual content item (text, image, or file) within a message.""" type: Literal["text", "image_url", "file", "input_audio"] - text: str | None = None - image_url: dict[str, str] | None = None - input_audio: dict[str, Any] | None = None - file: dict[str, str] | None = None + text: str | None = Field(default=None) + image_url: dict[str, Any] | None = Field(default=None) + input_audio: dict[str, Any] | None = Field(default=None) + file: dict[str, Any] | None = Field(default=None) annotations: list[dict[str, Any]] = Field(default_factory=list) @@ -21,13 +21,13 @@ class Message(BaseModel): """Message model""" role: str - content: str | list[ContentItem] | None = None - name: str | None = None - tool_calls: list[ToolCall] | None = None - tool_call_id: str | None = None - refusal: str | None = None - reasoning_content: str | None = None - audio: dict[str, Any] | None = None + content: str | list[ContentItem] | None = Field(default=None) + name: str | None = Field(default=None) + tool_calls: list[ToolCall] | None = Field(default=None) + tool_call_id: str | None = Field(default=None) + refusal: str | None = Field(default=None) + reasoning_content: str | None = Field(default=None) + audio: dict[str, Any] | None = Field(default=None) annotations: list[dict[str, Any]] = Field(default_factory=list) @model_validator(mode="after") @@ -44,7 +44,7 @@ class Choice(BaseModel): index: int message: Message finish_reason: str - logprobs: dict[str, Any] | None = None + logprobs: dict[str, Any] | None = Field(default=None) class FunctionCall(BaseModel): @@ -66,8 +66,8 @@ class ToolFunctionDefinition(BaseModel): """Function definition for tool.""" name: str - description: str | None = None - parameters: dict[str, Any] | None = None + description: str | None = Field(default=None) + parameters: dict[str, Any] | None = Field(default=None) class Tool(BaseModel): @@ -96,8 +96,8 @@ class Usage(BaseModel): prompt_tokens: int completion_tokens: int total_tokens: int - prompt_tokens_details: dict[str, int] | None = None - completion_tokens_details: dict[str, int] | None = None + prompt_tokens_details: dict[str, int] | None = Field(default=None) + completion_tokens_details: dict[str, int] | None = Field(default=None) class ModelData(BaseModel): @@ -114,16 +114,16 @@ class ChatCompletionRequest(BaseModel): model: str messages: list[Message] - stream: bool | None = False - user: str | None = None - temperature: float | None = 0.7 - top_p: float | None = 1.0 - max_tokens: int | None = None - tools: list[Tool] | None = None + stream: bool | None = Field(default=False) + user: str | None = Field(default=None) + temperature: float | None = Field(default=0.7) + top_p: float | None = Field(default=1.0) + max_tokens: int | None = Field(default=None) + tools: list[Tool] | None = Field(default=None) tool_choice: ( Literal["none"] | Literal["auto"] | Literal["required"] | ToolChoiceFunction | None - ) = None - response_format: dict[str, Any] | None = None + ) = Field(default=None) + response_format: dict[str, Any] | None = Field(default=None) class ChatCompletionResponse(BaseModel): @@ -148,9 +148,9 @@ class HealthCheckResponse(BaseModel): """Health check response model""" ok: bool - storage: dict[str, str | int] | None = None - clients: dict[str, bool] | None = None - error: str | None = None + storage: dict[str, Any] | None = Field(default=None) + clients: dict[str, bool] | None = Field(default=None) + error: str | None = Field(default=None) class ConversationInStore(BaseModel): @@ -172,12 +172,12 @@ class ResponseInputContent(BaseModel): """Content item for Responses API input.""" type: Literal["input_text", "input_image", "input_file"] - text: str | None = None - image_url: str | None = None - detail: Literal["auto", "low", "high"] | None = None - file_url: str | None = None - file_data: str | None = None - filename: str | None = None + text: str | None = Field(default=None) + image_url: str | None = Field(default=None) + detail: Literal["auto", "low", "high"] | None = Field(default=None) + file_url: str | None = Field(default=None) + file_data: str | None = Field(default=None) + filename: str | None = Field(default=None) annotations: list[dict[str, Any]] = Field(default_factory=list) @model_validator(mode="before") @@ -192,7 +192,7 @@ def normalize_output_text(cls, data: Any) -> Any: class ResponseInputItem(BaseModel): """Single input item for Responses API.""" - type: Literal["message"] | None = "message" + type: Literal["message"] | None = Field(default="message") role: Literal["user", "assistant", "system", "developer"] content: str | list[ResponseInputContent] @@ -201,15 +201,15 @@ class ResponseToolChoice(BaseModel): """Tool choice enforcing a specific tool in Responses API.""" type: Literal["function", "image_generation"] - function: ToolChoiceFunctionDetail | None = None + function: ToolChoiceFunctionDetail | None = Field(default=None) class ResponseImageTool(BaseModel): """Image generation tool specification for Responses API.""" type: Literal["image_generation"] - model: str | None = None - output_format: str | None = None + model: str | None = Field(default=None) + output_format: str | None = Field(default=None) class ResponseCreateRequest(BaseModel): @@ -217,17 +217,17 @@ class ResponseCreateRequest(BaseModel): model: str input: str | list[ResponseInputItem] - instructions: str | list[ResponseInputItem] | None = None - temperature: float | None = 0.7 - top_p: float | None = 1.0 - max_output_tokens: int | None = None - stream: bool | None = False - tool_choice: str | ResponseToolChoice | None = None - tools: list[Tool | ResponseImageTool] | None = None - store: bool | None = None - user: str | None = None - response_format: dict[str, Any] | None = None - metadata: dict[str, Any] | None = None + instructions: str | list[ResponseInputItem] | None = Field(default=None) + temperature: float | None = Field(default=0.7) + top_p: float | None = Field(default=1.0) + max_output_tokens: int | None = Field(default=None) + stream: bool | None = Field(default=False) + tool_choice: str | ResponseToolChoice | None = Field(default=None) + tools: list[Tool | ResponseImageTool] | None = Field(default=None) + store: bool | None = Field(default=None) + user: str | None = Field(default=None) + response_format: dict[str, Any] | None = Field(default=None) + metadata: dict[str, Any] | None = Field(default=None) class ResponseUsage(BaseModel): @@ -242,7 +242,7 @@ class ResponseOutputContent(BaseModel): """Content item for Responses API output.""" type: Literal["output_text"] - text: str | None = "" + text: str | None = Field(default="") annotations: list[dict[str, Any]] = Field(default_factory=list) @@ -259,20 +259,22 @@ class ResponseImageGenerationCall(BaseModel): """Image generation call record emitted in Responses API.""" id: str - type: Literal["image_generation_call"] = "image_generation_call" - status: Literal["completed", "in_progress", "generating", "failed"] = "completed" - result: str | None = None - output_format: str | None = None - size: str | None = None - revised_prompt: str | None = None + type: Literal["image_generation_call"] = Field(default="image_generation_call") + status: Literal["completed", "in_progress", "generating", "failed"] = Field(default="completed") + result: str | None = Field(default=None) + output_format: str | None = Field(default=None) + size: str | None = Field(default=None) + revised_prompt: str | None = Field(default=None) class ResponseToolCall(BaseModel): """Tool call record emitted in Responses API.""" id: str - type: Literal["tool_call"] = "tool_call" - status: Literal["in_progress", "completed", "failed", "requires_action"] = "completed" + type: Literal["tool_call"] = Field(default="tool_call") + status: Literal["in_progress", "completed", "failed", "requires_action"] = Field( + default="completed" + ) function: FunctionCall @@ -280,7 +282,7 @@ class ResponseCreateResponse(BaseModel): """Responses API response payload.""" id: str - object: Literal["response"] = "response" + object: Literal["response"] = Field(default="response") created_at: int model: str output: list[ResponseOutputMessage | ResponseImageGenerationCall | ResponseToolCall] @@ -291,13 +293,13 @@ class ResponseCreateResponse(BaseModel): "incomplete", "cancelled", "requires_action", - ] = "completed" - tool_choice: str | ResponseToolChoice | None = None - tools: list[Tool | ResponseImageTool] | None = None + ] = Field(default="completed") + tool_choice: str | ResponseToolChoice | None = Field(default=None) + tools: list[Tool | ResponseImageTool] | None = Field(default=None) usage: ResponseUsage - error: dict[str, Any] | None = None - metadata: dict[str, Any] | None = None - input: str | list[ResponseInputItem] | None = None + error: dict[str, Any] | None = Field(default=None) + metadata: dict[str, Any] | None = Field(default=None) + input: str | list[ResponseInputItem] | None = Field(default=None) # Rebuild models with forward references From 0540c4f16df0a2aef8558c6bd30f6a34dd733fb2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C6=B0u=20Quang=20V=C5=A9?= Date: Sun, 22 Feb 2026 10:25:08 +0700 Subject: [PATCH 140/291] Refactor regex patterns for tool and chat message processing in helper module --- Dockerfile | 3 +- app/server/chat.py | 285 +++++++------------------------------------- app/utils/helper.py | 74 ++++++++---- 3 files changed, 100 insertions(+), 262 deletions(-) diff --git a/Dockerfile b/Dockerfile index ef7f41e..62ce9d1 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,6 +1,7 @@ FROM ghcr.io/astral-sh/uv:python3.13-trixie-slim -LABEL org.opencontainers.image.description="Web-based Gemini models wrapped into an OpenAI-compatible API." +LABEL org.opencontainers.image.title="Gemini-FastAPI" \ + org.opencontainers.image.description="Web-based Gemini models wrapped into an OpenAI-compatible API." WORKDIR /app diff --git a/app/server/chat.py b/app/server/chat.py index 3849af5..b7c0564 100644 --- a/app/server/chat.py +++ b/app/server/chat.py @@ -48,8 +48,8 @@ from app.services import GeminiClientPool, GeminiClientWrapper, LMDBConversationStore from app.utils import g_config from app.utils.helper import ( - TOOL_HINT_LINE_END, - TOOL_HINT_LINE_START, + STREAM_MASTER_RE, + STREAM_TAIL_RE, TOOL_HINT_STRIPPED, TOOL_WRAP_HINT, detect_image_extension, @@ -751,275 +751,80 @@ async def _send_with_split( class StreamingOutputFilter: """ Filter to suppress technical protocol markers, tool calls, and system hints from the stream. - Uses a state machine to handle fragmentation where markers are split across multiple chunks. + Uses a high-performance regex state machine to handle fragmented markers. """ def __init__(self): self.buffer = "" self.state = "NORMAL" self.current_role = "" - self.block_buffer = "" - - self.STATE_MARKERS = { - "TOOL": { - "starts": ["[ToolCalls]", "\\[ToolCalls\\]"], - "ends": ["[/ToolCalls]", "\\[\\/ToolCalls\\]"], - }, - "ORPHAN": { - "starts": ["[Call:", "\\[Call\\:"], - "ends": ["[/Call]", "\\[\\/Call\\]"], - }, - "RESP": { - "starts": ["[ToolResults]", "\\[ToolResults\\]"], - "ends": ["[/ToolResults]", "\\[\\/ToolResults\\]"], - }, - "ARG": { - "starts": ["[CallParameter:", "\\[CallParameter\\:"], - "ends": ["[/CallParameter]", "\\[\\/CallParameter\\]"], - }, - "RESULT": { - "starts": ["[ToolResult]", "\\[ToolResult\\]"], - "ends": ["[/ToolResult]", "\\[\\/ToolResult\\]"], - }, - "ITEM": { - "starts": ["[Result:", "\\[Result\\:"], - "ends": ["[/Result]", "\\[\\/Result\\]"], - }, - "TAG": { - "starts": ["<|im_start|>", "\\<\\|im\\_start\\|\\>"], - "ends": ["<|im_end|>", "\\<\\|im\\_end\\|\\>"], - }, - } - - hint_start = f"\n{TOOL_HINT_LINE_START}" if TOOL_HINT_LINE_START else "" - if hint_start: - self.STATE_MARKERS["HINT"] = { - "starts": [hint_start], - "ends": [TOOL_HINT_LINE_END], - } - - self.ORPHAN_ENDS = [ - "<|im_end|>", - "\\<\\|im\\_end\\|\\>", - "[/Call]", - "\\[\\/Call\\]", - "[/ToolCalls]", - "\\[\\/ToolCalls\\]", - "[/CallParameter]", - "\\[\\/CallParameter\\]", - "[/ToolResult]", - "\\[\\/ToolResult\\]", - "[/ToolResults]", - "\\[\\/ToolResults\\]", - "[/Result]", - "\\[\\/Result\\]", - ] - - self.WATCH_MARKERS = [] - for cfg in self.STATE_MARKERS.values(): - self.WATCH_MARKERS.extend(cfg["starts"]) - self.WATCH_MARKERS.extend(cfg.get("ends", [])) - self.WATCH_MARKERS.extend(self.ORPHAN_ENDS) + self.in_chatml = False def process(self, chunk: str) -> str: self.buffer += chunk output = [] while self.buffer: - buf_low = self.buffer.lower() - if self.state == "NORMAL": - indices = [] - for m_type, cfg in self.STATE_MARKERS.items(): - for p in cfg["starts"]: - idx = buf_low.find(p.lower()) - if idx != -1: - indices.append((idx, m_type, len(p))) - - for p in self.ORPHAN_ENDS: - idx = buf_low.find(p.lower()) - if idx != -1: - indices.append((idx, "SKIP", len(p))) - - if not indices: - keep_len = 0 - for marker in self.WATCH_MARKERS: - m_low = marker.lower() - for i in range(len(m_low) - 1, 0, -1): - if buf_low.endswith(m_low[:i]): - keep_len = max(keep_len, i) - break - yield_len = len(self.buffer) - keep_len - if yield_len > 0: - output.append(self.buffer[:yield_len]) - self.buffer = self.buffer[yield_len:] - break - - indices.sort() - idx, m_type, m_len = indices[0] - output.append(self.buffer[:idx]) - self.buffer = self.buffer[idx:] - - if m_type == "SKIP": - self.buffer = self.buffer[m_len:] - continue - - self.state = f"IN_{m_type}" - if m_type in ("TOOL", "ORPHAN"): - self.block_buffer = "" - - self.buffer = self.buffer[m_len:] - - elif self.state == "IN_HINT": - cfg = self.STATE_MARKERS["HINT"] - found_idx, found_len = -1, 0 - for p in cfg["ends"]: - idx = buf_low.find(p.lower()) - if idx != -1 and (found_idx == -1 or idx < found_idx): - found_idx, found_len = idx, len(p) - - if found_idx != -1: - self.buffer = self.buffer[found_idx + found_len :] - self.state = "NORMAL" - else: - max_end_len = max(len(p) for p in cfg["ends"]) - if len(self.buffer) > max_end_len: - self.buffer = self.buffer[-max_end_len:] - break - - elif self.state == "IN_ARG": - cfg = self.STATE_MARKERS["ARG"] - found_idx, found_len = -1, 0 - for p in cfg["ends"]: - idx = buf_low.find(p.lower()) - if idx != -1 and (found_idx == -1 or idx < found_idx): - found_idx, found_len = idx, len(p) - - if found_idx != -1: - self.buffer = self.buffer[found_idx + found_len :] - self.state = "NORMAL" - else: - max_end_len = max(len(p) for p in cfg["ends"]) - if len(self.buffer) > max_end_len: - self.buffer = self.buffer[-max_end_len:] - break - - elif self.state == "IN_RESULT": - cfg = self.STATE_MARKERS["RESULT"] - found_idx, found_len = -1, 0 - for p in cfg["ends"]: - idx = buf_low.find(p.lower()) - if idx != -1 and (found_idx == -1 or idx < found_idx): - found_idx, found_len = idx, len(p) - - if found_idx != -1: - self.buffer = self.buffer[found_idx + found_len :] - self.state = "NORMAL" - else: - max_end_len = max(len(p) for p in cfg["ends"]) - if len(self.buffer) > max_end_len: - self.buffer = self.buffer[-max_end_len:] - break - - elif self.state == "IN_RESP": - cfg = self.STATE_MARKERS["RESP"] - found_idx, found_len = -1, 0 - for p in cfg["ends"]: - idx = buf_low.find(p.lower()) - if idx != -1 and (found_idx == -1 or idx < found_idx): - found_idx, found_len = idx, len(p) - - if found_idx != -1: - self.buffer = self.buffer[found_idx + found_len :] - self.state = "NORMAL" - else: - break - - elif self.state == "IN_TOOL": - cfg = self.STATE_MARKERS["TOOL"] - found_idx, found_len = -1, 0 - for p in cfg["ends"]: - idx = buf_low.find(p.lower()) - if idx != -1 and (found_idx == -1 or idx < found_idx): - found_idx, found_len = idx, len(p) - - if found_idx != -1: - self.block_buffer += self.buffer[:found_idx] - self.buffer = self.buffer[found_idx + found_len :] - self.state = "NORMAL" - else: - max_end_len = max(len(p) for p in cfg["ends"]) - if len(self.buffer) > max_end_len: - self.block_buffer += self.buffer[:-max_end_len] - self.buffer = self.buffer[-max_end_len:] - break - - elif self.state == "IN_ORPHAN": - cfg = self.STATE_MARKERS["ORPHAN"] - found_idx, found_len = -1, 0 - for p in cfg["ends"]: - idx = buf_low.find(p.lower()) - if idx != -1 and (found_idx == -1 or idx < found_idx): - found_idx, found_len = idx, len(p) - - if found_idx != -1: - self.block_buffer += self.buffer[:found_idx] - self.buffer = self.buffer[found_idx + found_len :] - self.state = "NORMAL" - else: - max_end_len = max(len(p) for p in cfg["ends"]) - if len(self.buffer) > max_end_len: - self.block_buffer += self.buffer[:-max_end_len] - self.buffer = self.buffer[-max_end_len:] - break - - elif self.state == "IN_TAG": + if self.state == "IN_TAG_HEADER": nl_idx = self.buffer.find("\n") if nl_idx != -1: self.current_role = self.buffer[:nl_idx].strip().lower() self.buffer = self.buffer[nl_idx + 1 :] self.state = "IN_BLOCK" + self.in_chatml = True + continue else: break - elif self.state == "IN_BLOCK": - cfg = self.STATE_MARKERS["TAG"] - found_idx, found_len = -1, 0 - for p in cfg["ends"]: - idx = buf_low.find(p.lower()) - if idx != -1 and (found_idx == -1 or idx < found_idx): - found_idx, found_len = idx, len(p) - - if found_idx != -1: - content = self.buffer[:found_idx] - if self.current_role != "tool": - output.append(content) - self.buffer = self.buffer[found_idx + found_len :] - self.state = "NORMAL" - self.current_role = "" + match = STREAM_MASTER_RE.search(self.buffer) + if not match: + tail_match = STREAM_TAIL_RE.search(self.buffer) + keep_len = len(tail_match.group(0)) if tail_match else 0 + yield_len = len(self.buffer) - keep_len + if yield_len > 0: + if self.state == "NORMAL" or ( + self.state == "IN_BLOCK" and self.current_role != "tool" + ): + output.append(self.buffer[:yield_len]) + self.buffer = self.buffer[yield_len:] + break + + start, end = match.span() + matched_group = match.lastgroup + pre_text = self.buffer[:start] + + if self.state == "NORMAL" or (self.state == "IN_BLOCK" and self.current_role != "tool"): + output.append(pre_text) + + if matched_group.endswith("_START"): + m_type = matched_group.split("_")[0] + if m_type == "TAG": + self.state = "IN_TAG_HEADER" else: - max_end_len = max(len(p) for p in cfg["ends"]) - if self.current_role != "tool": - if len(self.buffer) > max_end_len: - output.append(self.buffer[:-max_end_len]) - self.buffer = self.buffer[-max_end_len:] - break - else: - if len(self.buffer) > max_end_len: - self.buffer = self.buffer[-max_end_len:] - break + self.state = f"IN_{m_type}" + elif matched_group in ("PROTOCOL_EXIT", "HINT_EXIT"): + self.state = "IN_BLOCK" if self.in_chatml else "NORMAL" + elif matched_group == "TAG_EXIT": + self.state = "NORMAL" + self.in_chatml = False + self.current_role = "" + + self.buffer = self.buffer[end:] return "".join(output) def flush(self) -> str: """Release remaining buffer content and perform final cleanup at stream end.""" res = "" - if self.state in ("IN_TOOL", "IN_ORPHAN", "IN_RESP", "IN_HINT", "IN_ARG", "IN_RESULT"): - res = "" - elif (self.state == "IN_BLOCK" and self.current_role != "tool") or self.state == "NORMAL": + if self.state == "NORMAL" or (self.state == "IN_BLOCK" and self.current_role != "tool"): res = self.buffer + tail_match = STREAM_TAIL_RE.search(res) + if tail_match: + res = res[: -len(tail_match.group(0))] self.buffer = "" self.state = "NORMAL" + self.in_chatml = False return strip_system_hints(res) diff --git a/app/utils/helper.py b/app/utils/helper.py index 002d401..e101e17 100644 --- a/app/utils/helper.py +++ b/app/utils/helper.py @@ -36,42 +36,76 @@ "CRITICAL: Do NOT mix natural language with protocol tags. Either respond naturally OR provide the protocol block alone. There is no middle ground.\n" ) TOOL_BLOCK_RE = re.compile( - r"(?:\[ToolCalls]|\\\[ToolCalls\\])\s*(.*?)\s*(?:\[/ToolCalls]|\\\[\\/ToolCalls\\])", + r"\\?\[ToolCalls\\?]\s*(.*?)\s*\\?\[\\?/?ToolCalls\\?]", re.DOTALL | re.IGNORECASE, ) TOOL_CALL_RE = re.compile( - r"(?:\[Call:|\\\[Call\\:)(?P(?:[^]\\]|\\.)+)(?:]|\\])\s*(?P.*?)\s*(?:\[/Call]|\\\[\\/Call\\])", + r"\\?\[Call\\?:\s*(?P(?:[^]\\]|\\.)+)\s*\\?]\s*(?P.*?)\s*\\?\[\\?/?Call\\?]", re.DOTALL | re.IGNORECASE, ) RESPONSE_BLOCK_RE = re.compile( - r"(?:\[ToolResults]|\\\[ToolResults\\])\s*(.*?)\s*(?:\[/ToolResults]|\\\[\\/ToolResults\\])", + r"\\?\[ToolResults\\?]\s*(.*?)\s*\\?\[\\?/?ToolResults\\?]", re.DOTALL | re.IGNORECASE, ) RESPONSE_ITEM_RE = re.compile( - r"(?:\[Result:|\\\[Result\\:)(?P(?:[^]\\]|\\.)+)(?:]|\\])\s*(?P.*?)\s*(?:\[/Result]|\\\[\\/Result\\])", + r"\\?\[Result\\?:\s*(?P(?:[^]\\]|\\.)+)\s*\\?]\s*(?P.*?)\s*\\?\[\\?/?Result\\?]", re.DOTALL | re.IGNORECASE, ) TAGGED_ARG_RE = re.compile( - r"(?:\[CallParameter:|\\\[CallParameter\\:)(?P(?:[^]\\]|\\.)+)(?:]|\\])\s*(?P.*?)\s*(?:\[/CallParameter]|\\\[\\/CallParameter\\])", + r"\\?\[CallParameter\\?:\s*(?P(?:[^]\\]|\\.)+)\s*\\?]\s*(?P.*?)\s*\\?\[\\?/?CallParameter\\?]", re.DOTALL | re.IGNORECASE, ) TAGGED_RESULT_RE = re.compile( - r"(?:\[ToolResult]|\\\[ToolResult\\])\s*(.*?)\s*(?:\[/ToolResult]|\\\[\\/ToolResult\\])", + r"\\?\[ToolResult\\?]\s*(.*?)\s*\\?\[\\?/?ToolResult\\?]", re.DOTALL | re.IGNORECASE, ) -CONTROL_TOKEN_RE = re.compile( - r"<\|im_(?:start|end)\|>|\\<\\\|im\\_(?:start|end)\\\|\\>", re.IGNORECASE -) -CHATML_START_RE = re.compile( - r"(?:<\|im_start\|>|\\<\\\|im\\_start\\\|\\>)\s*(\w+)\s*\n?", re.IGNORECASE -) -CHATML_END_RE = re.compile(r"<\|im_end\|>|\\<\\\|im\\_end\\\|\\>", re.IGNORECASE) +CONTROL_TOKEN_RE = re.compile(r"\\?<\\?\|im\\?_(?:start|end)\\?\|\\?>", re.IGNORECASE) +CHATML_START_RE = re.compile(r"\\?<\\?\|im\\?_start\\?\|\\?>\s*(\w+)\s*\n?", re.IGNORECASE) +CHATML_END_RE = re.compile(r"\\?<\\?\|im\\?_end\\?\|\\?>", re.IGNORECASE) COMMONMARK_UNESCAPE_RE = re.compile(r"\\([!\"#$%&'()*+,\-./:;<=>?@\[\\\]^_`{|}~])") PARAM_FENCE_RE = re.compile(r"^(?P`{3,})") TOOL_HINT_STRIPPED = TOOL_WRAP_HINT.strip() _hint_lines = [line.strip() for line in TOOL_WRAP_HINT.split("\n") if line.strip()] TOOL_HINT_LINE_START = _hint_lines[0] if _hint_lines else "" TOOL_HINT_LINE_END = _hint_lines[-1] if _hint_lines else "" +TOOL_HINT_START_ESC = re.escape(TOOL_HINT_LINE_START) if TOOL_HINT_LINE_START else "" +TOOL_HINT_END_ESC = re.escape(TOOL_HINT_LINE_END) if TOOL_HINT_LINE_END else "" + +HINT_FULL_RE = ( + re.compile(rf"\n?{TOOL_HINT_START_ESC}:?.*?{TOOL_HINT_END_ESC}\\.?\n?", re.DOTALL) + if TOOL_HINT_START_ESC and TOOL_HINT_END_ESC + else None +) +HINT_START_RE = re.compile(rf"\n?{TOOL_HINT_START_ESC}:?\s*") if TOOL_HINT_START_ESC else None +HINT_END_RE = re.compile(rf"\s*{TOOL_HINT_END_ESC}\.?\n?") if TOOL_HINT_END_ESC else None + +# --- Streaming Specific Patterns --- +_START_PATTERNS = { + "TOOL": r"\\?\[ToolCalls\\?\]", + "ORPHAN": r"\\?\[Call\\?:\s*[^\]\\]+\s*\\?\]", + "RESP": r"\\?\[ToolResults\\?\]", + "ARG": r"\\?\[CallParameter\\?:\s*[^\]\\]+\s*\\?\]", + "RESULT": r"\\?\[ToolResult\\?\]", + "ITEM": r"\\?\[Result\\?:\s*[^\]\\]+\s*\\?\]", + "TAG": r"\\?<\\?\|im\\?_start\\?\|\\?>", +} + +_PROTOCOL_ENDS = r"\\?\[\\?/(?:ToolCalls|Call|ToolResults|CallParameter|ToolResult|Result)\\?\]" +_TAG_END = r"\\?<\\?\|im\\?_end\\?\|\\?>" + +_master_parts = [f"(?P<{name}_START>{pattern})" for name, pattern in _START_PATTERNS.items()] +_master_parts.append(f"(?P{_PROTOCOL_ENDS})") +_master_parts.append(f"(?P{_TAG_END})") + +if TOOL_HINT_START_ESC and TOOL_HINT_END_ESC: + _START_PATTERNS["HINT"] = rf"\n?{TOOL_HINT_START_ESC}:?\s*" + _master_parts.append(f"(?P{TOOL_HINT_END_ESC}\\.?\n?)") + +STREAM_MASTER_RE = re.compile("|".join(_master_parts), re.IGNORECASE) +STREAM_TAIL_RE = re.compile( + r"(?:\\|\\?\[(?:T(?:o?o?l?)?|C(?:a?l?l?)?|R(?:e?s?u?l?t?)?|/)?[\w/:]*|\\?<\??\|?i?m?_?(?:s?t?a?r?t?|e?n?d?)\|?|)$", + re.IGNORECASE, +) def add_tag(role: str, content: str, unclose: bool = False) -> str: @@ -213,14 +247,12 @@ def strip_system_hints(text: str) -> str: cleaned = t_unescaped.replace(TOOL_WRAP_HINT, "").replace(TOOL_HINT_STRIPPED, "") - if TOOL_HINT_LINE_START and TOOL_HINT_LINE_END: - pattern = rf"\n?{re.escape(TOOL_HINT_LINE_START)}.*?{re.escape(TOOL_HINT_LINE_END)}\.?\n?" - cleaned = re.sub(pattern, "", cleaned, flags=re.DOTALL) - - if TOOL_HINT_LINE_START: - cleaned = re.sub(rf"\n?{re.escape(TOOL_HINT_LINE_START)}:?\s*", "", cleaned) - if TOOL_HINT_LINE_END: - cleaned = re.sub(rf"\s*{re.escape(TOOL_HINT_LINE_END)}\.?\n?", "", cleaned) + if HINT_FULL_RE: + cleaned = HINT_FULL_RE.sub("", cleaned) + if HINT_START_RE: + cleaned = HINT_START_RE.sub("", cleaned) + if HINT_END_RE: + cleaned = HINT_END_RE.sub("", cleaned) cleaned = strip_tagged_blocks(cleaned) cleaned = CONTROL_TOKEN_RE.sub("", cleaned) From 084eda7942a09030bc456f59184799790a7edb88 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C6=B0u=20Quang=20V=C5=A9?= Date: Sun, 22 Feb 2026 10:47:54 +0700 Subject: [PATCH 141/291] Update dependencies to latest versions --- pyproject.toml | 6 ++--- uv.lock | 60 +++++++++++++++++++++++++------------------------- 2 files changed, 33 insertions(+), 33 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index a1ae29d..6599122 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,13 +5,13 @@ description = "FastAPI Server built on Gemini Web API" readme = "README.md" requires-python = "==3.13.*" dependencies = [ - "fastapi>=0.129.0", + "fastapi>=0.129.2", "gemini-webapi>=1.19.2", "httptools>=0.7.1", "lmdb>=1.7.5", "loguru>=0.7.3", "orjson>=3.11.7", - "pydantic-settings[yaml]>=2.13.0", + "pydantic-settings[yaml]>=2.13.1", "uvicorn>=0.41.0", "uvloop>=0.22.1; sys_platform != 'win32'", ] @@ -22,7 +22,7 @@ Repository = "https://github.com/Nativu5/Gemini-FastAPI" [project.optional-dependencies] dev = [ "pytest>=9.0.2", - "ruff>=0.15.1", + "ruff>=0.15.2", ] [dependency-groups] diff --git a/uv.lock b/uv.lock index 4c819e7..4cae2c0 100644 --- a/uv.lock +++ b/uv.lock @@ -64,7 +64,7 @@ wheels = [ [[package]] name = "fastapi" -version = "0.129.0" +version = "0.129.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "annotated-doc" }, @@ -73,9 +73,9 @@ dependencies = [ { name = "typing-extensions" }, { name = "typing-inspection" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/48/47/75f6bea02e797abff1bca968d5997793898032d9923c1935ae2efdece642/fastapi-0.129.0.tar.gz", hash = "sha256:61315cebd2e65df5f97ec298c888f9de30430dd0612d59d6480beafbc10655af", size = 375450, upload-time = "2026-02-12T13:54:52.541Z" } +sdist = { url = "https://files.pythonhosted.org/packages/fd/cc/1b0d90ed759ff8c9dbc4800de7475d4e9256a81b97b45bd05a1affcb350a/fastapi-0.129.2.tar.gz", hash = "sha256:e2b3637a2b47856e704dbd9a3a09393f6df48e8b9cb6c7a3e26ba44d2053f9ab", size = 368211, upload-time = "2026-02-21T17:25:49.198Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/9e/dd/d0ee25348ac58245ee9f90b6f3cbb666bf01f69be7e0911f9851bddbda16/fastapi-0.129.0-py3-none-any.whl", hash = "sha256:b4946880e48f462692b31c083be0432275cbfb6e2274566b1be91479cc1a84ec", size = 102950, upload-time = "2026-02-12T13:54:54.528Z" }, + { url = "https://files.pythonhosted.org/packages/18/d0/a89a640308016c7fff8d2a47b86cc03ee7cca780b5079d0b69f466f9e1a9/fastapi-0.129.2-py3-none-any.whl", hash = "sha256:e21d9f6e8db376655187905ad0145edd6f6a4e5f2bff241c4efb8a0bffd6a540", size = 103227, upload-time = "2026-02-21T17:25:47.745Z" }, ] [[package]] @@ -107,15 +107,15 @@ dev = [ [package.metadata] requires-dist = [ - { name = "fastapi", specifier = ">=0.129.0" }, + { name = "fastapi", specifier = ">=0.129.2" }, { name = "gemini-webapi", specifier = ">=1.19.2" }, { name = "httptools", specifier = ">=0.7.1" }, { name = "lmdb", specifier = ">=1.7.5" }, { name = "loguru", specifier = ">=0.7.3" }, { name = "orjson", specifier = ">=3.11.7" }, - { name = "pydantic-settings", extras = ["yaml"], specifier = ">=2.13.0" }, + { name = "pydantic-settings", extras = ["yaml"], specifier = ">=2.13.1" }, { name = "pytest", marker = "extra == 'dev'", specifier = ">=9.0.2" }, - { name = "ruff", marker = "extra == 'dev'", specifier = ">=0.15.1" }, + { name = "ruff", marker = "extra == 'dev'", specifier = ">=0.15.2" }, { name = "uvicorn", specifier = ">=0.41.0" }, { name = "uvloop", marker = "sys_platform != 'win32'", specifier = ">=0.22.1" }, ] @@ -356,16 +356,16 @@ wheels = [ [[package]] name = "pydantic-settings" -version = "2.13.0" +version = "2.13.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pydantic" }, { name = "python-dotenv" }, { name = "typing-inspection" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/96/a1/ae859ffac5a3338a66b74c5e29e244fd3a3cc483c89feaf9f56c39898d75/pydantic_settings-2.13.0.tar.gz", hash = "sha256:95d875514610e8595672800a5c40b073e99e4aae467fa7c8f9c263061ea2e1fe", size = 222450, upload-time = "2026-02-15T12:11:23.476Z" } +sdist = { url = "https://files.pythonhosted.org/packages/52/6d/fffca34caecc4a3f97bda81b2098da5e8ab7efc9a66e819074a11955d87e/pydantic_settings-2.13.1.tar.gz", hash = "sha256:b4c11847b15237fb0171e1462bf540e294affb9b86db4d9aa5c01730bdbe4025", size = 223826, upload-time = "2026-02-19T13:45:08.055Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/b0/1a/dd1b9d7e627486cf8e7523d09b70010e05a4bc41414f4ae6ce184cf0afb6/pydantic_settings-2.13.0-py3-none-any.whl", hash = "sha256:d67b576fff39cd086b595441bf9c75d4193ca9c0ed643b90360694d0f1240246", size = 58429, upload-time = "2026-02-15T12:11:22.133Z" }, + { url = "https://files.pythonhosted.org/packages/00/4b/ccc026168948fec4f7555b9164c724cf4125eac006e176541483d2c959be/pydantic_settings-2.13.1-py3-none-any.whl", hash = "sha256:d56fd801823dbeae7f0975e1f8c8e25c258eb75d278ea7abb5d9cebb01b56237", size = 58929, upload-time = "2026-02-19T13:45:06.034Z" }, ] [package.optional-dependencies] @@ -427,27 +427,27 @@ wheels = [ [[package]] name = "ruff" -version = "0.15.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/04/dc/4e6ac71b511b141cf626357a3946679abeba4cf67bc7cc5a17920f31e10d/ruff-0.15.1.tar.gz", hash = "sha256:c590fe13fb57c97141ae975c03a1aedb3d3156030cabd740d6ff0b0d601e203f", size = 4540855, upload-time = "2026-02-12T23:09:09.998Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/23/bf/e6e4324238c17f9d9120a9d60aa99a7daaa21204c07fcd84e2ef03bb5fd1/ruff-0.15.1-py3-none-linux_armv6l.whl", hash = "sha256:b101ed7cf4615bda6ffe65bdb59f964e9f4a0d3f85cbf0e54f0ab76d7b90228a", size = 10367819, upload-time = "2026-02-12T23:09:03.598Z" }, - { url = "https://files.pythonhosted.org/packages/b3/ea/c8f89d32e7912269d38c58f3649e453ac32c528f93bb7f4219258be2e7ed/ruff-0.15.1-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:939c995e9277e63ea632cc8d3fae17aa758526f49a9a850d2e7e758bfef46602", size = 10798618, upload-time = "2026-02-12T23:09:22.928Z" }, - { url = "https://files.pythonhosted.org/packages/5e/0f/1d0d88bc862624247d82c20c10d4c0f6bb2f346559d8af281674cf327f15/ruff-0.15.1-py3-none-macosx_11_0_arm64.whl", hash = "sha256:1d83466455fdefe60b8d9c8df81d3c1bbb2115cede53549d3b522ce2bc703899", size = 10148518, upload-time = "2026-02-12T23:08:58.339Z" }, - { url = "https://files.pythonhosted.org/packages/f5/c8/291c49cefaa4a9248e986256df2ade7add79388fe179e0691be06fae6f37/ruff-0.15.1-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a9457e3c3291024866222b96108ab2d8265b477e5b1534c7ddb1810904858d16", size = 10518811, upload-time = "2026-02-12T23:09:31.865Z" }, - { url = "https://files.pythonhosted.org/packages/c3/1a/f5707440e5ae43ffa5365cac8bbb91e9665f4a883f560893829cf16a606b/ruff-0.15.1-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:92c92b003e9d4f7fbd33b1867bb15a1b785b1735069108dfc23821ba045b29bc", size = 10196169, upload-time = "2026-02-12T23:09:17.306Z" }, - { url = "https://files.pythonhosted.org/packages/2a/ff/26ddc8c4da04c8fd3ee65a89c9fb99eaa5c30394269d424461467be2271f/ruff-0.15.1-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1fe5c41ab43e3a06778844c586251eb5a510f67125427625f9eb2b9526535779", size = 10990491, upload-time = "2026-02-12T23:09:25.503Z" }, - { url = "https://files.pythonhosted.org/packages/fc/00/50920cb385b89413f7cdb4bb9bc8fc59c1b0f30028d8bccc294189a54955/ruff-0.15.1-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:66a6dd6df4d80dc382c6484f8ce1bcceb55c32e9f27a8b94c32f6c7331bf14fb", size = 11843280, upload-time = "2026-02-12T23:09:19.88Z" }, - { url = "https://files.pythonhosted.org/packages/5d/6d/2f5cad8380caf5632a15460c323ae326f1e1a2b5b90a6ee7519017a017ca/ruff-0.15.1-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6a4a42cbb8af0bda9bcd7606b064d7c0bc311a88d141d02f78920be6acb5aa83", size = 11274336, upload-time = "2026-02-12T23:09:14.907Z" }, - { url = "https://files.pythonhosted.org/packages/a3/1d/5f56cae1d6c40b8a318513599b35ea4b075d7dc1cd1d04449578c29d1d75/ruff-0.15.1-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4ab064052c31dddada35079901592dfba2e05f5b1e43af3954aafcbc1096a5b2", size = 11137288, upload-time = "2026-02-12T23:09:07.475Z" }, - { url = "https://files.pythonhosted.org/packages/cd/20/6f8d7d8f768c93b0382b33b9306b3b999918816da46537d5a61635514635/ruff-0.15.1-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:5631c940fe9fe91f817a4c2ea4e81f47bee3ca4aa646134a24374f3c19ad9454", size = 11070681, upload-time = "2026-02-12T23:08:55.43Z" }, - { url = "https://files.pythonhosted.org/packages/9a/67/d640ac76069f64cdea59dba02af2e00b1fa30e2103c7f8d049c0cff4cafd/ruff-0.15.1-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:68138a4ba184b4691ccdc39f7795c66b3c68160c586519e7e8444cf5a53e1b4c", size = 10486401, upload-time = "2026-02-12T23:09:27.927Z" }, - { url = "https://files.pythonhosted.org/packages/65/3d/e1429f64a3ff89297497916b88c32a5cc88eeca7e9c787072d0e7f1d3e1e/ruff-0.15.1-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:518f9af03bfc33c03bdb4cb63fabc935341bb7f54af500f92ac309ecfbba6330", size = 10197452, upload-time = "2026-02-12T23:09:12.147Z" }, - { url = "https://files.pythonhosted.org/packages/78/83/e2c3bade17dad63bf1e1c2ffaf11490603b760be149e1419b07049b36ef2/ruff-0.15.1-py3-none-musllinux_1_2_i686.whl", hash = "sha256:da79f4d6a826caaea95de0237a67e33b81e6ec2e25fc7e1993a4015dffca7c61", size = 10693900, upload-time = "2026-02-12T23:09:34.418Z" }, - { url = "https://files.pythonhosted.org/packages/a1/27/fdc0e11a813e6338e0706e8b39bb7a1d61ea5b36873b351acee7e524a72a/ruff-0.15.1-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:3dd86dccb83cd7d4dcfac303ffc277e6048600dfc22e38158afa208e8bf94a1f", size = 11227302, upload-time = "2026-02-12T23:09:36.536Z" }, - { url = "https://files.pythonhosted.org/packages/f6/58/ac864a75067dcbd3b95be5ab4eb2b601d7fbc3d3d736a27e391a4f92a5c1/ruff-0.15.1-py3-none-win32.whl", hash = "sha256:660975d9cb49b5d5278b12b03bb9951d554543a90b74ed5d366b20e2c57c2098", size = 10462555, upload-time = "2026-02-12T23:09:29.899Z" }, - { url = "https://files.pythonhosted.org/packages/e0/5e/d4ccc8a27ecdb78116feac4935dfc39d1304536f4296168f91ed3ec00cd2/ruff-0.15.1-py3-none-win_amd64.whl", hash = "sha256:c820fef9dd5d4172a6570e5721704a96c6679b80cf7be41659ed439653f62336", size = 11599956, upload-time = "2026-02-12T23:09:01.157Z" }, - { url = "https://files.pythonhosted.org/packages/2a/07/5bda6a85b220c64c65686bc85bd0bbb23b29c62b3a9f9433fa55f17cda93/ruff-0.15.1-py3-none-win_arm64.whl", hash = "sha256:5ff7d5f0f88567850f45081fac8f4ec212be8d0b963e385c3f7d0d2eb4899416", size = 10874604, upload-time = "2026-02-12T23:09:05.515Z" }, +version = "0.15.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/06/04/eab13a954e763b0606f460443fcbf6bb5a0faf06890ea3754ff16523dce5/ruff-0.15.2.tar.gz", hash = "sha256:14b965afee0969e68bb871eba625343b8673375f457af4abe98553e8bbb98342", size = 4558148, upload-time = "2026-02-19T22:32:20.271Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2f/70/3a4dc6d09b13cb3e695f28307e5d889b2e1a66b7af9c5e257e796695b0e6/ruff-0.15.2-py3-none-linux_armv6l.whl", hash = "sha256:120691a6fdae2f16d65435648160f5b81a9625288f75544dc40637436b5d3c0d", size = 10430565, upload-time = "2026-02-19T22:32:41.824Z" }, + { url = "https://files.pythonhosted.org/packages/71/0b/bb8457b56185ece1305c666dc895832946d24055be90692381c31d57466d/ruff-0.15.2-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:a89056d831256099658b6bba4037ac6dd06f49d194199215befe2bb10457ea5e", size = 10820354, upload-time = "2026-02-19T22:32:07.366Z" }, + { url = "https://files.pythonhosted.org/packages/2d/c1/e0532d7f9c9e0b14c46f61b14afd563298b8b83f337b6789ddd987e46121/ruff-0.15.2-py3-none-macosx_11_0_arm64.whl", hash = "sha256:e36dee3a64be0ebd23c86ffa3aa3fd3ac9a712ff295e192243f814a830b6bd87", size = 10170767, upload-time = "2026-02-19T22:32:13.188Z" }, + { url = "https://files.pythonhosted.org/packages/47/e8/da1aa341d3af017a21c7a62fb5ec31d4e7ad0a93ab80e3a508316efbcb23/ruff-0.15.2-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a9fb47b6d9764677f8c0a193c0943ce9a05d6763523f132325af8a858eadc2b9", size = 10529591, upload-time = "2026-02-19T22:32:02.547Z" }, + { url = "https://files.pythonhosted.org/packages/93/74/184fbf38e9f3510231fbc5e437e808f0b48c42d1df9434b208821efcd8d6/ruff-0.15.2-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f376990f9d0d6442ea9014b19621d8f2aaf2b8e39fdbfc79220b7f0c596c9b80", size = 10260771, upload-time = "2026-02-19T22:32:36.938Z" }, + { url = "https://files.pythonhosted.org/packages/05/ac/605c20b8e059a0bc4b42360414baa4892ff278cec1c91fff4be0dceedefd/ruff-0.15.2-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2dcc987551952d73cbf5c88d9fdee815618d497e4df86cd4c4824cc59d5dd75f", size = 11045791, upload-time = "2026-02-19T22:32:31.642Z" }, + { url = "https://files.pythonhosted.org/packages/fd/52/db6e419908f45a894924d410ac77d64bdd98ff86901d833364251bd08e22/ruff-0.15.2-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:42a47fd785cbe8c01b9ff45031af875d101b040ad8f4de7bbb716487c74c9a77", size = 11879271, upload-time = "2026-02-19T22:32:29.305Z" }, + { url = "https://files.pythonhosted.org/packages/3e/d8/7992b18f2008bdc9231d0f10b16df7dda964dbf639e2b8b4c1b4e91b83af/ruff-0.15.2-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cbe9f49354866e575b4c6943856989f966421870e85cd2ac94dccb0a9dcb2fea", size = 11303707, upload-time = "2026-02-19T22:32:22.492Z" }, + { url = "https://files.pythonhosted.org/packages/d7/02/849b46184bcfdd4b64cde61752cc9a146c54759ed036edd11857e9b8443b/ruff-0.15.2-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b7a672c82b5f9887576087d97be5ce439f04bbaf548ee987b92d3a7dede41d3a", size = 11149151, upload-time = "2026-02-19T22:32:44.234Z" }, + { url = "https://files.pythonhosted.org/packages/70/04/f5284e388bab60d1d3b99614a5a9aeb03e0f333847e2429bebd2aaa1feec/ruff-0.15.2-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:72ecc64f46f7019e2bcc3cdc05d4a7da958b629a5ab7033195e11a438403d956", size = 11091132, upload-time = "2026-02-19T22:32:24.691Z" }, + { url = "https://files.pythonhosted.org/packages/fa/ae/88d844a21110e14d92cf73d57363fab59b727ebeabe78009b9ccb23500af/ruff-0.15.2-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:8dcf243b15b561c655c1ef2f2b0050e5d50db37fe90115507f6ff37d865dc8b4", size = 10504717, upload-time = "2026-02-19T22:32:26.75Z" }, + { url = "https://files.pythonhosted.org/packages/64/27/867076a6ada7f2b9c8292884ab44d08fd2ba71bd2b5364d4136f3cd537e1/ruff-0.15.2-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:dab6941c862c05739774677c6273166d2510d254dac0695c0e3f5efa1b5585de", size = 10263122, upload-time = "2026-02-19T22:32:10.036Z" }, + { url = "https://files.pythonhosted.org/packages/e7/ef/faf9321d550f8ebf0c6373696e70d1758e20ccdc3951ad7af00c0956be7c/ruff-0.15.2-py3-none-musllinux_1_2_i686.whl", hash = "sha256:1b9164f57fc36058e9a6806eb92af185b0697c9fe4c7c52caa431c6554521e5c", size = 10735295, upload-time = "2026-02-19T22:32:39.227Z" }, + { url = "https://files.pythonhosted.org/packages/2f/55/e8089fec62e050ba84d71b70e7834b97709ca9b7aba10c1a0b196e493f97/ruff-0.15.2-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:80d24fcae24d42659db7e335b9e1531697a7102c19185b8dc4a028b952865fd8", size = 11241641, upload-time = "2026-02-19T22:32:34.617Z" }, + { url = "https://files.pythonhosted.org/packages/23/01/1c30526460f4d23222d0fabd5888868262fd0e2b71a00570ca26483cd993/ruff-0.15.2-py3-none-win32.whl", hash = "sha256:fd5ff9e5f519a7e1bd99cbe8daa324010a74f5e2ebc97c6242c08f26f3714f6f", size = 10507885, upload-time = "2026-02-19T22:32:15.635Z" }, + { url = "https://files.pythonhosted.org/packages/5c/10/3d18e3bbdf8fc50bbb4ac3cc45970aa5a9753c5cb51bf9ed9a3cd8b79fa3/ruff-0.15.2-py3-none-win_amd64.whl", hash = "sha256:d20014e3dfa400f3ff84830dfb5755ece2de45ab62ecea4af6b7262d0fb4f7c5", size = 11623725, upload-time = "2026-02-19T22:32:04.947Z" }, + { url = "https://files.pythonhosted.org/packages/6d/78/097c0798b1dab9f8affe73da9642bb4500e098cb27fd8dc9724816ac747b/ruff-0.15.2-py3-none-win_arm64.whl", hash = "sha256:cabddc5822acdc8f7b5527b36ceac55cc51eec7b1946e60181de8fe83ca8876e", size = 10941649, upload-time = "2026-02-19T22:32:18.108Z" }, ] [[package]] From bdf80282cc9f51cfe8894bd2117f6ba1408f2fff Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C6=B0u=20Quang=20V=C5=A9?= Date: Sun, 22 Feb 2026 10:50:46 +0700 Subject: [PATCH 142/291] Refactor StreamingOutputFilter to use a stack-based state machine for improved handling of nested fragmented markers --- app/server/chat.py | 46 ++++++++++++++++++++++++++-------------------- 1 file changed, 26 insertions(+), 20 deletions(-) diff --git a/app/server/chat.py b/app/server/chat.py index b7c0564..a8d07cc 100644 --- a/app/server/chat.py +++ b/app/server/chat.py @@ -751,14 +751,21 @@ async def _send_with_split( class StreamingOutputFilter: """ Filter to suppress technical protocol markers, tool calls, and system hints from the stream. - Uses a high-performance regex state machine to handle fragmented markers. + Uses a stack-based state machine to handle nested fragmented markers. """ def __init__(self): self.buffer = "" - self.state = "NORMAL" + self.stack = ["NORMAL"] self.current_role = "" - self.in_chatml = False + + @property + def state(self): + return self.stack[-1] + + def _is_outputting(self) -> bool: + """Determines if the current state allows yielding text to the stream.""" + return self.state == "NORMAL" or (self.state == "IN_BLOCK" and self.current_role != "tool") def process(self, chunk: str) -> str: self.buffer += chunk @@ -770,8 +777,7 @@ def process(self, chunk: str) -> str: if nl_idx != -1: self.current_role = self.buffer[:nl_idx].strip().lower() self.buffer = self.buffer[nl_idx + 1 :] - self.state = "IN_BLOCK" - self.in_chatml = True + self.stack[-1] = "IN_BLOCK" continue else: break @@ -782,9 +788,7 @@ def process(self, chunk: str) -> str: keep_len = len(tail_match.group(0)) if tail_match else 0 yield_len = len(self.buffer) - keep_len if yield_len > 0: - if self.state == "NORMAL" or ( - self.state == "IN_BLOCK" and self.current_role != "tool" - ): + if self._is_outputting(): output.append(self.buffer[:yield_len]) self.buffer = self.buffer[yield_len:] break @@ -793,21 +797,23 @@ def process(self, chunk: str) -> str: matched_group = match.lastgroup pre_text = self.buffer[:start] - if self.state == "NORMAL" or (self.state == "IN_BLOCK" and self.current_role != "tool"): + if self._is_outputting(): output.append(pre_text) if matched_group.endswith("_START"): m_type = matched_group.split("_")[0] if m_type == "TAG": - self.state = "IN_TAG_HEADER" + self.stack.append("IN_TAG_HEADER") + else: + self.stack.append(f"IN_{m_type}") + elif matched_group in ("PROTOCOL_EXIT", "TAG_EXIT", "HINT_EXIT"): + if len(self.stack) > 1: + self.stack.pop() else: - self.state = f"IN_{m_type}" - elif matched_group in ("PROTOCOL_EXIT", "HINT_EXIT"): - self.state = "IN_BLOCK" if self.in_chatml else "NORMAL" - elif matched_group == "TAG_EXIT": - self.state = "NORMAL" - self.in_chatml = False - self.current_role = "" + self.stack = ["NORMAL"] + + if self.state == "NORMAL": + self.current_role = "" self.buffer = self.buffer[end:] @@ -816,15 +822,15 @@ def process(self, chunk: str) -> str: def flush(self) -> str: """Release remaining buffer content and perform final cleanup at stream end.""" res = "" - if self.state == "NORMAL" or (self.state == "IN_BLOCK" and self.current_role != "tool"): + if self._is_outputting(): res = self.buffer tail_match = STREAM_TAIL_RE.search(res) if tail_match: res = res[: -len(tail_match.group(0))] self.buffer = "" - self.state = "NORMAL" - self.in_chatml = False + self.stack = ["NORMAL"] + self.current_role = "" return strip_system_hints(res) From 2e4a97f364849fe752316818ecdd6b795b44511c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C6=B0u=20Quang=20V=C5=A9?= Date: Sun, 22 Feb 2026 11:12:39 +0700 Subject: [PATCH 143/291] Refactor `StreamingOutputFilter` for improved handling of nested fragmented markers --- app/utils/helper.py | 44 +++++++++++++++++++++++++------------------- 1 file changed, 25 insertions(+), 19 deletions(-) diff --git a/app/utils/helper.py b/app/utils/helper.py index e101e17..4c217c3 100644 --- a/app/utils/helper.py +++ b/app/utils/helper.py @@ -36,32 +36,36 @@ "CRITICAL: Do NOT mix natural language with protocol tags. Either respond naturally OR provide the protocol block alone. There is no middle ground.\n" ) TOOL_BLOCK_RE = re.compile( - r"\\?\[ToolCalls\\?]\s*(.*?)\s*\\?\[\\?/?ToolCalls\\?]", + r"\\?\[\s*ToolCalls\s*\\?]\s*(.*?)\s*\\?\[\s*\\?/\s*ToolCalls\s*\\?]", re.DOTALL | re.IGNORECASE, ) TOOL_CALL_RE = re.compile( - r"\\?\[Call\\?:\s*(?P(?:[^]\\]|\\.)+)\s*\\?]\s*(?P.*?)\s*\\?\[\\?/?Call\\?]", + r"\\?\[\s*Call\s*\\?:\s*(?P(?:[^]\\]|\\.)+)\s*\\?]\s*(?P.*?)\s*\\?\[\s*\\?/\s*Call\s*\\?]", re.DOTALL | re.IGNORECASE, ) RESPONSE_BLOCK_RE = re.compile( - r"\\?\[ToolResults\\?]\s*(.*?)\s*\\?\[\\?/?ToolResults\\?]", + r"\\?\[\s*ToolResults\s*\\?]\s*(.*?)\s*\\?\[\s*\\?/\s*ToolResults\s*\\?]", re.DOTALL | re.IGNORECASE, ) RESPONSE_ITEM_RE = re.compile( - r"\\?\[Result\\?:\s*(?P(?:[^]\\]|\\.)+)\s*\\?]\s*(?P.*?)\s*\\?\[\\?/?Result\\?]", + r"\\?\[\s*Result\s*\\?:\s*(?P(?:[^]\\]|\\.)+)\s*\\?]\s*(?P.*?)\s*\\?\[\s*\\?/\s*Result\s*\\?]", re.DOTALL | re.IGNORECASE, ) TAGGED_ARG_RE = re.compile( - r"\\?\[CallParameter\\?:\s*(?P(?:[^]\\]|\\.)+)\s*\\?]\s*(?P.*?)\s*\\?\[\\?/?CallParameter\\?]", + r"\\?\[\s*CallParameter\s*\\?:\s*(?P(?:[^]\\]|\\.)+)\s*\\?]\s*(?P.*?)\s*\\?\[\s*\\?/\s*CallParameter\s*\\?]", re.DOTALL | re.IGNORECASE, ) TAGGED_RESULT_RE = re.compile( - r"\\?\[ToolResult\\?]\s*(.*?)\s*\\?\[\\?/?ToolResult\\?]", + r"\\?\[\s*ToolResult\s*\\?]\s*(.*?)\s*\\?\[\s*\\?/\s*ToolResult\s*\\?]", re.DOTALL | re.IGNORECASE, ) -CONTROL_TOKEN_RE = re.compile(r"\\?<\\?\|im\\?_(?:start|end)\\?\|\\?>", re.IGNORECASE) -CHATML_START_RE = re.compile(r"\\?<\\?\|im\\?_start\\?\|\\?>\s*(\w+)\s*\n?", re.IGNORECASE) -CHATML_END_RE = re.compile(r"\\?<\\?\|im\\?_end\\?\|\\?>", re.IGNORECASE) +CONTROL_TOKEN_RE = re.compile( + r"\\?\s*<\s*\\?\|\s*im\s*\\?_(?:start|end)\s*\\?\|\s*>\s*", re.IGNORECASE +) +CHATML_START_RE = re.compile( + r"\\?\s*<\s*\\?\|\s*im\s*\\?_start\s*\\?\|\s*>\s*(\w+)\s*\n?", re.IGNORECASE +) +CHATML_END_RE = re.compile(r"\\?\s*<\s*\\?\|\s*im\s*\\?_end\s*\\?\|\s*>\s*", re.IGNORECASE) COMMONMARK_UNESCAPE_RE = re.compile(r"\\([!\"#$%&'()*+,\-./:;<=>?@\[\\\]^_`{|}~])") PARAM_FENCE_RE = re.compile(r"^(?P`{3,})") TOOL_HINT_STRIPPED = TOOL_WRAP_HINT.strip() @@ -81,17 +85,19 @@ # --- Streaming Specific Patterns --- _START_PATTERNS = { - "TOOL": r"\\?\[ToolCalls\\?\]", - "ORPHAN": r"\\?\[Call\\?:\s*[^\]\\]+\s*\\?\]", - "RESP": r"\\?\[ToolResults\\?\]", - "ARG": r"\\?\[CallParameter\\?:\s*[^\]\\]+\s*\\?\]", - "RESULT": r"\\?\[ToolResult\\?\]", - "ITEM": r"\\?\[Result\\?:\s*[^\]\\]+\s*\\?\]", - "TAG": r"\\?<\\?\|im\\?_start\\?\|\\?>", + "TOOL": r"\\?\[\s*ToolCalls\s*\\?\]", + "ORPHAN": r"\\?\[\s*Call\s*\\?:\s*(?:[^\]\\]|\\.)+\s*\\?\]", + "RESP": r"\\?\[\s*ToolResults\s*\\?\]", + "ARG": r"\\?\[\s*CallParameter\s*\\?:\s*(?:[^\]\\]|\\.)+\s*\\?\]", + "RESULT": r"\\?\[\s*ToolResult\s*\\?\]", + "ITEM": r"\\?\[\s*Result\s*\\?:\s*(?:[^\]\\]|\\.)+\s*\\?\]", + "TAG": r"\\?\s*<\s*\\?\|\s*im\s*\\?_start\s*\\?\|\s*>", } -_PROTOCOL_ENDS = r"\\?\[\\?/(?:ToolCalls|Call|ToolResults|CallParameter|ToolResult|Result)\\?\]" -_TAG_END = r"\\?<\\?\|im\\?_end\\?\|\\?>" +_PROTOCOL_ENDS = ( + r"\\?\[\s*\\?/\s*(?:ToolCalls|Call|ToolResults|CallParameter|ToolResult|Result)\s*\\?\]" +) +_TAG_END = r"\\?\s*<\s*\\?\|\s*im\s*\\?_end\s*\\?\|\s*>" _master_parts = [f"(?P<{name}_START>{pattern})" for name, pattern in _START_PATTERNS.items()] _master_parts.append(f"(?P{_PROTOCOL_ENDS})") @@ -103,7 +109,7 @@ STREAM_MASTER_RE = re.compile("|".join(_master_parts), re.IGNORECASE) STREAM_TAIL_RE = re.compile( - r"(?:\\|\\?\[(?:T(?:o?o?l?)?|C(?:a?l?l?)?|R(?:e?s?u?l?t?)?|/)?[\w/:]*|\\?<\??\|?i?m?_?(?:s?t?a?r?t?|e?n?d?)\|?|)$", + r"(?:\\|\\?\[[TCRP/]?\s*[^]]*|\\?\s*<\s*\\?\|?\s*i?\s*m?\s*\\?_?(?:s?t?a?r?t?|e?n?d?)\s*\\?\|?\s*>?|)$", re.IGNORECASE, ) From c4d9016024ee2da4efdde9778b0bfd94275a3491 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C6=B0u=20Quang=20V=C5=A9?= Date: Sun, 22 Feb 2026 12:32:14 +0700 Subject: [PATCH 144/291] Refactor `StreamingOutputFilter` for improved handling of nested fragmented markers --- app/utils/helper.py | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/app/utils/helper.py b/app/utils/helper.py index 4c217c3..9f07bf7 100644 --- a/app/utils/helper.py +++ b/app/utils/helper.py @@ -76,12 +76,16 @@ TOOL_HINT_END_ESC = re.escape(TOOL_HINT_LINE_END) if TOOL_HINT_LINE_END else "" HINT_FULL_RE = ( - re.compile(rf"\n?{TOOL_HINT_START_ESC}:?.*?{TOOL_HINT_END_ESC}\\.?\n?", re.DOTALL) + re.compile(rf"\n?{TOOL_HINT_START_ESC}:?.*?{TOOL_HINT_END_ESC}\n?", re.DOTALL | re.IGNORECASE) if TOOL_HINT_START_ESC and TOOL_HINT_END_ESC else None ) -HINT_START_RE = re.compile(rf"\n?{TOOL_HINT_START_ESC}:?\s*") if TOOL_HINT_START_ESC else None -HINT_END_RE = re.compile(rf"\s*{TOOL_HINT_END_ESC}\.?\n?") if TOOL_HINT_END_ESC else None +HINT_START_RE = ( + re.compile(rf"\n?{TOOL_HINT_START_ESC}:?\s*", re.IGNORECASE) if TOOL_HINT_START_ESC else None +) +HINT_END_RE = ( + re.compile(rf"\s*{TOOL_HINT_END_ESC}\n?", re.IGNORECASE) if TOOL_HINT_END_ESC else None +) # --- Streaming Specific Patterns --- _START_PATTERNS = { @@ -99,13 +103,15 @@ ) _TAG_END = r"\\?\s*<\s*\\?\|\s*im\s*\\?_end\s*\\?\|\s*>" +if TOOL_HINT_START_ESC and TOOL_HINT_END_ESC: + _START_PATTERNS["HINT"] = rf"\n?{TOOL_HINT_START_ESC}:?\s*" + _master_parts = [f"(?P<{name}_START>{pattern})" for name, pattern in _START_PATTERNS.items()] _master_parts.append(f"(?P{_PROTOCOL_ENDS})") _master_parts.append(f"(?P{_TAG_END})") if TOOL_HINT_START_ESC and TOOL_HINT_END_ESC: - _START_PATTERNS["HINT"] = rf"\n?{TOOL_HINT_START_ESC}:?\s*" - _master_parts.append(f"(?P{TOOL_HINT_END_ESC}\\.?\n?)") + _master_parts.append(f"(?P{TOOL_HINT_END_ESC}\n?)") STREAM_MASTER_RE = re.compile("|".join(_master_parts), re.IGNORECASE) STREAM_TAIL_RE = re.compile( From 1da4daccc1bf5f9e96d30d40625461351e723097 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C6=B0u=20Quang=20V=C5=A9?= Date: Sun, 22 Feb 2026 13:24:11 +0700 Subject: [PATCH 145/291] Fix fence stripping logic in _strip_param_fences function --- app/utils/helper.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/app/utils/helper.py b/app/utils/helper.py index 9f07bf7..e4fb498 100644 --- a/app/utils/helper.py +++ b/app/utils/helper.py @@ -164,12 +164,12 @@ def _strip_param_fences(s: str) -> str: if not match or not s.endswith(match.group("fence")): return s + fence = match.group("fence") lines = s.splitlines() - if len(lines) >= 2: + if len(lines) >= 3 and lines[-1].strip() == fence: return "\n".join(lines[1:-1]) - n = len(match.group("fence")) - return s[n:-n].strip() + return s[len(fence) : -len(fence)].strip() def estimate_tokens(text: str | None) -> int: From ef058d45636829255ecde88486a6c3e280fd5557 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C6=B0u=20Quang=20V=C5=A9?= Date: Sun, 22 Feb 2026 16:18:58 +0700 Subject: [PATCH 146/291] Refactor think tag removal to use a precompiled regex pattern --- app/services/lmdb.py | 4 ++-- app/utils/helper.py | 1 + 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/app/services/lmdb.py b/app/services/lmdb.py index 87a1449..8c4edb4 100644 --- a/app/services/lmdb.py +++ b/app/services/lmdb.py @@ -1,5 +1,4 @@ import hashlib -import re import string from contextlib import contextmanager from datetime import datetime, timedelta @@ -13,6 +12,7 @@ from app.models import ContentItem, ConversationInStore, Message from app.utils import g_config from app.utils.helper import ( + THINK_TAGS_RE, extract_tool_calls, normalize_llm_text, remove_tool_call_blocks, @@ -589,7 +589,7 @@ def remove_think_tags(text: str) -> str: """Remove all ... tags and strip whitespace.""" if not text: return text - cleaned_content = re.sub(r".*?", "", text, flags=re.DOTALL) + cleaned_content = THINK_TAGS_RE.sub("", text) return cleaned_content.strip() @staticmethod diff --git a/app/utils/helper.py b/app/utils/helper.py index e4fb498..2e9e801 100644 --- a/app/utils/helper.py +++ b/app/utils/helper.py @@ -86,6 +86,7 @@ HINT_END_RE = ( re.compile(rf"\s*{TOOL_HINT_END_ESC}\n?", re.IGNORECASE) if TOOL_HINT_END_ESC else None ) +THINK_TAGS_RE = re.compile(r".*?", re.DOTALL | re.IGNORECASE) # --- Streaming Specific Patterns --- _START_PATTERNS = { From 906380f79defe9d4c09aab4dfa24296b2b98d2b8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C6=B0u=20Quang=20V=C5=A9?= Date: Sun, 22 Feb 2026 20:33:51 +0700 Subject: [PATCH 147/291] Refactor HTTP client usage to utilize AsyncSession from curl-cffi for improved performance --- app/utils/helper.py | 4 ++-- pyproject.toml | 1 + uv.lock | 57 +++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 60 insertions(+), 2 deletions(-) diff --git a/app/utils/helper.py b/app/utils/helper.py index 2e9e801..9a930b7 100644 --- a/app/utils/helper.py +++ b/app/utils/helper.py @@ -10,8 +10,8 @@ from pathlib import Path from urllib.parse import urlparse -import httpx import orjson +from curl_cffi.requests import AsyncSession from loguru import logger from app.models import FunctionCall, Message, ToolCall @@ -202,7 +202,7 @@ async def save_url_to_tempfile(url: str, tempdir: Path | None = None) -> Path: data = base64.b64decode(url.split(",")[1]) suffix = mimetypes.guess_extension(mime_type) or f".{mime_type.split('/')[1]}" else: - async with httpx.AsyncClient(follow_redirects=True) as client: + async with AsyncSession(impersonate="chrome", allow_redirects=True) as client: resp = await client.get(url) resp.raise_for_status() data = resp.content diff --git a/pyproject.toml b/pyproject.toml index 6599122..6802f51 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,6 +5,7 @@ description = "FastAPI Server built on Gemini Web API" readme = "README.md" requires-python = "==3.13.*" dependencies = [ + "curl-cffi>=0.14.0", "fastapi>=0.129.2", "gemini-webapi>=1.19.2", "httptools>=0.7.1", diff --git a/uv.lock b/uv.lock index 4cae2c0..c7aa914 100644 --- a/uv.lock +++ b/uv.lock @@ -41,6 +41,29 @@ wheels = [ { 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]] +name = "cffi" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pycparser", marker = "implementation_name != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/eb/56/b1ba7935a17738ae8453301356628e8147c79dbb825bcbc73dc7401f9846/cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529", size = 523588, upload-time = "2025-09-08T23:24:04.541Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4b/8d/a0a47a0c9e413a658623d014e91e74a50cdd2c423f7ccfd44086ef767f90/cffi-2.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:00bdf7acc5f795150faa6957054fbbca2439db2f775ce831222b66f192f03beb", size = 185230, upload-time = "2025-09-08T23:23:00.879Z" }, + { url = "https://files.pythonhosted.org/packages/4a/d2/a6c0296814556c68ee32009d9c2ad4f85f2707cdecfd7727951ec228005d/cffi-2.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:45d5e886156860dc35862657e1494b9bae8dfa63bf56796f2fb56e1679fc0bca", size = 181043, upload-time = "2025-09-08T23:23:02.231Z" }, + { url = "https://files.pythonhosted.org/packages/b0/1e/d22cc63332bd59b06481ceaac49d6c507598642e2230f201649058a7e704/cffi-2.0.0-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:07b271772c100085dd28b74fa0cd81c8fb1a3ba18b21e03d7c27f3436a10606b", size = 212446, upload-time = "2025-09-08T23:23:03.472Z" }, + { url = "https://files.pythonhosted.org/packages/a9/f5/a2c23eb03b61a0b8747f211eb716446c826ad66818ddc7810cc2cc19b3f2/cffi-2.0.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d48a880098c96020b02d5a1f7d9251308510ce8858940e6fa99ece33f610838b", size = 220101, upload-time = "2025-09-08T23:23:04.792Z" }, + { url = "https://files.pythonhosted.org/packages/f2/7f/e6647792fc5850d634695bc0e6ab4111ae88e89981d35ac269956605feba/cffi-2.0.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f93fd8e5c8c0a4aa1f424d6173f14a892044054871c771f8566e4008eaa359d2", size = 207948, upload-time = "2025-09-08T23:23:06.127Z" }, + { url = "https://files.pythonhosted.org/packages/cb/1e/a5a1bd6f1fb30f22573f76533de12a00bf274abcdc55c8edab639078abb6/cffi-2.0.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:dd4f05f54a52fb558f1ba9f528228066954fee3ebe629fc1660d874d040ae5a3", size = 206422, upload-time = "2025-09-08T23:23:07.753Z" }, + { url = "https://files.pythonhosted.org/packages/98/df/0a1755e750013a2081e863e7cd37e0cdd02664372c754e5560099eb7aa44/cffi-2.0.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c8d3b5532fc71b7a77c09192b4a5a200ea992702734a2e9279a37f2478236f26", size = 219499, upload-time = "2025-09-08T23:23:09.648Z" }, + { url = "https://files.pythonhosted.org/packages/50/e1/a969e687fcf9ea58e6e2a928ad5e2dd88cc12f6f0ab477e9971f2309b57c/cffi-2.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d9b29c1f0ae438d5ee9acb31cadee00a58c46cc9c0b2f9038c6b0b3470877a8c", size = 222928, upload-time = "2025-09-08T23:23:10.928Z" }, + { url = "https://files.pythonhosted.org/packages/36/54/0362578dd2c9e557a28ac77698ed67323ed5b9775ca9d3fe73fe191bb5d8/cffi-2.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6d50360be4546678fc1b79ffe7a66265e28667840010348dd69a314145807a1b", size = 221302, upload-time = "2025-09-08T23:23:12.42Z" }, + { url = "https://files.pythonhosted.org/packages/eb/6d/bf9bda840d5f1dfdbf0feca87fbdb64a918a69bca42cfa0ba7b137c48cb8/cffi-2.0.0-cp313-cp313-win32.whl", hash = "sha256:74a03b9698e198d47562765773b4a8309919089150a0bb17d829ad7b44b60d27", size = 172909, upload-time = "2025-09-08T23:23:14.32Z" }, + { url = "https://files.pythonhosted.org/packages/37/18/6519e1ee6f5a1e579e04b9ddb6f1676c17368a7aba48299c3759bbc3c8b3/cffi-2.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:19f705ada2530c1167abacb171925dd886168931e0a7b78f5bffcae5c6b5be75", size = 183402, upload-time = "2025-09-08T23:23:15.535Z" }, + { url = "https://files.pythonhosted.org/packages/cb/0e/02ceeec9a7d6ee63bb596121c2c8e9b3a9e150936f4fbef6ca1943e6137c/cffi-2.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:256f80b80ca3853f90c21b23ee78cd008713787b1b1e93eae9f3d6a7134abd91", size = 177780, upload-time = "2025-09-08T23:23:16.761Z" }, +] + [[package]] name = "click" version = "8.3.1" @@ -62,6 +85,29 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, ] +[[package]] +name = "curl-cffi" +version = "0.14.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "cffi" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9b/c9/0067d9a25ed4592b022d4558157fcdb6e123516083700786d38091688767/curl_cffi-0.14.0.tar.gz", hash = "sha256:5ffbc82e59f05008ec08ea432f0e535418823cda44178ee518906a54f27a5f0f", size = 162633, upload-time = "2025-12-16T03:25:07.931Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/aa/f0/0f21e9688eaac85e705537b3a87a5588d0cefb2f09d83e83e0e8be93aa99/curl_cffi-0.14.0-cp39-abi3-macosx_14_0_arm64.whl", hash = "sha256:e35e89c6a69872f9749d6d5fda642ed4fc159619329e99d577d0104c9aad5893", size = 3087277, upload-time = "2025-12-16T03:24:49.607Z" }, + { url = "https://files.pythonhosted.org/packages/ba/a3/0419bd48fce5b145cb6a2344c6ac17efa588f5b0061f212c88e0723da026/curl_cffi-0.14.0-cp39-abi3-macosx_15_0_x86_64.whl", hash = "sha256:5945478cd28ad7dfb5c54473bcfb6743ee1d66554d57951fdf8fc0e7d8cf4e45", size = 5804650, upload-time = "2025-12-16T03:24:51.518Z" }, + { url = "https://files.pythonhosted.org/packages/e2/07/a238dd062b7841b8caa2fa8a359eb997147ff3161288f0dd46654d898b4d/curl_cffi-0.14.0-cp39-abi3-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c42e8fa3c667db9ccd2e696ee47adcd3cd5b0838d7282f3fc45f6c0ef3cfdfa7", size = 8231918, upload-time = "2025-12-16T03:24:52.862Z" }, + { url = "https://files.pythonhosted.org/packages/7c/d2/ce907c9b37b5caf76ac08db40cc4ce3d9f94c5500db68a195af3513eacbc/curl_cffi-0.14.0-cp39-abi3-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:060fe2c99c41d3cb7f894de318ddf4b0301b08dca70453d769bd4e74b36b8483", size = 8654624, upload-time = "2025-12-16T03:24:54.579Z" }, + { url = "https://files.pythonhosted.org/packages/f2/ae/6256995b18c75e6ef76b30753a5109e786813aa79088b27c8eabb1ef85c9/curl_cffi-0.14.0-cp39-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:b158c41a25388690dd0d40b5bc38d1e0f512135f17fdb8029868cbc1993d2e5b", size = 8010654, upload-time = "2025-12-16T03:24:56.507Z" }, + { url = "https://files.pythonhosted.org/packages/fb/10/ff64249e516b103cb762e0a9dca3ee0f04cf25e2a1d5d9838e0f1273d071/curl_cffi-0.14.0-cp39-abi3-manylinux_2_28_i686.whl", hash = "sha256:1439fbef3500fb723333c826adf0efb0e2e5065a703fb5eccce637a2250db34a", size = 7781969, upload-time = "2025-12-16T03:24:57.885Z" }, + { url = "https://files.pythonhosted.org/packages/51/76/d6f7bb76c2d12811aa7ff16f5e17b678abdd1b357b9a8ac56310ceccabd5/curl_cffi-0.14.0-cp39-abi3-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e7176f2c2d22b542e3cf261072a81deb018cfa7688930f95dddef215caddb469", size = 7969133, upload-time = "2025-12-16T03:24:59.261Z" }, + { url = "https://files.pythonhosted.org/packages/23/7c/cca39c0ed4e1772613d3cba13091c0e9d3b89365e84b9bf9838259a3cd8f/curl_cffi-0.14.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:03f21ade2d72978c2bb8670e9b6de5260e2755092b02d94b70b906813662998d", size = 9080167, upload-time = "2025-12-16T03:25:00.946Z" }, + { url = "https://files.pythonhosted.org/packages/75/03/a942d7119d3e8911094d157598ae0169b1c6ca1bd3f27d7991b279bcc45b/curl_cffi-0.14.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:58ebf02de64ee5c95613209ddacb014c2d2f86298d7080c0a1c12ed876ee0690", size = 9520464, upload-time = "2025-12-16T03:25:02.922Z" }, + { url = "https://files.pythonhosted.org/packages/a2/77/78900e9b0833066d2274bda75cba426fdb4cef7fbf6a4f6a6ca447607bec/curl_cffi-0.14.0-cp39-abi3-win_amd64.whl", hash = "sha256:6e503f9a103f6ae7acfb3890c843b53ec030785a22ae7682a22cc43afb94123e", size = 1677416, upload-time = "2025-12-16T03:25:04.902Z" }, + { url = "https://files.pythonhosted.org/packages/5c/7c/d2ba86b0b3e1e2830bd94163d047de122c69a8df03c5c7c36326c456ad82/curl_cffi-0.14.0-cp39-abi3-win_arm64.whl", hash = "sha256:2eed50a969201605c863c4c31269dfc3e0da52916086ac54553cfa353022425c", size = 1425067, upload-time = "2025-12-16T03:25:06.454Z" }, +] + [[package]] name = "fastapi" version = "0.129.2" @@ -83,6 +129,7 @@ name = "gemini-fastapi" version = "1.0.0" source = { virtual = "." } dependencies = [ + { name = "curl-cffi" }, { name = "fastapi" }, { name = "gemini-webapi" }, { name = "httptools" }, @@ -107,6 +154,7 @@ dev = [ [package.metadata] requires-dist = [ + { name = "curl-cffi", specifier = ">=0.14.0" }, { name = "fastapi", specifier = ">=0.129.2" }, { name = "gemini-webapi", specifier = ">=1.19.2" }, { name = "httptools", specifier = ">=0.7.1" }, @@ -314,6 +362,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, ] +[[package]] +name = "pycparser" +version = "3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", size = 103492, upload-time = "2026-01-21T14:26:51.89Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" }, +] + [[package]] name = "pydantic" version = "2.12.5" From 9ecb982893c483397b9c74e345b1e977b1bee097 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C6=B0u=20Quang=20V=C5=A9?= Date: Sun, 22 Feb 2026 21:28:32 +0700 Subject: [PATCH 148/291] Revert "Refactor HTTP client usage to utilize AsyncSession from curl-cffi for improved performance" This reverts commit 906380f79defe9d4c09aab4dfa24296b2b98d2b8. --- app/utils/helper.py | 4 ++-- pyproject.toml | 1 - uv.lock | 57 --------------------------------------------- 3 files changed, 2 insertions(+), 60 deletions(-) diff --git a/app/utils/helper.py b/app/utils/helper.py index 9a930b7..2e9e801 100644 --- a/app/utils/helper.py +++ b/app/utils/helper.py @@ -10,8 +10,8 @@ from pathlib import Path from urllib.parse import urlparse +import httpx import orjson -from curl_cffi.requests import AsyncSession from loguru import logger from app.models import FunctionCall, Message, ToolCall @@ -202,7 +202,7 @@ async def save_url_to_tempfile(url: str, tempdir: Path | None = None) -> Path: data = base64.b64decode(url.split(",")[1]) suffix = mimetypes.guess_extension(mime_type) or f".{mime_type.split('/')[1]}" else: - async with AsyncSession(impersonate="chrome", allow_redirects=True) as client: + async with httpx.AsyncClient(follow_redirects=True) as client: resp = await client.get(url) resp.raise_for_status() data = resp.content diff --git a/pyproject.toml b/pyproject.toml index 6802f51..6599122 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,7 +5,6 @@ description = "FastAPI Server built on Gemini Web API" readme = "README.md" requires-python = "==3.13.*" dependencies = [ - "curl-cffi>=0.14.0", "fastapi>=0.129.2", "gemini-webapi>=1.19.2", "httptools>=0.7.1", diff --git a/uv.lock b/uv.lock index c7aa914..4cae2c0 100644 --- a/uv.lock +++ b/uv.lock @@ -41,29 +41,6 @@ wheels = [ { 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]] -name = "cffi" -version = "2.0.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "pycparser", marker = "implementation_name != 'PyPy'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/eb/56/b1ba7935a17738ae8453301356628e8147c79dbb825bcbc73dc7401f9846/cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529", size = 523588, upload-time = "2025-09-08T23:24:04.541Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/4b/8d/a0a47a0c9e413a658623d014e91e74a50cdd2c423f7ccfd44086ef767f90/cffi-2.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:00bdf7acc5f795150faa6957054fbbca2439db2f775ce831222b66f192f03beb", size = 185230, upload-time = "2025-09-08T23:23:00.879Z" }, - { url = "https://files.pythonhosted.org/packages/4a/d2/a6c0296814556c68ee32009d9c2ad4f85f2707cdecfd7727951ec228005d/cffi-2.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:45d5e886156860dc35862657e1494b9bae8dfa63bf56796f2fb56e1679fc0bca", size = 181043, upload-time = "2025-09-08T23:23:02.231Z" }, - { url = "https://files.pythonhosted.org/packages/b0/1e/d22cc63332bd59b06481ceaac49d6c507598642e2230f201649058a7e704/cffi-2.0.0-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:07b271772c100085dd28b74fa0cd81c8fb1a3ba18b21e03d7c27f3436a10606b", size = 212446, upload-time = "2025-09-08T23:23:03.472Z" }, - { url = "https://files.pythonhosted.org/packages/a9/f5/a2c23eb03b61a0b8747f211eb716446c826ad66818ddc7810cc2cc19b3f2/cffi-2.0.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d48a880098c96020b02d5a1f7d9251308510ce8858940e6fa99ece33f610838b", size = 220101, upload-time = "2025-09-08T23:23:04.792Z" }, - { url = "https://files.pythonhosted.org/packages/f2/7f/e6647792fc5850d634695bc0e6ab4111ae88e89981d35ac269956605feba/cffi-2.0.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f93fd8e5c8c0a4aa1f424d6173f14a892044054871c771f8566e4008eaa359d2", size = 207948, upload-time = "2025-09-08T23:23:06.127Z" }, - { url = "https://files.pythonhosted.org/packages/cb/1e/a5a1bd6f1fb30f22573f76533de12a00bf274abcdc55c8edab639078abb6/cffi-2.0.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:dd4f05f54a52fb558f1ba9f528228066954fee3ebe629fc1660d874d040ae5a3", size = 206422, upload-time = "2025-09-08T23:23:07.753Z" }, - { url = "https://files.pythonhosted.org/packages/98/df/0a1755e750013a2081e863e7cd37e0cdd02664372c754e5560099eb7aa44/cffi-2.0.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c8d3b5532fc71b7a77c09192b4a5a200ea992702734a2e9279a37f2478236f26", size = 219499, upload-time = "2025-09-08T23:23:09.648Z" }, - { url = "https://files.pythonhosted.org/packages/50/e1/a969e687fcf9ea58e6e2a928ad5e2dd88cc12f6f0ab477e9971f2309b57c/cffi-2.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d9b29c1f0ae438d5ee9acb31cadee00a58c46cc9c0b2f9038c6b0b3470877a8c", size = 222928, upload-time = "2025-09-08T23:23:10.928Z" }, - { url = "https://files.pythonhosted.org/packages/36/54/0362578dd2c9e557a28ac77698ed67323ed5b9775ca9d3fe73fe191bb5d8/cffi-2.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6d50360be4546678fc1b79ffe7a66265e28667840010348dd69a314145807a1b", size = 221302, upload-time = "2025-09-08T23:23:12.42Z" }, - { url = "https://files.pythonhosted.org/packages/eb/6d/bf9bda840d5f1dfdbf0feca87fbdb64a918a69bca42cfa0ba7b137c48cb8/cffi-2.0.0-cp313-cp313-win32.whl", hash = "sha256:74a03b9698e198d47562765773b4a8309919089150a0bb17d829ad7b44b60d27", size = 172909, upload-time = "2025-09-08T23:23:14.32Z" }, - { url = "https://files.pythonhosted.org/packages/37/18/6519e1ee6f5a1e579e04b9ddb6f1676c17368a7aba48299c3759bbc3c8b3/cffi-2.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:19f705ada2530c1167abacb171925dd886168931e0a7b78f5bffcae5c6b5be75", size = 183402, upload-time = "2025-09-08T23:23:15.535Z" }, - { url = "https://files.pythonhosted.org/packages/cb/0e/02ceeec9a7d6ee63bb596121c2c8e9b3a9e150936f4fbef6ca1943e6137c/cffi-2.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:256f80b80ca3853f90c21b23ee78cd008713787b1b1e93eae9f3d6a7134abd91", size = 177780, upload-time = "2025-09-08T23:23:16.761Z" }, -] - [[package]] name = "click" version = "8.3.1" @@ -85,29 +62,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, ] -[[package]] -name = "curl-cffi" -version = "0.14.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "certifi" }, - { name = "cffi" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/9b/c9/0067d9a25ed4592b022d4558157fcdb6e123516083700786d38091688767/curl_cffi-0.14.0.tar.gz", hash = "sha256:5ffbc82e59f05008ec08ea432f0e535418823cda44178ee518906a54f27a5f0f", size = 162633, upload-time = "2025-12-16T03:25:07.931Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/aa/f0/0f21e9688eaac85e705537b3a87a5588d0cefb2f09d83e83e0e8be93aa99/curl_cffi-0.14.0-cp39-abi3-macosx_14_0_arm64.whl", hash = "sha256:e35e89c6a69872f9749d6d5fda642ed4fc159619329e99d577d0104c9aad5893", size = 3087277, upload-time = "2025-12-16T03:24:49.607Z" }, - { url = "https://files.pythonhosted.org/packages/ba/a3/0419bd48fce5b145cb6a2344c6ac17efa588f5b0061f212c88e0723da026/curl_cffi-0.14.0-cp39-abi3-macosx_15_0_x86_64.whl", hash = "sha256:5945478cd28ad7dfb5c54473bcfb6743ee1d66554d57951fdf8fc0e7d8cf4e45", size = 5804650, upload-time = "2025-12-16T03:24:51.518Z" }, - { url = "https://files.pythonhosted.org/packages/e2/07/a238dd062b7841b8caa2fa8a359eb997147ff3161288f0dd46654d898b4d/curl_cffi-0.14.0-cp39-abi3-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c42e8fa3c667db9ccd2e696ee47adcd3cd5b0838d7282f3fc45f6c0ef3cfdfa7", size = 8231918, upload-time = "2025-12-16T03:24:52.862Z" }, - { url = "https://files.pythonhosted.org/packages/7c/d2/ce907c9b37b5caf76ac08db40cc4ce3d9f94c5500db68a195af3513eacbc/curl_cffi-0.14.0-cp39-abi3-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:060fe2c99c41d3cb7f894de318ddf4b0301b08dca70453d769bd4e74b36b8483", size = 8654624, upload-time = "2025-12-16T03:24:54.579Z" }, - { url = "https://files.pythonhosted.org/packages/f2/ae/6256995b18c75e6ef76b30753a5109e786813aa79088b27c8eabb1ef85c9/curl_cffi-0.14.0-cp39-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:b158c41a25388690dd0d40b5bc38d1e0f512135f17fdb8029868cbc1993d2e5b", size = 8010654, upload-time = "2025-12-16T03:24:56.507Z" }, - { url = "https://files.pythonhosted.org/packages/fb/10/ff64249e516b103cb762e0a9dca3ee0f04cf25e2a1d5d9838e0f1273d071/curl_cffi-0.14.0-cp39-abi3-manylinux_2_28_i686.whl", hash = "sha256:1439fbef3500fb723333c826adf0efb0e2e5065a703fb5eccce637a2250db34a", size = 7781969, upload-time = "2025-12-16T03:24:57.885Z" }, - { url = "https://files.pythonhosted.org/packages/51/76/d6f7bb76c2d12811aa7ff16f5e17b678abdd1b357b9a8ac56310ceccabd5/curl_cffi-0.14.0-cp39-abi3-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e7176f2c2d22b542e3cf261072a81deb018cfa7688930f95dddef215caddb469", size = 7969133, upload-time = "2025-12-16T03:24:59.261Z" }, - { url = "https://files.pythonhosted.org/packages/23/7c/cca39c0ed4e1772613d3cba13091c0e9d3b89365e84b9bf9838259a3cd8f/curl_cffi-0.14.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:03f21ade2d72978c2bb8670e9b6de5260e2755092b02d94b70b906813662998d", size = 9080167, upload-time = "2025-12-16T03:25:00.946Z" }, - { url = "https://files.pythonhosted.org/packages/75/03/a942d7119d3e8911094d157598ae0169b1c6ca1bd3f27d7991b279bcc45b/curl_cffi-0.14.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:58ebf02de64ee5c95613209ddacb014c2d2f86298d7080c0a1c12ed876ee0690", size = 9520464, upload-time = "2025-12-16T03:25:02.922Z" }, - { url = "https://files.pythonhosted.org/packages/a2/77/78900e9b0833066d2274bda75cba426fdb4cef7fbf6a4f6a6ca447607bec/curl_cffi-0.14.0-cp39-abi3-win_amd64.whl", hash = "sha256:6e503f9a103f6ae7acfb3890c843b53ec030785a22ae7682a22cc43afb94123e", size = 1677416, upload-time = "2025-12-16T03:25:04.902Z" }, - { url = "https://files.pythonhosted.org/packages/5c/7c/d2ba86b0b3e1e2830bd94163d047de122c69a8df03c5c7c36326c456ad82/curl_cffi-0.14.0-cp39-abi3-win_arm64.whl", hash = "sha256:2eed50a969201605c863c4c31269dfc3e0da52916086ac54553cfa353022425c", size = 1425067, upload-time = "2025-12-16T03:25:06.454Z" }, -] - [[package]] name = "fastapi" version = "0.129.2" @@ -129,7 +83,6 @@ name = "gemini-fastapi" version = "1.0.0" source = { virtual = "." } dependencies = [ - { name = "curl-cffi" }, { name = "fastapi" }, { name = "gemini-webapi" }, { name = "httptools" }, @@ -154,7 +107,6 @@ dev = [ [package.metadata] requires-dist = [ - { name = "curl-cffi", specifier = ">=0.14.0" }, { name = "fastapi", specifier = ">=0.129.2" }, { name = "gemini-webapi", specifier = ">=1.19.2" }, { name = "httptools", specifier = ">=0.7.1" }, @@ -362,15 +314,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, ] -[[package]] -name = "pycparser" -version = "3.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", size = 103492, upload-time = "2026-01-21T14:26:51.89Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" }, -] - [[package]] name = "pydantic" version = "2.12.5" From 6c5cabd306e7e66f1c7a7bc9bf12c3d18df68a1e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C6=B0u=20Quang=20V=C5=A9?= Date: Sun, 22 Feb 2026 21:34:15 +0700 Subject: [PATCH 149/291] Update: Explicitly added httpx as a dependency to safeguard against potential breakages from lower-level library changes and ensure consistent availability. --- app/utils/helper.py | 2 +- pyproject.toml | 1 + uv.lock | 2 ++ 3 files changed, 4 insertions(+), 1 deletion(-) diff --git a/app/utils/helper.py b/app/utils/helper.py index 2e9e801..da5b3c5 100644 --- a/app/utils/helper.py +++ b/app/utils/helper.py @@ -202,7 +202,7 @@ async def save_url_to_tempfile(url: str, tempdir: Path | None = None) -> Path: data = base64.b64decode(url.split(",")[1]) suffix = mimetypes.guess_extension(mime_type) or f".{mime_type.split('/')[1]}" else: - async with httpx.AsyncClient(follow_redirects=True) as client: + async with httpx.AsyncClient(http2=True, follow_redirects=True) as client: resp = await client.get(url) resp.raise_for_status() data = resp.content diff --git a/pyproject.toml b/pyproject.toml index 6599122..ebbadd4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,6 +8,7 @@ dependencies = [ "fastapi>=0.129.2", "gemini-webapi>=1.19.2", "httptools>=0.7.1", + "httpx[http2]>=0.28.1", "lmdb>=1.7.5", "loguru>=0.7.3", "orjson>=3.11.7", diff --git a/uv.lock b/uv.lock index 4cae2c0..cab521a 100644 --- a/uv.lock +++ b/uv.lock @@ -86,6 +86,7 @@ dependencies = [ { name = "fastapi" }, { name = "gemini-webapi" }, { name = "httptools" }, + { name = "httpx", extra = ["http2"] }, { name = "lmdb" }, { name = "loguru" }, { name = "orjson" }, @@ -110,6 +111,7 @@ requires-dist = [ { name = "fastapi", specifier = ">=0.129.2" }, { name = "gemini-webapi", specifier = ">=1.19.2" }, { name = "httptools", specifier = ">=0.7.1" }, + { name = "httpx", extras = ["http2"], specifier = ">=0.28.1" }, { name = "lmdb", specifier = ">=1.7.5" }, { name = "loguru", specifier = ">=0.7.3" }, { name = "orjson", specifier = ">=3.11.7" }, From 6b6e589f7ac015965eda661d5bdc489188b961d1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C6=B0u=20Quang=20V=C5=A9?= Date: Mon, 23 Feb 2026 10:17:55 +0700 Subject: [PATCH 150/291] ORJSONResponse is deprecated FastAPIDeprecationWarning: ORJSONResponse is deprecated, FastAPI now serializes data directly to JSON bytes via Pydantic when a return type or response model is set, which is faster and doesn't need a custom response class. Read more in the FastAPI docs: https://fastapi.tiangolo.com/advanced/custom-response/#orjson-or-response-model and https://fastapi.tiangolo.com/tutorial/response-model/ --- app/main.py | 2 -- app/server/middleware.py | 6 +++--- pyproject.toml | 2 +- uv.lock | 8 ++++---- 4 files changed, 8 insertions(+), 10 deletions(-) diff --git a/app/main.py b/app/main.py index 0634ce2..20d15b0 100644 --- a/app/main.py +++ b/app/main.py @@ -2,7 +2,6 @@ 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 @@ -93,7 +92,6 @@ 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/server/middleware.py b/app/server/middleware.py index 4bc358d..b5bc55b 100644 --- a/app/server/middleware.py +++ b/app/server/middleware.py @@ -6,7 +6,7 @@ from fastapi import Depends, FastAPI, HTTPException, Request, status from fastapi.middleware.cors import CORSMiddleware -from fastapi.responses import ORJSONResponse +from fastapi.responses import JSONResponse from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer from loguru import logger @@ -70,12 +70,12 @@ def cleanup_expired_images(retention_days: int) -> int: def global_exception_handler(request: Request, exc: Exception): if isinstance(exc, HTTPException): - return ORJSONResponse( + return JSONResponse( status_code=exc.status_code, content={"error": {"message": exc.detail}}, ) - return ORJSONResponse( + return JSONResponse( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, content={"error": {"message": str(exc)}}, ) diff --git a/pyproject.toml b/pyproject.toml index ebbadd4..f699096 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,7 +5,7 @@ description = "FastAPI Server built on Gemini Web API" readme = "README.md" requires-python = "==3.13.*" dependencies = [ - "fastapi>=0.129.2", + "fastapi>=0.131.0", "gemini-webapi>=1.19.2", "httptools>=0.7.1", "httpx[http2]>=0.28.1", diff --git a/uv.lock b/uv.lock index cab521a..d1f77a0 100644 --- a/uv.lock +++ b/uv.lock @@ -64,7 +64,7 @@ wheels = [ [[package]] name = "fastapi" -version = "0.129.2" +version = "0.131.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "annotated-doc" }, @@ -73,9 +73,9 @@ dependencies = [ { name = "typing-extensions" }, { name = "typing-inspection" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/fd/cc/1b0d90ed759ff8c9dbc4800de7475d4e9256a81b97b45bd05a1affcb350a/fastapi-0.129.2.tar.gz", hash = "sha256:e2b3637a2b47856e704dbd9a3a09393f6df48e8b9cb6c7a3e26ba44d2053f9ab", size = 368211, upload-time = "2026-02-21T17:25:49.198Z" } +sdist = { url = "https://files.pythonhosted.org/packages/91/32/158cbf685b7d5a26f87131069da286bf10fc9fbf7fc968d169d48a45d689/fastapi-0.131.0.tar.gz", hash = "sha256:6531155e52bee2899a932c746c9a8250f210e3c3303a5f7b9f8a808bfe0548ff", size = 369612, upload-time = "2026-02-22T16:38:11.252Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/18/d0/a89a640308016c7fff8d2a47b86cc03ee7cca780b5079d0b69f466f9e1a9/fastapi-0.129.2-py3-none-any.whl", hash = "sha256:e21d9f6e8db376655187905ad0145edd6f6a4e5f2bff241c4efb8a0bffd6a540", size = 103227, upload-time = "2026-02-21T17:25:47.745Z" }, + { url = "https://files.pythonhosted.org/packages/ff/94/b58ec24c321acc2ad1327f69b033cadc005e0f26df9a73828c9e9c7db7ce/fastapi-0.131.0-py3-none-any.whl", hash = "sha256:ed0e53decccf4459de78837ce1b867cd04fa9ce4579497b842579755d20b405a", size = 103854, upload-time = "2026-02-22T16:38:09.814Z" }, ] [[package]] @@ -108,7 +108,7 @@ dev = [ [package.metadata] requires-dist = [ - { name = "fastapi", specifier = ">=0.129.2" }, + { name = "fastapi", specifier = ">=0.131.0" }, { name = "gemini-webapi", specifier = ">=1.19.2" }, { name = "httptools", specifier = ">=0.7.1" }, { name = "httpx", extras = ["http2"], specifier = ">=0.28.1" }, From fbe19db6d682ca8e93da11cfef8af0a84ffdf06f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C6=B0u=20Quang=20V=C5=A9?= Date: Mon, 23 Feb 2026 11:23:53 +0700 Subject: [PATCH 151/291] Refactor HTTP client usage to utilize AsyncSession from curl-cffi for improved performance --- app/utils/helper.py | 4 +-- pyproject.toml | 2 +- uv.lock | 59 +++++++++++++++++++++++++++++++++++++++++++-- 3 files changed, 60 insertions(+), 5 deletions(-) diff --git a/app/utils/helper.py b/app/utils/helper.py index da5b3c5..9a930b7 100644 --- a/app/utils/helper.py +++ b/app/utils/helper.py @@ -10,8 +10,8 @@ from pathlib import Path from urllib.parse import urlparse -import httpx import orjson +from curl_cffi.requests import AsyncSession from loguru import logger from app.models import FunctionCall, Message, ToolCall @@ -202,7 +202,7 @@ async def save_url_to_tempfile(url: str, tempdir: Path | None = None) -> Path: data = base64.b64decode(url.split(",")[1]) suffix = mimetypes.guess_extension(mime_type) or f".{mime_type.split('/')[1]}" else: - async with httpx.AsyncClient(http2=True, follow_redirects=True) as client: + async with AsyncSession(impersonate="chrome", allow_redirects=True) as client: resp = await client.get(url) resp.raise_for_status() data = resp.content diff --git a/pyproject.toml b/pyproject.toml index f699096..38c88e3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,10 +5,10 @@ description = "FastAPI Server built on Gemini Web API" readme = "README.md" requires-python = "==3.13.*" dependencies = [ + "curl-cffi>=0.14.0", "fastapi>=0.131.0", "gemini-webapi>=1.19.2", "httptools>=0.7.1", - "httpx[http2]>=0.28.1", "lmdb>=1.7.5", "loguru>=0.7.3", "orjson>=3.11.7", diff --git a/uv.lock b/uv.lock index d1f77a0..379fa2d 100644 --- a/uv.lock +++ b/uv.lock @@ -41,6 +41,29 @@ wheels = [ { 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]] +name = "cffi" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pycparser", marker = "implementation_name != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/eb/56/b1ba7935a17738ae8453301356628e8147c79dbb825bcbc73dc7401f9846/cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529", size = 523588, upload-time = "2025-09-08T23:24:04.541Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4b/8d/a0a47a0c9e413a658623d014e91e74a50cdd2c423f7ccfd44086ef767f90/cffi-2.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:00bdf7acc5f795150faa6957054fbbca2439db2f775ce831222b66f192f03beb", size = 185230, upload-time = "2025-09-08T23:23:00.879Z" }, + { url = "https://files.pythonhosted.org/packages/4a/d2/a6c0296814556c68ee32009d9c2ad4f85f2707cdecfd7727951ec228005d/cffi-2.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:45d5e886156860dc35862657e1494b9bae8dfa63bf56796f2fb56e1679fc0bca", size = 181043, upload-time = "2025-09-08T23:23:02.231Z" }, + { url = "https://files.pythonhosted.org/packages/b0/1e/d22cc63332bd59b06481ceaac49d6c507598642e2230f201649058a7e704/cffi-2.0.0-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:07b271772c100085dd28b74fa0cd81c8fb1a3ba18b21e03d7c27f3436a10606b", size = 212446, upload-time = "2025-09-08T23:23:03.472Z" }, + { url = "https://files.pythonhosted.org/packages/a9/f5/a2c23eb03b61a0b8747f211eb716446c826ad66818ddc7810cc2cc19b3f2/cffi-2.0.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d48a880098c96020b02d5a1f7d9251308510ce8858940e6fa99ece33f610838b", size = 220101, upload-time = "2025-09-08T23:23:04.792Z" }, + { url = "https://files.pythonhosted.org/packages/f2/7f/e6647792fc5850d634695bc0e6ab4111ae88e89981d35ac269956605feba/cffi-2.0.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f93fd8e5c8c0a4aa1f424d6173f14a892044054871c771f8566e4008eaa359d2", size = 207948, upload-time = "2025-09-08T23:23:06.127Z" }, + { url = "https://files.pythonhosted.org/packages/cb/1e/a5a1bd6f1fb30f22573f76533de12a00bf274abcdc55c8edab639078abb6/cffi-2.0.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:dd4f05f54a52fb558f1ba9f528228066954fee3ebe629fc1660d874d040ae5a3", size = 206422, upload-time = "2025-09-08T23:23:07.753Z" }, + { url = "https://files.pythonhosted.org/packages/98/df/0a1755e750013a2081e863e7cd37e0cdd02664372c754e5560099eb7aa44/cffi-2.0.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c8d3b5532fc71b7a77c09192b4a5a200ea992702734a2e9279a37f2478236f26", size = 219499, upload-time = "2025-09-08T23:23:09.648Z" }, + { url = "https://files.pythonhosted.org/packages/50/e1/a969e687fcf9ea58e6e2a928ad5e2dd88cc12f6f0ab477e9971f2309b57c/cffi-2.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d9b29c1f0ae438d5ee9acb31cadee00a58c46cc9c0b2f9038c6b0b3470877a8c", size = 222928, upload-time = "2025-09-08T23:23:10.928Z" }, + { url = "https://files.pythonhosted.org/packages/36/54/0362578dd2c9e557a28ac77698ed67323ed5b9775ca9d3fe73fe191bb5d8/cffi-2.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6d50360be4546678fc1b79ffe7a66265e28667840010348dd69a314145807a1b", size = 221302, upload-time = "2025-09-08T23:23:12.42Z" }, + { url = "https://files.pythonhosted.org/packages/eb/6d/bf9bda840d5f1dfdbf0feca87fbdb64a918a69bca42cfa0ba7b137c48cb8/cffi-2.0.0-cp313-cp313-win32.whl", hash = "sha256:74a03b9698e198d47562765773b4a8309919089150a0bb17d829ad7b44b60d27", size = 172909, upload-time = "2025-09-08T23:23:14.32Z" }, + { url = "https://files.pythonhosted.org/packages/37/18/6519e1ee6f5a1e579e04b9ddb6f1676c17368a7aba48299c3759bbc3c8b3/cffi-2.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:19f705ada2530c1167abacb171925dd886168931e0a7b78f5bffcae5c6b5be75", size = 183402, upload-time = "2025-09-08T23:23:15.535Z" }, + { url = "https://files.pythonhosted.org/packages/cb/0e/02ceeec9a7d6ee63bb596121c2c8e9b3a9e150936f4fbef6ca1943e6137c/cffi-2.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:256f80b80ca3853f90c21b23ee78cd008713787b1b1e93eae9f3d6a7134abd91", size = 177780, upload-time = "2025-09-08T23:23:16.761Z" }, +] + [[package]] name = "click" version = "8.3.1" @@ -62,6 +85,29 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, ] +[[package]] +name = "curl-cffi" +version = "0.14.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "cffi" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9b/c9/0067d9a25ed4592b022d4558157fcdb6e123516083700786d38091688767/curl_cffi-0.14.0.tar.gz", hash = "sha256:5ffbc82e59f05008ec08ea432f0e535418823cda44178ee518906a54f27a5f0f", size = 162633, upload-time = "2025-12-16T03:25:07.931Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/aa/f0/0f21e9688eaac85e705537b3a87a5588d0cefb2f09d83e83e0e8be93aa99/curl_cffi-0.14.0-cp39-abi3-macosx_14_0_arm64.whl", hash = "sha256:e35e89c6a69872f9749d6d5fda642ed4fc159619329e99d577d0104c9aad5893", size = 3087277, upload-time = "2025-12-16T03:24:49.607Z" }, + { url = "https://files.pythonhosted.org/packages/ba/a3/0419bd48fce5b145cb6a2344c6ac17efa588f5b0061f212c88e0723da026/curl_cffi-0.14.0-cp39-abi3-macosx_15_0_x86_64.whl", hash = "sha256:5945478cd28ad7dfb5c54473bcfb6743ee1d66554d57951fdf8fc0e7d8cf4e45", size = 5804650, upload-time = "2025-12-16T03:24:51.518Z" }, + { url = "https://files.pythonhosted.org/packages/e2/07/a238dd062b7841b8caa2fa8a359eb997147ff3161288f0dd46654d898b4d/curl_cffi-0.14.0-cp39-abi3-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c42e8fa3c667db9ccd2e696ee47adcd3cd5b0838d7282f3fc45f6c0ef3cfdfa7", size = 8231918, upload-time = "2025-12-16T03:24:52.862Z" }, + { url = "https://files.pythonhosted.org/packages/7c/d2/ce907c9b37b5caf76ac08db40cc4ce3d9f94c5500db68a195af3513eacbc/curl_cffi-0.14.0-cp39-abi3-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:060fe2c99c41d3cb7f894de318ddf4b0301b08dca70453d769bd4e74b36b8483", size = 8654624, upload-time = "2025-12-16T03:24:54.579Z" }, + { url = "https://files.pythonhosted.org/packages/f2/ae/6256995b18c75e6ef76b30753a5109e786813aa79088b27c8eabb1ef85c9/curl_cffi-0.14.0-cp39-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:b158c41a25388690dd0d40b5bc38d1e0f512135f17fdb8029868cbc1993d2e5b", size = 8010654, upload-time = "2025-12-16T03:24:56.507Z" }, + { url = "https://files.pythonhosted.org/packages/fb/10/ff64249e516b103cb762e0a9dca3ee0f04cf25e2a1d5d9838e0f1273d071/curl_cffi-0.14.0-cp39-abi3-manylinux_2_28_i686.whl", hash = "sha256:1439fbef3500fb723333c826adf0efb0e2e5065a703fb5eccce637a2250db34a", size = 7781969, upload-time = "2025-12-16T03:24:57.885Z" }, + { url = "https://files.pythonhosted.org/packages/51/76/d6f7bb76c2d12811aa7ff16f5e17b678abdd1b357b9a8ac56310ceccabd5/curl_cffi-0.14.0-cp39-abi3-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e7176f2c2d22b542e3cf261072a81deb018cfa7688930f95dddef215caddb469", size = 7969133, upload-time = "2025-12-16T03:24:59.261Z" }, + { url = "https://files.pythonhosted.org/packages/23/7c/cca39c0ed4e1772613d3cba13091c0e9d3b89365e84b9bf9838259a3cd8f/curl_cffi-0.14.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:03f21ade2d72978c2bb8670e9b6de5260e2755092b02d94b70b906813662998d", size = 9080167, upload-time = "2025-12-16T03:25:00.946Z" }, + { url = "https://files.pythonhosted.org/packages/75/03/a942d7119d3e8911094d157598ae0169b1c6ca1bd3f27d7991b279bcc45b/curl_cffi-0.14.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:58ebf02de64ee5c95613209ddacb014c2d2f86298d7080c0a1c12ed876ee0690", size = 9520464, upload-time = "2025-12-16T03:25:02.922Z" }, + { url = "https://files.pythonhosted.org/packages/a2/77/78900e9b0833066d2274bda75cba426fdb4cef7fbf6a4f6a6ca447607bec/curl_cffi-0.14.0-cp39-abi3-win_amd64.whl", hash = "sha256:6e503f9a103f6ae7acfb3890c843b53ec030785a22ae7682a22cc43afb94123e", size = 1677416, upload-time = "2025-12-16T03:25:04.902Z" }, + { url = "https://files.pythonhosted.org/packages/5c/7c/d2ba86b0b3e1e2830bd94163d047de122c69a8df03c5c7c36326c456ad82/curl_cffi-0.14.0-cp39-abi3-win_arm64.whl", hash = "sha256:2eed50a969201605c863c4c31269dfc3e0da52916086ac54553cfa353022425c", size = 1425067, upload-time = "2025-12-16T03:25:06.454Z" }, +] + [[package]] name = "fastapi" version = "0.131.0" @@ -83,10 +129,10 @@ name = "gemini-fastapi" version = "1.0.0" source = { virtual = "." } dependencies = [ + { name = "curl-cffi" }, { name = "fastapi" }, { name = "gemini-webapi" }, { name = "httptools" }, - { name = "httpx", extra = ["http2"] }, { name = "lmdb" }, { name = "loguru" }, { name = "orjson" }, @@ -108,10 +154,10 @@ dev = [ [package.metadata] requires-dist = [ + { name = "curl-cffi", specifier = ">=0.14.0" }, { name = "fastapi", specifier = ">=0.131.0" }, { name = "gemini-webapi", specifier = ">=1.19.2" }, { name = "httptools", specifier = ">=0.7.1" }, - { name = "httpx", extras = ["http2"], specifier = ">=0.28.1" }, { name = "lmdb", specifier = ">=1.7.5" }, { name = "loguru", specifier = ">=0.7.3" }, { name = "orjson", specifier = ">=3.11.7" }, @@ -316,6 +362,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, ] +[[package]] +name = "pycparser" +version = "3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", size = 103492, upload-time = "2026-01-21T14:26:51.89Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" }, +] + [[package]] name = "pydantic" version = "2.12.5" From b7a9ca50001d7750de161aedf69f373acedff6cc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C6=B0u=20Quang=20V=C5=A9?= Date: Tue, 24 Feb 2026 11:34:06 +0700 Subject: [PATCH 152/291] Update dependencies to latest versions --- pyproject.toml | 2 +- uv.lock | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 38c88e3..9ef1f91 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,7 +6,7 @@ readme = "README.md" requires-python = "==3.13.*" dependencies = [ "curl-cffi>=0.14.0", - "fastapi>=0.131.0", + "fastapi>=0.132.0", "gemini-webapi>=1.19.2", "httptools>=0.7.1", "lmdb>=1.7.5", diff --git a/uv.lock b/uv.lock index 379fa2d..6481763 100644 --- a/uv.lock +++ b/uv.lock @@ -110,7 +110,7 @@ wheels = [ [[package]] name = "fastapi" -version = "0.131.0" +version = "0.132.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "annotated-doc" }, @@ -119,9 +119,9 @@ dependencies = [ { name = "typing-extensions" }, { name = "typing-inspection" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/91/32/158cbf685b7d5a26f87131069da286bf10fc9fbf7fc968d169d48a45d689/fastapi-0.131.0.tar.gz", hash = "sha256:6531155e52bee2899a932c746c9a8250f210e3c3303a5f7b9f8a808bfe0548ff", size = 369612, upload-time = "2026-02-22T16:38:11.252Z" } +sdist = { url = "https://files.pythonhosted.org/packages/a0/55/f1b4d4e478a0a1b4b1113d0f610a1b08e539b69900f97fdc97155d62fdee/fastapi-0.132.0.tar.gz", hash = "sha256:ef687847936d8a57ea6ea04cf9a85fe5f2c6ba64e22bfa721467094b69d48d92", size = 372422, upload-time = "2026-02-23T17:56:22.218Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ff/94/b58ec24c321acc2ad1327f69b033cadc005e0f26df9a73828c9e9c7db7ce/fastapi-0.131.0-py3-none-any.whl", hash = "sha256:ed0e53decccf4459de78837ce1b867cd04fa9ce4579497b842579755d20b405a", size = 103854, upload-time = "2026-02-22T16:38:09.814Z" }, + { url = "https://files.pythonhosted.org/packages/a8/de/6171c3363bbc5e01686e200e0880647c9270daa476d91030435cf14d32f5/fastapi-0.132.0-py3-none-any.whl", hash = "sha256:3c487d5afce196fa8ea509ae1531e96ccd5cdd2fd6eae78b73e2c20fba706689", size = 104652, upload-time = "2026-02-23T17:56:20.836Z" }, ] [[package]] @@ -155,7 +155,7 @@ dev = [ [package.metadata] requires-dist = [ { name = "curl-cffi", specifier = ">=0.14.0" }, - { name = "fastapi", specifier = ">=0.131.0" }, + { name = "fastapi", specifier = ">=0.132.0" }, { name = "gemini-webapi", specifier = ">=1.19.2" }, { name = "httptools", specifier = ">=0.7.1" }, { name = "lmdb", specifier = ">=1.7.5" }, From c4aefbf779f6952a1252e5188ac358ce83b7230d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C6=B0u=20Quang=20V=C5=A9?= Date: Tue, 24 Feb 2026 11:57:56 +0700 Subject: [PATCH 153/291] Add API endpoint documentation for OpenAI-compatible and advanced endpoints in README files --- README.md | 24 ++++++++++++++++++++++++ README.zh.md | 24 ++++++++++++++++++++++++ 2 files changed, 48 insertions(+) diff --git a/README.md b/README.md index 91f687c..6b6f485 100644 --- a/README.md +++ b/README.md @@ -74,6 +74,30 @@ python run.py The server will start on `http://localhost:8000` by default. +## API Endpoints + +The server provides several endpoints, including OpenAI-compatible ones. + +### OpenAI-Compatible Endpoints + +These endpoints are designed to be compatible with OpenAI's API structure, allowing you to use Gemini as a drop-in replacement. + +- **`GET /v1/models`**: Lists all supported Gemini models. +- **`POST /v1/chat/completions`**: Unified chat interface. + - **Streaming**: Set `stream: true` to receive real-time delta chunks. + - **Multi-modal**: Supports text, images, and file uploads. + - **Tool Calling**: Supports function calling via the `tools` parameter. + - **Structured Output**: Supports `response_format` for JSON schema enforcement. + +### Advanced Endpoints + +- **`POST /v1/responses`**: An alternative endpoint for complex interaction patterns, supporting rich output items including generated images and tool calls. + +### Utility Endpoints + +- **`GET /health`**: Health check endpoint. Returns the status of the server, configured Gemini clients, and conversation storage. +- **`GET /images/{filename}`**: Internal endpoint to serve generated images. Requires a valid token (automatically included in image URLs returned by the API). + ## Docker Deployment ### Run with Options diff --git a/README.zh.md b/README.zh.md index d23bec1..d012d32 100644 --- a/README.zh.md +++ b/README.zh.md @@ -74,6 +74,30 @@ python run.py 服务默认启动在 `http://localhost:8000`。 +## API 接口 + +本服务器提供了一系列接口,重点支持 OpenAI 兼容协议。 + +### OpenAI 兼容接口 + +这些接口遵循 OpenAI 的 API 规范,允许你将 Gemini 作为 **Drop-in 替代方案** 直接接入现有的 AI 应用。 + +- **`GET /v1/models`**: 列出所有可用的 Gemini 模型。 +- **`POST /v1/chat/completions`**: 统一聊天对话接口。 + - **流式传输**: 设置 `stream: true` 即可实时接收增量响应 (Stream Delta)。 + - **多模态支持**: 支持在消息中包含文本、图片以及文件上传。 + - **工具调用**: 支持通过 `tools` 参数进行函数调用 (Function Calling)。 + - **结构化输出**: 支持 `response_format`,可严格遵循 JSON Schema。 + +### 高级接口 + +- **`POST /v1/responses`**: 用于复杂交互模式的专用接口,支持分步输出、生成图片及工具调用等更丰富的响应项。 + +### 辅助与系统接口 + +- **`GET /health`**: 健康检查接口。返回服务器运行状态、已配置的 Gemini 客户端健康度以及对话存储统计信息。 +- **`GET /images/{filename}`**: 用于访问生成的图片的内部接口。需携带有效 Token(API 返回的图片 URL 中已自动包含该 Token)。 + ## Docker 部署 ### 直接运行 From e00a8fa0261063e4cc82f4e22951da3ff17dc012 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C6=B0u=20Quang=20V=C5=A9?= Date: Wed, 25 Feb 2026 15:24:28 +0700 Subject: [PATCH 154/291] Add optional custom cookies parameter --- app/services/pool.py | 25 +++++++++++++++++++------ app/utils/config.py | 14 +++++++++++++- pyproject.toml | 2 +- uv.lock | 14 +++++++------- 4 files changed, 40 insertions(+), 15 deletions(-) diff --git a/app/services/pool.py b/app/services/pool.py index 3b4197c..3c26e3d 100644 --- a/app/services/pool.py +++ b/app/services/pool.py @@ -1,6 +1,8 @@ import asyncio +import inspect from collections import deque +from gemini_webapi import GeminiClient from loguru import logger from app.utils import g_config @@ -22,12 +24,23 @@ def __init__(self) -> None: raise ValueError("No Gemini clients configured") for c in g_config.gemini.clients: - client = GeminiClientWrapper( - client_id=c.id, - secure_1psid=c.secure_1psid, - secure_1psidts=c.secure_1psidts, - proxy=c.proxy, - ) + kwargs = { + "client_id": c.id, + "secure_1psid": c.secure_1psid, + "secure_1psidts": c.secure_1psidts, + "proxy": c.proxy, + } + if c.cookies: + sig = inspect.signature(GeminiClient.__init__) + if "cookies" in sig.parameters: + kwargs["cookies"] = c.cookies + else: + logger.debug( + f"Ignoring 'cookies' in config for client {c.id} because " + "the current version of gemini_webapi doesn't support it." + ) + + client = GeminiClientWrapper(**kwargs) self._clients.append(client) self._id_map[c.id] = client self._round_robin.append(client) diff --git a/app/utils/config.py b/app/utils/config.py index 21d2891..e00c7d9 100644 --- a/app/utils/config.py +++ b/app/utils/config.py @@ -42,6 +42,9 @@ class GeminiClientSettings(BaseModel): secure_1psid: str = Field(..., description="Gemini Secure 1PSID") secure_1psidts: str = Field(..., description="Gemini Secure 1PSIDTS") proxy: str | None = Field(default=None, description="Proxy URL for this Gemini client") + cookies: dict[str, str] | None = Field( + default=None, description="Optional custom cookies for this Gemini client" + ) @field_validator("proxy", mode="before") @classmethod @@ -51,6 +54,16 @@ def _blank_proxy_to_none(cls, value: str | None) -> str | None: stripped = value.strip() return stripped or None + @field_validator("cookies", mode="before") + @classmethod + def _parse_cookies(cls, v: Any) -> Any: + if isinstance(v, str) and v.strip().startswith("{"): + try: + return orjson.loads(v) + except orjson.JSONDecodeError: + pass + return v + class GeminiModelConfig(BaseModel): """Configuration for a custom Gemini model.""" @@ -67,7 +80,6 @@ def _parse_json_string(cls, v: Any) -> Any: try: return orjson.loads(v) except orjson.JSONDecodeError: - # Return the original value to let Pydantic handle the error or type mismatch return v return v diff --git a/pyproject.toml b/pyproject.toml index 9ef1f91..9a6dee9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,7 +6,7 @@ readme = "README.md" requires-python = "==3.13.*" dependencies = [ "curl-cffi>=0.14.0", - "fastapi>=0.132.0", + "fastapi>=0.133.0", "gemini-webapi>=1.19.2", "httptools>=0.7.1", "lmdb>=1.7.5", diff --git a/uv.lock b/uv.lock index 6481763..8ded1fa 100644 --- a/uv.lock +++ b/uv.lock @@ -34,11 +34,11 @@ wheels = [ [[package]] name = "certifi" -version = "2026.1.4" +version = "2026.2.25" source = { registry = "https://pypi.org/simple" } -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" } +sdist = { url = "https://files.pythonhosted.org/packages/af/2d/7bf41579a8986e348fa033a31cdd0e4121114f6bce2457e8876010b092dd/certifi-2026.2.25.tar.gz", hash = "sha256:e887ab5cee78ea814d3472169153c2d12cd43b14bd03329a39a9c6e2e80bfba7", size = 155029, upload-time = "2026-02-25T02:54:17.342Z" } wheels = [ - { 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" }, + { url = "https://files.pythonhosted.org/packages/9a/3c/c17fb3ca2d9c3acff52e30b309f538586f9f5b9c9cf454f3845fc9af4881/certifi-2026.2.25-py3-none-any.whl", hash = "sha256:027692e4402ad994f1c42e52a4997a9763c646b73e4096e4d5d6db8af1d6f0fa", size = 153684, upload-time = "2026-02-25T02:54:15.766Z" }, ] [[package]] @@ -110,7 +110,7 @@ wheels = [ [[package]] name = "fastapi" -version = "0.132.0" +version = "0.133.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "annotated-doc" }, @@ -119,9 +119,9 @@ dependencies = [ { name = "typing-extensions" }, { name = "typing-inspection" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/a0/55/f1b4d4e478a0a1b4b1113d0f610a1b08e539b69900f97fdc97155d62fdee/fastapi-0.132.0.tar.gz", hash = "sha256:ef687847936d8a57ea6ea04cf9a85fe5f2c6ba64e22bfa721467094b69d48d92", size = 372422, upload-time = "2026-02-23T17:56:22.218Z" } +sdist = { url = "https://files.pythonhosted.org/packages/c2/04/ab382c7c03dd545f2c964d06e87ad0d5faa944a2434186ad9c285f5d87e0/fastapi-0.133.0.tar.gz", hash = "sha256:b900a2bf5685cdb0647a41d5900bdeafc3a9e8a28ac08c6246b76699e164d60d", size = 373265, upload-time = "2026-02-24T09:53:40.143Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/a8/de/6171c3363bbc5e01686e200e0880647c9270daa476d91030435cf14d32f5/fastapi-0.132.0-py3-none-any.whl", hash = "sha256:3c487d5afce196fa8ea509ae1531e96ccd5cdd2fd6eae78b73e2c20fba706689", size = 104652, upload-time = "2026-02-23T17:56:20.836Z" }, + { url = "https://files.pythonhosted.org/packages/bf/b4/023e75a2ec3f5440e380df6caf4d28edc0806d007193e6fb0707237886a4/fastapi-0.133.0-py3-none-any.whl", hash = "sha256:0a78878483d60702a1dde864c24ab349a1a53ef4db6b6f74f8cd4a2b2bc67d2f", size = 104787, upload-time = "2026-02-24T09:53:41.404Z" }, ] [[package]] @@ -155,7 +155,7 @@ dev = [ [package.metadata] requires-dist = [ { name = "curl-cffi", specifier = ">=0.14.0" }, - { name = "fastapi", specifier = ">=0.132.0" }, + { name = "fastapi", specifier = ">=0.133.0" }, { name = "gemini-webapi", specifier = ">=1.19.2" }, { name = "httptools", specifier = ">=0.7.1" }, { name = "lmdb", specifier = ">=1.7.5" }, From eb3c286f7951282ba94929de68eaf732ba858b8e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C6=B0u=20Quang=20V=C5=A9?= Date: Thu, 26 Feb 2026 19:51:39 +0700 Subject: [PATCH 155/291] Revert "Add optional custom cookies parameter" This reverts commit e00a8fa0261063e4cc82f4e22951da3ff17dc012. --- app/services/pool.py | 25 ++++++------------------- app/utils/config.py | 14 +------------- pyproject.toml | 2 +- uv.lock | 14 +++++++------- 4 files changed, 15 insertions(+), 40 deletions(-) diff --git a/app/services/pool.py b/app/services/pool.py index 3c26e3d..3b4197c 100644 --- a/app/services/pool.py +++ b/app/services/pool.py @@ -1,8 +1,6 @@ import asyncio -import inspect from collections import deque -from gemini_webapi import GeminiClient from loguru import logger from app.utils import g_config @@ -24,23 +22,12 @@ def __init__(self) -> None: raise ValueError("No Gemini clients configured") for c in g_config.gemini.clients: - kwargs = { - "client_id": c.id, - "secure_1psid": c.secure_1psid, - "secure_1psidts": c.secure_1psidts, - "proxy": c.proxy, - } - if c.cookies: - sig = inspect.signature(GeminiClient.__init__) - if "cookies" in sig.parameters: - kwargs["cookies"] = c.cookies - else: - logger.debug( - f"Ignoring 'cookies' in config for client {c.id} because " - "the current version of gemini_webapi doesn't support it." - ) - - client = GeminiClientWrapper(**kwargs) + client = GeminiClientWrapper( + client_id=c.id, + secure_1psid=c.secure_1psid, + secure_1psidts=c.secure_1psidts, + proxy=c.proxy, + ) self._clients.append(client) self._id_map[c.id] = client self._round_robin.append(client) diff --git a/app/utils/config.py b/app/utils/config.py index e00c7d9..21d2891 100644 --- a/app/utils/config.py +++ b/app/utils/config.py @@ -42,9 +42,6 @@ class GeminiClientSettings(BaseModel): secure_1psid: str = Field(..., description="Gemini Secure 1PSID") secure_1psidts: str = Field(..., description="Gemini Secure 1PSIDTS") proxy: str | None = Field(default=None, description="Proxy URL for this Gemini client") - cookies: dict[str, str] | None = Field( - default=None, description="Optional custom cookies for this Gemini client" - ) @field_validator("proxy", mode="before") @classmethod @@ -54,16 +51,6 @@ def _blank_proxy_to_none(cls, value: str | None) -> str | None: stripped = value.strip() return stripped or None - @field_validator("cookies", mode="before") - @classmethod - def _parse_cookies(cls, v: Any) -> Any: - if isinstance(v, str) and v.strip().startswith("{"): - try: - return orjson.loads(v) - except orjson.JSONDecodeError: - pass - return v - class GeminiModelConfig(BaseModel): """Configuration for a custom Gemini model.""" @@ -80,6 +67,7 @@ def _parse_json_string(cls, v: Any) -> Any: try: return orjson.loads(v) except orjson.JSONDecodeError: + # Return the original value to let Pydantic handle the error or type mismatch return v return v diff --git a/pyproject.toml b/pyproject.toml index 9a6dee9..9ef1f91 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,7 +6,7 @@ readme = "README.md" requires-python = "==3.13.*" dependencies = [ "curl-cffi>=0.14.0", - "fastapi>=0.133.0", + "fastapi>=0.132.0", "gemini-webapi>=1.19.2", "httptools>=0.7.1", "lmdb>=1.7.5", diff --git a/uv.lock b/uv.lock index 8ded1fa..6481763 100644 --- a/uv.lock +++ b/uv.lock @@ -34,11 +34,11 @@ wheels = [ [[package]] name = "certifi" -version = "2026.2.25" +version = "2026.1.4" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/af/2d/7bf41579a8986e348fa033a31cdd0e4121114f6bce2457e8876010b092dd/certifi-2026.2.25.tar.gz", hash = "sha256:e887ab5cee78ea814d3472169153c2d12cd43b14bd03329a39a9c6e2e80bfba7", size = 155029, upload-time = "2026-02-25T02:54:17.342Z" } +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/9a/3c/c17fb3ca2d9c3acff52e30b309f538586f9f5b9c9cf454f3845fc9af4881/certifi-2026.2.25-py3-none-any.whl", hash = "sha256:027692e4402ad994f1c42e52a4997a9763c646b73e4096e4d5d6db8af1d6f0fa", size = 153684, upload-time = "2026-02-25T02:54:15.766Z" }, + { 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]] @@ -110,7 +110,7 @@ wheels = [ [[package]] name = "fastapi" -version = "0.133.0" +version = "0.132.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "annotated-doc" }, @@ -119,9 +119,9 @@ dependencies = [ { name = "typing-extensions" }, { name = "typing-inspection" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/c2/04/ab382c7c03dd545f2c964d06e87ad0d5faa944a2434186ad9c285f5d87e0/fastapi-0.133.0.tar.gz", hash = "sha256:b900a2bf5685cdb0647a41d5900bdeafc3a9e8a28ac08c6246b76699e164d60d", size = 373265, upload-time = "2026-02-24T09:53:40.143Z" } +sdist = { url = "https://files.pythonhosted.org/packages/a0/55/f1b4d4e478a0a1b4b1113d0f610a1b08e539b69900f97fdc97155d62fdee/fastapi-0.132.0.tar.gz", hash = "sha256:ef687847936d8a57ea6ea04cf9a85fe5f2c6ba64e22bfa721467094b69d48d92", size = 372422, upload-time = "2026-02-23T17:56:22.218Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/bf/b4/023e75a2ec3f5440e380df6caf4d28edc0806d007193e6fb0707237886a4/fastapi-0.133.0-py3-none-any.whl", hash = "sha256:0a78878483d60702a1dde864c24ab349a1a53ef4db6b6f74f8cd4a2b2bc67d2f", size = 104787, upload-time = "2026-02-24T09:53:41.404Z" }, + { url = "https://files.pythonhosted.org/packages/a8/de/6171c3363bbc5e01686e200e0880647c9270daa476d91030435cf14d32f5/fastapi-0.132.0-py3-none-any.whl", hash = "sha256:3c487d5afce196fa8ea509ae1531e96ccd5cdd2fd6eae78b73e2c20fba706689", size = 104652, upload-time = "2026-02-23T17:56:20.836Z" }, ] [[package]] @@ -155,7 +155,7 @@ dev = [ [package.metadata] requires-dist = [ { name = "curl-cffi", specifier = ">=0.14.0" }, - { name = "fastapi", specifier = ">=0.133.0" }, + { name = "fastapi", specifier = ">=0.132.0" }, { name = "gemini-webapi", specifier = ">=1.19.2" }, { name = "httptools", specifier = ">=0.7.1" }, { name = "lmdb", specifier = ">=1.7.5" }, From 29682fd61f8495ed652fe5f1e24fb4d848dd1d3e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C6=B0u=20Quang=20V=C5=A9?= Date: Thu, 26 Feb 2026 19:54:55 +0700 Subject: [PATCH 156/291] Update dependencies to latest versions --- pyproject.toml | 2 +- uv.lock | 14 +++++++------- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 9ef1f91..68f4563 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,7 +6,7 @@ readme = "README.md" requires-python = "==3.13.*" dependencies = [ "curl-cffi>=0.14.0", - "fastapi>=0.132.0", + "fastapi>=0.133.1", "gemini-webapi>=1.19.2", "httptools>=0.7.1", "lmdb>=1.7.5", diff --git a/uv.lock b/uv.lock index 6481763..4c2f563 100644 --- a/uv.lock +++ b/uv.lock @@ -34,11 +34,11 @@ wheels = [ [[package]] name = "certifi" -version = "2026.1.4" +version = "2026.2.25" source = { registry = "https://pypi.org/simple" } -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" } +sdist = { url = "https://files.pythonhosted.org/packages/af/2d/7bf41579a8986e348fa033a31cdd0e4121114f6bce2457e8876010b092dd/certifi-2026.2.25.tar.gz", hash = "sha256:e887ab5cee78ea814d3472169153c2d12cd43b14bd03329a39a9c6e2e80bfba7", size = 155029, upload-time = "2026-02-25T02:54:17.342Z" } wheels = [ - { 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" }, + { url = "https://files.pythonhosted.org/packages/9a/3c/c17fb3ca2d9c3acff52e30b309f538586f9f5b9c9cf454f3845fc9af4881/certifi-2026.2.25-py3-none-any.whl", hash = "sha256:027692e4402ad994f1c42e52a4997a9763c646b73e4096e4d5d6db8af1d6f0fa", size = 153684, upload-time = "2026-02-25T02:54:15.766Z" }, ] [[package]] @@ -110,7 +110,7 @@ wheels = [ [[package]] name = "fastapi" -version = "0.132.0" +version = "0.133.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "annotated-doc" }, @@ -119,9 +119,9 @@ dependencies = [ { name = "typing-extensions" }, { name = "typing-inspection" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/a0/55/f1b4d4e478a0a1b4b1113d0f610a1b08e539b69900f97fdc97155d62fdee/fastapi-0.132.0.tar.gz", hash = "sha256:ef687847936d8a57ea6ea04cf9a85fe5f2c6ba64e22bfa721467094b69d48d92", size = 372422, upload-time = "2026-02-23T17:56:22.218Z" } +sdist = { url = "https://files.pythonhosted.org/packages/22/6f/0eafed8349eea1fa462238b54a624c8b408cd1ba2795c8e64aa6c34f8ab7/fastapi-0.133.1.tar.gz", hash = "sha256:ed152a45912f102592976fde6cbce7dae1a8a1053da94202e51dd35d184fadd6", size = 378741, upload-time = "2026-02-25T18:18:17.398Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/a8/de/6171c3363bbc5e01686e200e0880647c9270daa476d91030435cf14d32f5/fastapi-0.132.0-py3-none-any.whl", hash = "sha256:3c487d5afce196fa8ea509ae1531e96ccd5cdd2fd6eae78b73e2c20fba706689", size = 104652, upload-time = "2026-02-23T17:56:20.836Z" }, + { url = "https://files.pythonhosted.org/packages/d2/c9/a175a7779f3599dfa4adfc97a6ce0e157237b3d7941538604aadaf97bfb6/fastapi-0.133.1-py3-none-any.whl", hash = "sha256:658f34ba334605b1617a65adf2ea6461901bdb9af3a3080d63ff791ecf7dc2e2", size = 109029, upload-time = "2026-02-25T18:18:18.578Z" }, ] [[package]] @@ -155,7 +155,7 @@ dev = [ [package.metadata] requires-dist = [ { name = "curl-cffi", specifier = ">=0.14.0" }, - { name = "fastapi", specifier = ">=0.132.0" }, + { name = "fastapi", specifier = ">=0.133.1" }, { name = "gemini-webapi", specifier = ">=1.19.2" }, { name = "httptools", specifier = ">=0.7.1" }, { name = "lmdb", specifier = ">=1.7.5" }, From 5fcd60b933b6d99b2e9e5f09764988271e212e55 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C6=B0u=20Quang=20V=C5=A9?= Date: Thu, 26 Feb 2026 21:29:30 +0700 Subject: [PATCH 157/291] Edit watchdog_timeout --- app/utils/config.py | 2 +- config/config.yaml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/app/utils/config.py b/app/utils/config.py index 21d2891..c4c81c7 100644 --- a/app/utils/config.py +++ b/app/utils/config.py @@ -85,7 +85,7 @@ class GeminiConfig(BaseModel): ) timeout: int = Field(default=300, ge=30, description="Init timeout in seconds") watchdog_timeout: int = Field( - default=60, ge=10, le=75, description="Watchdog timeout in seconds (Not more than 75s)" + default=120, ge=30, description="Watchdog timeout in seconds" ) auto_refresh: bool = Field(True, description="Enable auto-refresh for Gemini cookies") refresh_interval: int = Field( diff --git a/config/config.yaml b/config/config.yaml index 3d5e6f4..56497ad 100644 --- a/config/config.yaml +++ b/config/config.yaml @@ -23,7 +23,7 @@ gemini: secure_1psidts: "YOUR_SECURE_1PSIDTS_HERE" proxy: null # Optional proxy URL (null/empty means direct connection) timeout: 300 # Init timeout in seconds (Not less than 30s) - watchdog_timeout: 60 # Watchdog timeout in seconds (Not more than 75s) + watchdog_timeout: 120 # Watchdog timeout in seconds (Not less than 30s) auto_refresh: true # Auto-refresh session cookies refresh_interval: 540 # Refresh interval in seconds (Not less than 60s) verbose: false # Enable verbose logging for Gemini requests From 2305e2d46f2a79627798c5491d3e2ac929a7888c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C6=B0u=20Quang=20V=C5=A9?= Date: Thu, 26 Feb 2026 21:38:47 +0700 Subject: [PATCH 158/291] Edit watchdog_timeout --- app/utils/config.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/app/utils/config.py b/app/utils/config.py index c4c81c7..89119a1 100644 --- a/app/utils/config.py +++ b/app/utils/config.py @@ -84,9 +84,7 @@ class GeminiConfig(BaseModel): description="Strategy for loading models: 'append' merges custom with default, 'overwrite' uses only custom", ) timeout: int = Field(default=300, ge=30, description="Init timeout in seconds") - watchdog_timeout: int = Field( - default=120, ge=30, description="Watchdog timeout in seconds" - ) + watchdog_timeout: int = Field(default=120, ge=30, description="Watchdog timeout in seconds") auto_refresh: bool = Field(True, description="Enable auto-refresh for Gemini cookies") refresh_interval: int = Field( default=540, From c9693bd98a1eff8c5f3a18783e33e0337e2bf828 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C6=B0u=20Quang=20V=C5=A9?= Date: Thu, 26 Feb 2026 22:13:21 +0700 Subject: [PATCH 159/291] Add optional custom cookies parameter --- app/services/pool.py | 25 +++++++++++++++++++------ app/utils/config.py | 14 +++++++++++++- 2 files changed, 32 insertions(+), 7 deletions(-) diff --git a/app/services/pool.py b/app/services/pool.py index 3b4197c..3c26e3d 100644 --- a/app/services/pool.py +++ b/app/services/pool.py @@ -1,6 +1,8 @@ import asyncio +import inspect from collections import deque +from gemini_webapi import GeminiClient from loguru import logger from app.utils import g_config @@ -22,12 +24,23 @@ def __init__(self) -> None: raise ValueError("No Gemini clients configured") for c in g_config.gemini.clients: - client = GeminiClientWrapper( - client_id=c.id, - secure_1psid=c.secure_1psid, - secure_1psidts=c.secure_1psidts, - proxy=c.proxy, - ) + kwargs = { + "client_id": c.id, + "secure_1psid": c.secure_1psid, + "secure_1psidts": c.secure_1psidts, + "proxy": c.proxy, + } + if c.cookies: + sig = inspect.signature(GeminiClient.__init__) + if "cookies" in sig.parameters: + kwargs["cookies"] = c.cookies + else: + logger.debug( + f"Ignoring 'cookies' in config for client {c.id} because " + "the current version of gemini_webapi doesn't support it." + ) + + client = GeminiClientWrapper(**kwargs) self._clients.append(client) self._id_map[c.id] = client self._round_robin.append(client) diff --git a/app/utils/config.py b/app/utils/config.py index 89119a1..1aee013 100644 --- a/app/utils/config.py +++ b/app/utils/config.py @@ -42,6 +42,9 @@ class GeminiClientSettings(BaseModel): secure_1psid: str = Field(..., description="Gemini Secure 1PSID") secure_1psidts: str = Field(..., description="Gemini Secure 1PSIDTS") proxy: str | None = Field(default=None, description="Proxy URL for this Gemini client") + cookies: dict[str, str] | None = Field( + default=None, description="Optional custom cookies for this Gemini client" + ) @field_validator("proxy", mode="before") @classmethod @@ -51,6 +54,16 @@ def _blank_proxy_to_none(cls, value: str | None) -> str | None: stripped = value.strip() return stripped or None + @field_validator("cookies", mode="before") + @classmethod + def _parse_cookies(cls, v: Any) -> Any: + if isinstance(v, str) and v.strip().startswith("{"): + try: + return orjson.loads(v) + except orjson.JSONDecodeError: + pass + return v + class GeminiModelConfig(BaseModel): """Configuration for a custom Gemini model.""" @@ -67,7 +80,6 @@ def _parse_json_string(cls, v: Any) -> Any: try: return orjson.loads(v) except orjson.JSONDecodeError: - # Return the original value to let Pydantic handle the error or type mismatch return v return v From 1c7f99a47f291624fa681981ca90e397ea1d48fe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C6=B0u=20Quang=20V=C5=A9?= Date: Fri, 27 Feb 2026 23:42:29 +0700 Subject: [PATCH 160/291] Increase timeout settings for improved connection stability during peak hours --- .github/workflows/ruff.yaml | 2 +- app/services/client.py | 3 ++- app/utils/config.py | 6 +++--- config/config.yaml | 6 +++--- pyproject.toml | 2 +- uv.lock | 42 ++++++++++++++++++------------------- 6 files changed, 31 insertions(+), 30 deletions(-) diff --git a/.github/workflows/ruff.yaml b/.github/workflows/ruff.yaml index d451cdc..5e13127 100644 --- a/.github/workflows/ruff.yaml +++ b/.github/workflows/ruff.yaml @@ -24,7 +24,7 @@ jobs: - name: Install Ruff run: | python -m pip install --upgrade pip - pip install "ruff>=0.15.1" + pip install ruff - name: Run Ruff run: ruff check . diff --git a/app/services/client.py b/app/services/client.py index 49d9e87..b8f976b 100644 --- a/app/services/client.py +++ b/app/services/client.py @@ -33,7 +33,7 @@ async def init( timeout: float = cast(float, _UNSET), watchdog_timeout: float = cast(float, _UNSET), auto_close: bool = False, - close_delay: float = 300, + close_delay: float = cast(float, _UNSET), auto_refresh: bool = cast(bool, _UNSET), refresh_interval: float = cast(float, _UNSET), verbose: bool = cast(bool, _UNSET), @@ -44,6 +44,7 @@ async def init( config = g_config.gemini timeout = cast(float, _resolve(timeout, config.timeout)) watchdog_timeout = cast(float, _resolve(watchdog_timeout, config.watchdog_timeout)) + close_delay = timeout auto_refresh = cast(bool, _resolve(auto_refresh, config.auto_refresh)) refresh_interval = cast(float, _resolve(refresh_interval, config.refresh_interval)) verbose = cast(bool, _resolve(verbose, config.verbose)) diff --git a/app/utils/config.py b/app/utils/config.py index 1aee013..7371623 100644 --- a/app/utils/config.py +++ b/app/utils/config.py @@ -95,11 +95,11 @@ class GeminiConfig(BaseModel): default="append", description="Strategy for loading models: 'append' merges custom with default, 'overwrite' uses only custom", ) - timeout: int = Field(default=300, ge=30, description="Init timeout in seconds") - watchdog_timeout: int = Field(default=120, ge=30, description="Watchdog timeout in seconds") + timeout: int = Field(default=600, ge=30, description="Init timeout in seconds") + watchdog_timeout: int = Field(default=300, ge=30, description="Watchdog timeout in seconds") auto_refresh: bool = Field(True, description="Enable auto-refresh for Gemini cookies") refresh_interval: int = Field( - default=540, + default=600, ge=60, description="Interval in seconds to refresh Gemini cookies (Not less than 60s)", ) diff --git a/config/config.yaml b/config/config.yaml index 56497ad..f38ef86 100644 --- a/config/config.yaml +++ b/config/config.yaml @@ -22,10 +22,10 @@ gemini: secure_1psid: "YOUR_SECURE_1PSID_HERE" secure_1psidts: "YOUR_SECURE_1PSIDTS_HERE" proxy: null # Optional proxy URL (null/empty means direct connection) - timeout: 300 # Init timeout in seconds (Not less than 30s) - watchdog_timeout: 120 # Watchdog timeout in seconds (Not less than 30s) + timeout: 600 # Init timeout in seconds (Not less than 30s) + watchdog_timeout: 300 # Watchdog timeout in seconds (Not less than 30s) auto_refresh: true # Auto-refresh session cookies - refresh_interval: 540 # Refresh interval in seconds (Not less than 60s) + refresh_interval: 600 # Refresh interval in seconds (Not less than 60s) verbose: false # Enable verbose logging for Gemini requests max_chars_per_request: 1000000 # Maximum characters Gemini Web accepts per request. Non-pro users might have a lower limit model_strategy: "append" # Strategy: 'append' (default + custom) or 'overwrite' (custom only) diff --git a/pyproject.toml b/pyproject.toml index 68f4563..c99fbbd 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -23,7 +23,7 @@ Repository = "https://github.com/Nativu5/Gemini-FastAPI" [project.optional-dependencies] dev = [ "pytest>=9.0.2", - "ruff>=0.15.2", + "ruff>=0.15.4", ] [dependency-groups] diff --git a/uv.lock b/uv.lock index 4c2f563..077ff7a 100644 --- a/uv.lock +++ b/uv.lock @@ -484,27 +484,27 @@ wheels = [ [[package]] name = "ruff" -version = "0.15.2" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/06/04/eab13a954e763b0606f460443fcbf6bb5a0faf06890ea3754ff16523dce5/ruff-0.15.2.tar.gz", hash = "sha256:14b965afee0969e68bb871eba625343b8673375f457af4abe98553e8bbb98342", size = 4558148, upload-time = "2026-02-19T22:32:20.271Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/2f/70/3a4dc6d09b13cb3e695f28307e5d889b2e1a66b7af9c5e257e796695b0e6/ruff-0.15.2-py3-none-linux_armv6l.whl", hash = "sha256:120691a6fdae2f16d65435648160f5b81a9625288f75544dc40637436b5d3c0d", size = 10430565, upload-time = "2026-02-19T22:32:41.824Z" }, - { url = "https://files.pythonhosted.org/packages/71/0b/bb8457b56185ece1305c666dc895832946d24055be90692381c31d57466d/ruff-0.15.2-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:a89056d831256099658b6bba4037ac6dd06f49d194199215befe2bb10457ea5e", size = 10820354, upload-time = "2026-02-19T22:32:07.366Z" }, - { url = "https://files.pythonhosted.org/packages/2d/c1/e0532d7f9c9e0b14c46f61b14afd563298b8b83f337b6789ddd987e46121/ruff-0.15.2-py3-none-macosx_11_0_arm64.whl", hash = "sha256:e36dee3a64be0ebd23c86ffa3aa3fd3ac9a712ff295e192243f814a830b6bd87", size = 10170767, upload-time = "2026-02-19T22:32:13.188Z" }, - { url = "https://files.pythonhosted.org/packages/47/e8/da1aa341d3af017a21c7a62fb5ec31d4e7ad0a93ab80e3a508316efbcb23/ruff-0.15.2-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a9fb47b6d9764677f8c0a193c0943ce9a05d6763523f132325af8a858eadc2b9", size = 10529591, upload-time = "2026-02-19T22:32:02.547Z" }, - { url = "https://files.pythonhosted.org/packages/93/74/184fbf38e9f3510231fbc5e437e808f0b48c42d1df9434b208821efcd8d6/ruff-0.15.2-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f376990f9d0d6442ea9014b19621d8f2aaf2b8e39fdbfc79220b7f0c596c9b80", size = 10260771, upload-time = "2026-02-19T22:32:36.938Z" }, - { url = "https://files.pythonhosted.org/packages/05/ac/605c20b8e059a0bc4b42360414baa4892ff278cec1c91fff4be0dceedefd/ruff-0.15.2-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2dcc987551952d73cbf5c88d9fdee815618d497e4df86cd4c4824cc59d5dd75f", size = 11045791, upload-time = "2026-02-19T22:32:31.642Z" }, - { url = "https://files.pythonhosted.org/packages/fd/52/db6e419908f45a894924d410ac77d64bdd98ff86901d833364251bd08e22/ruff-0.15.2-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:42a47fd785cbe8c01b9ff45031af875d101b040ad8f4de7bbb716487c74c9a77", size = 11879271, upload-time = "2026-02-19T22:32:29.305Z" }, - { url = "https://files.pythonhosted.org/packages/3e/d8/7992b18f2008bdc9231d0f10b16df7dda964dbf639e2b8b4c1b4e91b83af/ruff-0.15.2-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cbe9f49354866e575b4c6943856989f966421870e85cd2ac94dccb0a9dcb2fea", size = 11303707, upload-time = "2026-02-19T22:32:22.492Z" }, - { url = "https://files.pythonhosted.org/packages/d7/02/849b46184bcfdd4b64cde61752cc9a146c54759ed036edd11857e9b8443b/ruff-0.15.2-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b7a672c82b5f9887576087d97be5ce439f04bbaf548ee987b92d3a7dede41d3a", size = 11149151, upload-time = "2026-02-19T22:32:44.234Z" }, - { url = "https://files.pythonhosted.org/packages/70/04/f5284e388bab60d1d3b99614a5a9aeb03e0f333847e2429bebd2aaa1feec/ruff-0.15.2-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:72ecc64f46f7019e2bcc3cdc05d4a7da958b629a5ab7033195e11a438403d956", size = 11091132, upload-time = "2026-02-19T22:32:24.691Z" }, - { url = "https://files.pythonhosted.org/packages/fa/ae/88d844a21110e14d92cf73d57363fab59b727ebeabe78009b9ccb23500af/ruff-0.15.2-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:8dcf243b15b561c655c1ef2f2b0050e5d50db37fe90115507f6ff37d865dc8b4", size = 10504717, upload-time = "2026-02-19T22:32:26.75Z" }, - { url = "https://files.pythonhosted.org/packages/64/27/867076a6ada7f2b9c8292884ab44d08fd2ba71bd2b5364d4136f3cd537e1/ruff-0.15.2-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:dab6941c862c05739774677c6273166d2510d254dac0695c0e3f5efa1b5585de", size = 10263122, upload-time = "2026-02-19T22:32:10.036Z" }, - { url = "https://files.pythonhosted.org/packages/e7/ef/faf9321d550f8ebf0c6373696e70d1758e20ccdc3951ad7af00c0956be7c/ruff-0.15.2-py3-none-musllinux_1_2_i686.whl", hash = "sha256:1b9164f57fc36058e9a6806eb92af185b0697c9fe4c7c52caa431c6554521e5c", size = 10735295, upload-time = "2026-02-19T22:32:39.227Z" }, - { url = "https://files.pythonhosted.org/packages/2f/55/e8089fec62e050ba84d71b70e7834b97709ca9b7aba10c1a0b196e493f97/ruff-0.15.2-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:80d24fcae24d42659db7e335b9e1531697a7102c19185b8dc4a028b952865fd8", size = 11241641, upload-time = "2026-02-19T22:32:34.617Z" }, - { url = "https://files.pythonhosted.org/packages/23/01/1c30526460f4d23222d0fabd5888868262fd0e2b71a00570ca26483cd993/ruff-0.15.2-py3-none-win32.whl", hash = "sha256:fd5ff9e5f519a7e1bd99cbe8daa324010a74f5e2ebc97c6242c08f26f3714f6f", size = 10507885, upload-time = "2026-02-19T22:32:15.635Z" }, - { url = "https://files.pythonhosted.org/packages/5c/10/3d18e3bbdf8fc50bbb4ac3cc45970aa5a9753c5cb51bf9ed9a3cd8b79fa3/ruff-0.15.2-py3-none-win_amd64.whl", hash = "sha256:d20014e3dfa400f3ff84830dfb5755ece2de45ab62ecea4af6b7262d0fb4f7c5", size = 11623725, upload-time = "2026-02-19T22:32:04.947Z" }, - { url = "https://files.pythonhosted.org/packages/6d/78/097c0798b1dab9f8affe73da9642bb4500e098cb27fd8dc9724816ac747b/ruff-0.15.2-py3-none-win_arm64.whl", hash = "sha256:cabddc5822acdc8f7b5527b36ceac55cc51eec7b1946e60181de8fe83ca8876e", size = 10941649, upload-time = "2026-02-19T22:32:18.108Z" }, +version = "0.15.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/da/31/d6e536cdebb6568ae75a7f00e4b4819ae0ad2640c3604c305a0428680b0c/ruff-0.15.4.tar.gz", hash = "sha256:3412195319e42d634470cc97aa9803d07e9d5c9223b99bcb1518f0c725f26ae1", size = 4569550, upload-time = "2026-02-26T20:04:14.959Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f2/82/c11a03cfec3a4d26a0ea1e571f0f44be5993b923f905eeddfc397c13d360/ruff-0.15.4-py3-none-linux_armv6l.whl", hash = "sha256:a1810931c41606c686bae8b5b9a8072adac2f611bb433c0ba476acba17a332e0", size = 10453333, upload-time = "2026-02-26T20:04:20.093Z" }, + { url = "https://files.pythonhosted.org/packages/ce/5d/6a1f271f6e31dffb31855996493641edc3eef8077b883eaf007a2f1c2976/ruff-0.15.4-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:5a1632c66672b8b4d3e1d1782859e98d6e0b4e70829530666644286600a33992", size = 10853356, upload-time = "2026-02-26T20:04:05.808Z" }, + { url = "https://files.pythonhosted.org/packages/b1/d8/0fab9f8842b83b1a9c2bf81b85063f65e93fb512e60effa95b0be49bfc54/ruff-0.15.4-py3-none-macosx_11_0_arm64.whl", hash = "sha256:a4386ba2cd6c0f4ff75252845906acc7c7c8e1ac567b7bc3d373686ac8c222ba", size = 10187434, upload-time = "2026-02-26T20:03:54.656Z" }, + { url = "https://files.pythonhosted.org/packages/85/cc/cc220fd9394eff5db8d94dec199eec56dd6c9f3651d8869d024867a91030/ruff-0.15.4-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b2496488bdfd3732747558b6f95ae427ff066d1fcd054daf75f5a50674411e75", size = 10535456, upload-time = "2026-02-26T20:03:52.738Z" }, + { url = "https://files.pythonhosted.org/packages/fa/0f/bced38fa5cf24373ec767713c8e4cadc90247f3863605fb030e597878661/ruff-0.15.4-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3f1c4893841ff2d54cbda1b2860fa3260173df5ddd7b95d370186f8a5e66a4ac", size = 10287772, upload-time = "2026-02-26T20:04:08.138Z" }, + { url = "https://files.pythonhosted.org/packages/2b/90/58a1802d84fed15f8f281925b21ab3cecd813bde52a8ca033a4de8ab0e7a/ruff-0.15.4-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:820b8766bd65503b6c30aaa6331e8ef3a6e564f7999c844e9a547c40179e440a", size = 11049051, upload-time = "2026-02-26T20:04:03.53Z" }, + { url = "https://files.pythonhosted.org/packages/d2/ac/b7ad36703c35f3866584564dc15f12f91cb1a26a897dc2fd13d7cb3ae1af/ruff-0.15.4-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c9fb74bab47139c1751f900f857fa503987253c3ef89129b24ed375e72873e85", size = 11890494, upload-time = "2026-02-26T20:04:10.497Z" }, + { url = "https://files.pythonhosted.org/packages/93/3d/3eb2f47a39a8b0da99faf9c54d3eb24720add1e886a5309d4d1be73a6380/ruff-0.15.4-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f80c98765949c518142b3a50a5db89343aa90f2c2bf7799de9986498ae6176db", size = 11326221, upload-time = "2026-02-26T20:04:12.84Z" }, + { url = "https://files.pythonhosted.org/packages/ff/90/bf134f4c1e5243e62690e09d63c55df948a74084c8ac3e48a88468314da6/ruff-0.15.4-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:451a2e224151729b3b6c9ffb36aed9091b2996fe4bdbd11f47e27d8f2e8888ec", size = 11168459, upload-time = "2026-02-26T20:04:00.969Z" }, + { url = "https://files.pythonhosted.org/packages/b5/e5/a64d27688789b06b5d55162aafc32059bb8c989c61a5139a36e1368285eb/ruff-0.15.4-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:a8f157f2e583c513c4f5f896163a93198297371f34c04220daf40d133fdd4f7f", size = 11104366, upload-time = "2026-02-26T20:03:48.099Z" }, + { url = "https://files.pythonhosted.org/packages/f1/f6/32d1dcb66a2559763fc3027bdd65836cad9eb09d90f2ed6a63d8e9252b02/ruff-0.15.4-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:917cc68503357021f541e69b35361c99387cdbbf99bd0ea4aa6f28ca99ff5338", size = 10510887, upload-time = "2026-02-26T20:03:45.771Z" }, + { url = "https://files.pythonhosted.org/packages/ff/92/22d1ced50971c5b6433aed166fcef8c9343f567a94cf2b9d9089f6aa80fe/ruff-0.15.4-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:e9737c8161da79fd7cfec19f1e35620375bd8b2a50c3e77fa3d2c16f574105cc", size = 10285939, upload-time = "2026-02-26T20:04:22.42Z" }, + { url = "https://files.pythonhosted.org/packages/e6/f4/7c20aec3143837641a02509a4668fb146a642fd1211846634edc17eb5563/ruff-0.15.4-py3-none-musllinux_1_2_i686.whl", hash = "sha256:291258c917539e18f6ba40482fe31d6f5ac023994ee11d7bdafd716f2aab8a68", size = 10765471, upload-time = "2026-02-26T20:03:58.924Z" }, + { url = "https://files.pythonhosted.org/packages/d0/09/6d2f7586f09a16120aebdff8f64d962d7c4348313c77ebb29c566cefc357/ruff-0.15.4-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:3f83c45911da6f2cd5936c436cf86b9f09f09165f033a99dcf7477e34041cbc3", size = 11263382, upload-time = "2026-02-26T20:04:24.424Z" }, + { url = "https://files.pythonhosted.org/packages/1b/fa/2ef715a1cd329ef47c1a050e10dee91a9054b7ce2fcfdd6a06d139afb7ec/ruff-0.15.4-py3-none-win32.whl", hash = "sha256:65594a2d557d4ee9f02834fcdf0a28daa8b3b9f6cb2cb93846025a36db47ef22", size = 10506664, upload-time = "2026-02-26T20:03:50.56Z" }, + { url = "https://files.pythonhosted.org/packages/d0/a8/c688ef7e29983976820d18710f955751d9f4d4eb69df658af3d006e2ba3e/ruff-0.15.4-py3-none-win_amd64.whl", hash = "sha256:04196ad44f0df220c2ece5b0e959c2f37c777375ec744397d21d15b50a75264f", size = 11651048, upload-time = "2026-02-26T20:04:17.191Z" }, + { url = "https://files.pythonhosted.org/packages/3e/0a/9e1be9035b37448ce2e68c978f0591da94389ade5a5abafa4cf99985d1b2/ruff-0.15.4-py3-none-win_arm64.whl", hash = "sha256:60d5177e8cfc70e51b9c5fad936c634872a74209f934c1e79107d11787ad5453", size = 10966776, upload-time = "2026-02-26T20:03:56.908Z" }, ] [[package]] From 78addcea6f2a020ce4762aec0bdac10e43cf1e48 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C6=B0u=20Quang=20V=C5=A9?= Date: Sat, 28 Feb 2026 11:05:16 +0700 Subject: [PATCH 161/291] Fix formatting of streaming response for thought and text deltas --- app/server/chat.py | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/app/server/chat.py b/app/server/chat.py index a8d07cc..a35070a 100644 --- a/app/server/chat.py +++ b/app/server/chat.py @@ -879,7 +879,7 @@ async def generate_stream(): if t_delta := chunk.thoughts_delta: if not last_chunk_was_thought and not full_thoughts: - yield f"data: {orjson.dumps({'id': completion_id, 'object': 'chat.completion.chunk', 'created': created_time, 'model': model_name, 'choices': [{'index': 0, 'delta': {'content': ''}, 'finish_reason': None}]}).decode('utf-8')}\n\n" + yield f"data: {orjson.dumps({'id': completion_id, 'object': 'chat.completion.chunk', 'created': created_time, 'model': model_name, 'choices': [{'index': 0, 'delta': {'content': '\n'}, 'finish_reason': None}]}).decode('utf-8')}\n\n" full_thoughts += t_delta data = { "id": completion_id, @@ -895,7 +895,7 @@ async def generate_stream(): if text_delta := chunk.text_delta: if last_chunk_was_thought: - yield f"data: {orjson.dumps({'id': completion_id, 'object': 'chat.completion.chunk', 'created': created_time, 'model': model_name, 'choices': [{'index': 0, 'delta': {'content': '\n'}, 'finish_reason': None}]}).decode('utf-8')}\n\n" + yield f"data: {orjson.dumps({'id': completion_id, 'object': 'chat.completion.chunk', 'created': created_time, 'model': model_name, 'choices': [{'index': 0, 'delta': {'content': '\n\n\n'}, 'finish_reason': None}]}).decode('utf-8')}\n\n" last_chunk_was_thought = False full_text += text_delta if visible_delta := suppressor.process(text_delta): @@ -926,7 +926,7 @@ async def generate_stream(): full_thoughts = final_chunk.thoughts if last_chunk_was_thought: - yield f"data: {orjson.dumps({'id': completion_id, 'object': 'chat.completion.chunk', 'created': created_time, 'model': model_name, 'choices': [{'index': 0, 'delta': {'content': '\n'}, 'finish_reason': None}]}).decode('utf-8')}\n\n" + yield f"data: {orjson.dumps({'id': completion_id, 'object': 'chat.completion.chunk', 'created': created_time, 'model': model_name, 'choices': [{'index': 0, 'delta': {'content': '\n\n\n'}, 'finish_reason': None}]}).decode('utf-8')}\n\n" if remaining_text := suppressor.flush(): data = { @@ -940,7 +940,7 @@ async def generate_stream(): } yield f"data: {orjson.dumps(data).decode('utf-8')}\n\n" - raw_output_with_think = f"{full_thoughts}\n" if full_thoughts else "" + raw_output_with_think = f"\n{full_thoughts}\n\n\n" if full_thoughts else "" raw_output_with_think += full_text assistant_text, storage_output, tool_calls = _process_llm_output( raw_output_with_think, full_text, structured_requirement @@ -1072,13 +1072,13 @@ async def generate_stream(): all_outputs.append(chunk) if t_delta := chunk.thoughts_delta: if not last_chunk_was_thought and not full_thoughts: - yield f"data: {orjson.dumps({**base_event, 'type': 'response.output_text.delta', 'output_index': 0, 'delta': ''}).decode('utf-8')}\n\n" + yield f"data: {orjson.dumps({**base_event, 'type': 'response.output_text.delta', 'output_index': 0, 'delta': '\n'}).decode('utf-8')}\n\n" full_thoughts += t_delta yield f"data: {orjson.dumps({**base_event, 'type': 'response.output_text.delta', 'output_index': 0, 'delta': t_delta}).decode('utf-8')}\n\n" last_chunk_was_thought = True if text_delta := chunk.text_delta: if last_chunk_was_thought: - yield f"data: {orjson.dumps({**base_event, 'type': 'response.output_text.delta', 'output_index': 0, 'delta': '\n'}).decode('utf-8')}\n\n" + yield f"data: {orjson.dumps({**base_event, 'type': 'response.output_text.delta', 'output_index': 0, 'delta': '\n\n\n'}).decode('utf-8')}\n\n" last_chunk_was_thought = False full_text += text_delta if visible_delta := suppressor.process(text_delta): @@ -1096,12 +1096,12 @@ async def generate_stream(): full_thoughts = final_chunk.thoughts if last_chunk_was_thought: - yield f"data: {orjson.dumps({**base_event, 'type': 'response.output_text.delta', 'output_index': 0, 'delta': '\n'}).decode('utf-8')}\n\n" + yield f"data: {orjson.dumps({**base_event, 'type': 'response.output_text.delta', 'output_index': 0, 'delta': '\n\n\n'}).decode('utf-8')}\n\n" if remaining_text := suppressor.flush(): yield f"data: {orjson.dumps({**base_event, 'type': 'response.output_text.delta', 'output_index': 0, 'delta': remaining_text}).decode('utf-8')}\n\n" yield f"data: {orjson.dumps({**base_event, 'type': 'response.output_text.done', 'output_index': 0}).decode('utf-8')}\n\n" - raw_output_with_think = f"{full_thoughts}\n" if full_thoughts else "" + raw_output_with_think = f"\n{full_thoughts}\n\n\n" if full_thoughts else "" raw_output_with_think += full_text assistant_text, storage_output, detected_tool_calls = _process_llm_output( raw_output_with_think, full_text, structured_requirement From 0d7791afff62badc796e36c084a8a78159823692 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C6=B0u=20Quang=20V=C5=A9?= Date: Sat, 28 Feb 2026 11:13:01 +0700 Subject: [PATCH 162/291] Update dependencies to latest versions --- pyproject.toml | 2 +- uv.lock | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index c99fbbd..8638b03 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,7 +6,7 @@ readme = "README.md" requires-python = "==3.13.*" dependencies = [ "curl-cffi>=0.14.0", - "fastapi>=0.133.1", + "fastapi>=0.134.0", "gemini-webapi>=1.19.2", "httptools>=0.7.1", "lmdb>=1.7.5", diff --git a/uv.lock b/uv.lock index 077ff7a..8fb1f99 100644 --- a/uv.lock +++ b/uv.lock @@ -110,7 +110,7 @@ wheels = [ [[package]] name = "fastapi" -version = "0.133.1" +version = "0.134.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "annotated-doc" }, @@ -119,9 +119,9 @@ dependencies = [ { name = "typing-extensions" }, { name = "typing-inspection" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/22/6f/0eafed8349eea1fa462238b54a624c8b408cd1ba2795c8e64aa6c34f8ab7/fastapi-0.133.1.tar.gz", hash = "sha256:ed152a45912f102592976fde6cbce7dae1a8a1053da94202e51dd35d184fadd6", size = 378741, upload-time = "2026-02-25T18:18:17.398Z" } +sdist = { url = "https://files.pythonhosted.org/packages/96/15/647ea81cb73b55b48fb095158a9cd64e42e9e4f1d34dbb5cc4a4939779d6/fastapi-0.134.0.tar.gz", hash = "sha256:3122b1ea0dbeaab48b5976e80b99ca7eda02be154bf03e126a33220e73255a9a", size = 385667, upload-time = "2026-02-27T21:18:12.931Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d2/c9/a175a7779f3599dfa4adfc97a6ce0e157237b3d7941538604aadaf97bfb6/fastapi-0.133.1-py3-none-any.whl", hash = "sha256:658f34ba334605b1617a65adf2ea6461901bdb9af3a3080d63ff791ecf7dc2e2", size = 109029, upload-time = "2026-02-25T18:18:18.578Z" }, + { url = "https://files.pythonhosted.org/packages/e3/e6/fd49c28a54b7d6f5c64045155e40f6cff9ed4920055043fb5ac7969f7f2f/fastapi-0.134.0-py3-none-any.whl", hash = "sha256:f4e7214f24b2262258492e05c48cf21125e4ffc427e30dd32fb4f74049a3d56a", size = 110404, upload-time = "2026-02-27T21:18:10.809Z" }, ] [[package]] @@ -163,7 +163,7 @@ requires-dist = [ { name = "orjson", specifier = ">=3.11.7" }, { name = "pydantic-settings", extras = ["yaml"], specifier = ">=2.13.1" }, { name = "pytest", marker = "extra == 'dev'", specifier = ">=9.0.2" }, - { name = "ruff", marker = "extra == 'dev'", specifier = ">=0.15.2" }, + { name = "ruff", marker = "extra == 'dev'", specifier = ">=0.15.4" }, { name = "uvicorn", specifier = ">=0.41.0" }, { name = "uvloop", marker = "sys_platform != 'win32'", specifier = ">=0.22.1" }, ] From 0c341827b9b74eca81d07643dd415cc46304e95f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C6=B0u=20Quang=20V=C5=A9?= Date: Sat, 28 Feb 2026 20:26:54 +0700 Subject: [PATCH 163/291] Refactor `` tag to `reasoning_content` and `reasoning_text` --- app/models/__init__.py | 6 + app/models/models.py | 40 +++-- app/server/chat.py | 347 +++++++++++++++++++++++++++-------------- app/services/lmdb.py | 68 ++++---- app/utils/helper.py | 1 - 5 files changed, 298 insertions(+), 164 deletions(-) diff --git a/app/models/__init__.py b/app/models/__init__.py index 3896de1..1378f1f 100644 --- a/app/models/__init__.py +++ b/app/models/__init__.py @@ -17,6 +17,9 @@ ResponseInputItem, ResponseOutputContent, ResponseOutputMessage, + ResponseReasoning, + ResponseReasoningContentPart, + ResponseSummaryPart, ResponseToolCall, ResponseToolChoice, ResponseUsage, @@ -47,6 +50,9 @@ "ResponseInputItem", "ResponseOutputContent", "ResponseOutputMessage", + "ResponseReasoning", + "ResponseReasoningContentPart", + "ResponseSummaryPart", "ResponseToolCall", "ResponseToolChoice", "ResponseUsage", diff --git a/app/models/models.py b/app/models/models.py index 3b3e627..8dbe86c 100644 --- a/app/models/models.py +++ b/app/models/models.py @@ -171,7 +171,7 @@ class ConversationInStore(BaseModel): class ResponseInputContent(BaseModel): """Content item for Responses API input.""" - type: Literal["input_text", "input_image", "input_file"] + type: Literal["input_text", "output_text", "reasoning_text", "input_image", "input_file"] text: str | None = Field(default=None) image_url: str | None = Field(default=None) detail: Literal["auto", "low", "high"] | None = Field(default=None) @@ -180,14 +180,6 @@ class ResponseInputContent(BaseModel): filename: str | None = Field(default=None) annotations: list[dict[str, Any]] = Field(default_factory=list) - @model_validator(mode="before") - @classmethod - def normalize_output_text(cls, data: Any) -> Any: - """Allow output_text (from previous turns) to be treated as input_text.""" - if isinstance(data, dict) and data.get("type") == "output_text": - data["type"] = "input_text" - return data - class ResponseInputItem(BaseModel): """Single input item for Responses API.""" @@ -236,6 +228,8 @@ class ResponseUsage(BaseModel): input_tokens: int output_tokens: int total_tokens: int + input_tokens_details: dict[str, int] | None = Field(default=None) + output_tokens_details: dict[str, int] | None = Field(default=None) class ResponseOutputContent(BaseModel): @@ -255,6 +249,30 @@ class ResponseOutputMessage(BaseModel): content: list[ResponseOutputContent] +class ResponseSummaryPart(BaseModel): + """Summary part for reasoning.""" + + type: Literal["summary_text"] = Field(default="summary_text") + text: str + + +class ResponseReasoningContentPart(BaseModel): + """Content part for reasoning.""" + + type: Literal["reasoning_text"] = Field(default="reasoning_text") + text: str + + +class ResponseReasoning(BaseModel): + """Reasoning item returned by Responses API.""" + + id: str + type: Literal["reasoning"] = Field(default="reasoning") + status: Literal["in_progress", "completed", "incomplete"] = Field(default="completed") + summary: list[ResponseSummaryPart] | None = Field(default=None) + content: list[ResponseReasoningContentPart] | None = Field(default=None) + + class ResponseImageGenerationCall(BaseModel): """Image generation call record emitted in Responses API.""" @@ -285,7 +303,9 @@ class ResponseCreateResponse(BaseModel): object: Literal["response"] = Field(default="response") created_at: int model: str - output: list[ResponseOutputMessage | ResponseImageGenerationCall | ResponseToolCall] + output: list[ + ResponseReasoning | ResponseOutputMessage | ResponseImageGenerationCall | ResponseToolCall + ] status: Literal[ "in_progress", "completed", diff --git a/app/server/chat.py b/app/server/chat.py index a35070a..089db7c 100644 --- a/app/server/chat.py +++ b/app/server/chat.py @@ -33,6 +33,8 @@ ResponseInputItem, ResponseOutputContent, ResponseOutputMessage, + ResponseReasoning, + ResponseReasoningContentPart, ResponseToolCall, ResponseToolChoice, ResponseUsage, @@ -120,8 +122,9 @@ def _calculate_usage( messages: list[Message], assistant_text: str | None, tool_calls: list[Any] | None, -) -> tuple[int, int, int]: - """Calculate prompt, completion and total tokens consistently.""" + thoughts: str | None = None, +) -> tuple[int, int, int, int]: + """Calculate prompt, completion, total and reasoning tokens consistently.""" prompt_tokens = sum(estimate_tokens(text_from_message(msg)) for msg in messages) tool_args_text = "" if tool_calls: @@ -138,7 +141,15 @@ def _calculate_usage( ) completion_tokens = estimate_tokens(completion_basis) - return prompt_tokens, completion_tokens, prompt_tokens + completion_tokens + reasoning_tokens = estimate_tokens(thoughts) if thoughts else 0 + total_completion_tokens = completion_tokens + reasoning_tokens + + return ( + prompt_tokens, + total_completion_tokens, + prompt_tokens + total_completion_tokens, + reasoning_tokens, + ) def _create_responses_standard_payload( @@ -151,34 +162,50 @@ def _create_responses_standard_payload( usage: ResponseUsage, request: ResponseCreateRequest, normalized_input: Any, + full_thoughts: str | None = None, ) -> ResponseCreateResponse: """Unified factory for building ResponseCreateResponse objects.""" message_id = f"msg_{uuid.uuid4().hex}" - tool_call_items: list[ResponseToolCall] = [] - if detected_tool_calls: - tool_call_items = [ - ResponseToolCall( - id=call.id if hasattr(call, "id") else call["id"], + reason_id = f"reason_{uuid.uuid4().hex}" + + output_items: list[Any] = [] + if full_thoughts: + output_items.append( + ResponseReasoning( + id=reason_id, status="completed", - function=call.function if hasattr(call, "function") else call["function"], + content=[ResponseReasoningContentPart(text=full_thoughts)], ) - for call in detected_tool_calls - ] + ) + + output_items.append( + ResponseOutputMessage( + id=message_id, + type="message", + role="assistant", + content=response_contents, + ) + ) + + if detected_tool_calls: + output_items.extend( + [ + ResponseToolCall( + id=call.id if hasattr(call, "id") else call["id"], + status="completed", + function=call.function if hasattr(call, "function") else call["function"], + ) + for call in detected_tool_calls + ] + ) + + output_items.extend(image_call_items) return ResponseCreateResponse( id=response_id, created_at=created_time, model=model_name, - output=[ - ResponseOutputMessage( - id=message_id, - type="message", - role="assistant", - content=response_contents, - ), - *tool_call_items, - *image_call_items, - ], + output=output_items, status="completed", usage=usage, input=normalized_input or None, @@ -196,6 +223,7 @@ def _create_chat_completion_standard_payload( tool_calls_payload: list[dict] | None, finish_reason: str, usage: dict, + reasoning_content: str | None = None, ) -> dict: """Unified factory for building Chat Completion response dictionaries.""" return { @@ -210,6 +238,7 @@ def _create_chat_completion_standard_payload( "role": "assistant", "content": visible_output or None, "tool_calls": tool_calls_payload or None, + "reasoning_content": reasoning_content or None, }, "finish_reason": finish_reason, } @@ -219,40 +248,41 @@ def _create_chat_completion_standard_payload( def _process_llm_output( - raw_output_with_think: str, - raw_output_clean: str, + thoughts: str | None, + raw_text: str, structured_requirement: StructuredOutputRequirement | None, -) -> tuple[str, str, list[Any]]: +) -> tuple[str | None, str, str, list[Any]]: """ Post-process Gemini output to extract tool calls and prepare clean text for display and storage. - Returns: (visible_text, storage_output, tool_calls) + Returns: (thoughts, visible_text, storage_output, tool_calls) """ - visible_with_think, tool_calls = extract_tool_calls(raw_output_with_think) + if thoughts: + thoughts = thoughts.strip() + + visible_output, tool_calls = extract_tool_calls(raw_text) if tool_calls: logger.debug(f"Detected {len(tool_calls)} tool call(s) in model output.") - visible_output = visible_with_think.strip() + visible_output = visible_output.strip() - storage_output = remove_tool_call_blocks(raw_output_clean) + storage_output = remove_tool_call_blocks(raw_text) storage_output = storage_output.strip() - if structured_requirement: - cleaned_for_json = LMDBConversationStore.remove_think_tags(visible_output) - if cleaned_for_json: - try: - structured_payload = orjson.loads(cleaned_for_json) - canonical_output = orjson.dumps(structured_payload).decode("utf-8") - visible_output = canonical_output - storage_output = canonical_output - logger.debug( - f"Structured response fulfilled (schema={structured_requirement.schema_name})." - ) - except orjson.JSONDecodeError: - logger.warning( - f"Failed to decode JSON for structured response (schema={structured_requirement.schema_name})." - ) + if structured_requirement and visible_output: + try: + structured_payload = orjson.loads(visible_output) + canonical_output = orjson.dumps(structured_payload).decode("utf-8") + visible_output = canonical_output + storage_output = canonical_output + logger.debug( + f"Structured response fulfilled (schema={structured_requirement.schema_name})." + ) + except orjson.JSONDecodeError: + logger.warning( + f"Failed to decode JSON for structured response (schema={structured_requirement.schema_name})." + ) - return visible_output, storage_output, tool_calls + return thoughts, visible_output, storage_output, tool_calls def _persist_conversation( @@ -263,6 +293,7 @@ def _persist_conversation( messages: list[Message], storage_output: str | None, tool_calls: list[Any] | None, + thoughts: str | None = None, ) -> str | None: """Unified logic to save conversation history to LMDB.""" try: @@ -270,6 +301,7 @@ def _persist_conversation( role="assistant", content=storage_output or None, tool_calls=tool_calls or None, + reasoning_content=thoughts or None, ) full_history = [*messages, current_assistant_message] cleaned_history = db.sanitize_messages(full_history) @@ -515,14 +547,22 @@ def _response_items_to_messages( messages.append(Message(role=role, content=content)) else: converted: list[ContentItem] = [] + reasoning_parts: list[str] = [] for part in content: - if part.type == "input_text": + if part.type in ("input_text", "output_text"): text_value = part.text or "" normalized_contents.append( - ResponseInputContent(type="input_text", text=text_value) + ResponseInputContent(type=part.type, text=text_value) ) if text_value: converted.append(ContentItem(type="text", text=text_value)) + elif part.type == "reasoning_text": + text_value = part.text or "" + normalized_contents.append( + ResponseInputContent(type="reasoning_text", text=text_value) + ) + if text_value: + reasoning_parts.append(text_value) elif part.type == "input_image": image_url = part.image_url if image_url: @@ -583,11 +623,16 @@ def _instructions_to_messages( instruction_messages.append(Message(role=role, content=content)) else: converted: list[ContentItem] = [] + reasoning_parts: list[str] = [] for part in content: - if part.type == "input_text": + if part.type in ("input_text", "output_text"): text_value = part.text or "" if text_value: converted.append(ContentItem(type="text", text=text_value)) + elif part.type == "reasoning_text": + text_value = part.text or "" + if text_value: + reasoning_parts.append(text_value) elif part.type == "input_image": image_url = part.image_url if image_url: @@ -609,7 +654,13 @@ def _instructions_to_messages( file_info["url"] = part.file_url if file_info: converted.append(ContentItem(type="file", file=file_info)) - instruction_messages.append(Message(role=role, content=converted or None)) + instruction_messages.append( + Message( + role=role, + content=converted or None, + reasoning_content="\n".join(reasoning_parts) if reasoning_parts else None, + ) + ) return instruction_messages @@ -858,7 +909,6 @@ def _create_real_streaming_response( async def generate_stream(): full_thoughts, full_text = "", "" has_started = False - last_chunk_was_thought = False all_outputs: list[ModelOutput] = [] suppressor = StreamingOutputFilter() try: @@ -878,8 +928,6 @@ async def generate_stream(): has_started = True if t_delta := chunk.thoughts_delta: - if not last_chunk_was_thought and not full_thoughts: - yield f"data: {orjson.dumps({'id': completion_id, 'object': 'chat.completion.chunk', 'created': created_time, 'model': model_name, 'choices': [{'index': 0, 'delta': {'content': '\n'}, 'finish_reason': None}]}).decode('utf-8')}\n\n" full_thoughts += t_delta data = { "id": completion_id, @@ -887,16 +935,16 @@ async def generate_stream(): "created": created_time, "model": model_name, "choices": [ - {"index": 0, "delta": {"content": t_delta}, "finish_reason": None} + { + "index": 0, + "delta": {"reasoning_content": t_delta}, + "finish_reason": None, + } ], } yield f"data: {orjson.dumps(data).decode('utf-8')}\n\n" - last_chunk_was_thought = True if text_delta := chunk.text_delta: - if last_chunk_was_thought: - yield f"data: {orjson.dumps({'id': completion_id, 'object': 'chat.completion.chunk', 'created': created_time, 'model': model_name, 'choices': [{'index': 0, 'delta': {'content': '\n\n\n'}, 'finish_reason': None}]}).decode('utf-8')}\n\n" - last_chunk_was_thought = False full_text += text_delta if visible_delta := suppressor.process(text_delta): data = { @@ -925,9 +973,6 @@ async def generate_stream(): if final_chunk.thoughts: full_thoughts = final_chunk.thoughts - if last_chunk_was_thought: - yield f"data: {orjson.dumps({'id': completion_id, 'object': 'chat.completion.chunk', 'created': created_time, 'model': model_name, 'choices': [{'index': 0, 'delta': {'content': '\n\n\n'}, 'finish_reason': None}]}).decode('utf-8')}\n\n" - if remaining_text := suppressor.flush(): data = { "id": completion_id, @@ -940,10 +985,8 @@ async def generate_stream(): } yield f"data: {orjson.dumps(data).decode('utf-8')}\n\n" - raw_output_with_think = f"\n{full_thoughts}\n\n\n" if full_thoughts else "" - raw_output_with_think += full_text - assistant_text, storage_output, tool_calls = _process_llm_output( - raw_output_with_think, full_text, structured_requirement + _thoughts, assistant_text, storage_output, tool_calls = _process_llm_output( + full_thoughts, full_text, structured_requirement ) images = [] @@ -1004,8 +1047,15 @@ async def generate_stream(): } yield f"data: {orjson.dumps(data).decode('utf-8')}\n\n" - p_tok, c_tok, t_tok = _calculate_usage(messages, assistant_text, tool_calls) - usage = {"prompt_tokens": p_tok, "completion_tokens": c_tok, "total_tokens": t_tok} + p_tok, c_tok, t_tok, r_tok = _calculate_usage( + messages, assistant_text, tool_calls, full_thoughts + ) + usage = { + "prompt_tokens": p_tok, + "completion_tokens": c_tok, + "total_tokens": t_tok, + "completion_tokens_details": {"reasoning_tokens": r_tok}, + } data = { "id": completion_id, "object": "chat.completion.chunk", @@ -1021,9 +1071,10 @@ async def generate_stream(): model.model_name, client_wrapper.id, session.metadata, - messages, # This should be the prepared messages + messages, storage_output, tool_calls, + full_thoughts, ) yield f"data: {orjson.dumps(data).decode('utf-8')}\n\n" yield "data: [DONE]\n\n" @@ -1059,11 +1110,15 @@ def _create_responses_real_streaming_response( async def generate_stream(): yield f"data: {orjson.dumps({**base_event, 'type': 'response.created', 'response': {'id': response_id, 'object': 'response', 'created_at': created_time, 'model': model_name, 'status': 'in_progress', 'metadata': request.metadata, 'input': None, 'tools': request.tools, 'tool_choice': request.tool_choice}}).decode('utf-8')}\n\n" - message_id = f"msg_{uuid.uuid4().hex}" - yield f"data: {orjson.dumps({**base_event, 'type': 'response.output_item.added', 'output_index': 0, 'item': {'id': message_id, 'type': 'message', 'role': 'assistant', 'content': []}}).decode('utf-8')}\n\n" full_thoughts, full_text = "", "" + thought_item_id = f"reason_{uuid.uuid4().hex}" + message_item_id = f"msg_{uuid.uuid4().hex}" + thought_item_added = False + message_item_added = False last_chunk_was_thought = False + current_idx = 0 + all_outputs: list[ModelOutput] = [] suppressor = StreamingOutputFilter() @@ -1071,18 +1126,31 @@ async def generate_stream(): async for chunk in generator: all_outputs.append(chunk) if t_delta := chunk.thoughts_delta: - if not last_chunk_was_thought and not full_thoughts: - yield f"data: {orjson.dumps({**base_event, 'type': 'response.output_text.delta', 'output_index': 0, 'delta': '\n'}).decode('utf-8')}\n\n" + if not thought_item_added: + yield f"data: {orjson.dumps({**base_event, 'type': 'response.output_item.added', 'output_index': current_idx, 'item': {'id': thought_item_id, 'type': 'reasoning', 'status': 'in_progress', 'content': []}}).decode('utf-8')}\n\n" + yield f"data: {orjson.dumps({**base_event, 'type': 'response.content_part.added', 'output_index': current_idx, 'part_index': 0, 'part': {'type': 'reasoning_text', 'text': ''}}).decode('utf-8')}\n\n" + thought_item_added = True + full_thoughts += t_delta - yield f"data: {orjson.dumps({**base_event, 'type': 'response.output_text.delta', 'output_index': 0, 'delta': t_delta}).decode('utf-8')}\n\n" + yield f"data: {orjson.dumps({**base_event, 'type': 'response.output_text.delta', 'output_index': current_idx, 'part_index': 0, 'delta': t_delta}).decode('utf-8')}\n\n" last_chunk_was_thought = True + if text_delta := chunk.text_delta: if last_chunk_was_thought: - yield f"data: {orjson.dumps({**base_event, 'type': 'response.output_text.delta', 'output_index': 0, 'delta': '\n\n\n'}).decode('utf-8')}\n\n" + yield f"data: {orjson.dumps({**base_event, 'type': 'response.output_text.done', 'output_index': current_idx, 'part_index': 0}).decode('utf-8')}\n\n" + yield f"data: {orjson.dumps({**base_event, 'type': 'response.content_part.done', 'output_index': current_idx, 'part_index': 0}).decode('utf-8')}\n\n" + yield f"data: {orjson.dumps({**base_event, 'type': 'response.output_item.done', 'output_index': current_idx, 'item': {'id': thought_item_id, 'type': 'reasoning', 'status': 'completed', 'content': [{'type': 'reasoning_text', 'text': full_thoughts}]}}).decode('utf-8')}\n\n" + current_idx += 1 last_chunk_was_thought = False + + if not message_item_added: + yield f"data: {orjson.dumps({**base_event, 'type': 'response.output_item.added', 'output_index': current_idx, 'item': {'id': message_item_id, 'type': 'message', 'role': 'assistant', 'content': []}}).decode('utf-8')}\n\n" + yield f"data: {orjson.dumps({**base_event, 'type': 'response.content_part.added', 'output_index': current_idx, 'part_index': 0, 'part': {'type': 'output_text', 'text': ''}}).decode('utf-8')}\n\n" + message_item_added = True + full_text += text_delta if visible_delta := suppressor.process(text_delta): - yield f"data: {orjson.dumps({**base_event, 'type': 'response.output_text.delta', 'output_index': 0, 'delta': visible_delta}).decode('utf-8')}\n\n" + yield f"data: {orjson.dumps({**base_event, 'type': 'response.output_text.delta', 'output_index': current_idx, 'part_index': 0, 'delta': visible_delta}).decode('utf-8')}\n\n" except Exception as e: logger.exception(f"Error during Responses API streaming: {e}") yield f"data: {orjson.dumps({**base_event, 'type': 'error', 'error': {'message': 'Streaming error.'}}).decode('utf-8')}\n\n" @@ -1096,17 +1164,33 @@ async def generate_stream(): full_thoughts = final_chunk.thoughts if last_chunk_was_thought: - yield f"data: {orjson.dumps({**base_event, 'type': 'response.output_text.delta', 'output_index': 0, 'delta': '\n\n\n'}).decode('utf-8')}\n\n" - if remaining_text := suppressor.flush(): - yield f"data: {orjson.dumps({**base_event, 'type': 'response.output_text.delta', 'output_index': 0, 'delta': remaining_text}).decode('utf-8')}\n\n" - yield f"data: {orjson.dumps({**base_event, 'type': 'response.output_text.done', 'output_index': 0}).decode('utf-8')}\n\n" + yield f"data: {orjson.dumps({**base_event, 'type': 'response.content_part.done', 'output_index': current_idx, 'part_index': 0}).decode('utf-8')}\n\n" + yield f"data: {orjson.dumps({**base_event, 'type': 'response.output_item.done', 'output_index': current_idx, 'item': {'id': thought_item_id, 'type': 'reasoning', 'status': 'completed', 'content': [{'type': 'reasoning_text', 'text': full_thoughts}]}}).decode('utf-8')}\n\n" + current_idx += 1 - raw_output_with_think = f"\n{full_thoughts}\n\n\n" if full_thoughts else "" - raw_output_with_think += full_text - assistant_text, storage_output, detected_tool_calls = _process_llm_output( - raw_output_with_think, full_text, structured_requirement + remaining_from_suppressor = suppressor.flush() + if remaining_from_suppressor: + if not message_item_added: + yield f"data: {orjson.dumps({**base_event, 'type': 'response.output_item.added', 'output_index': current_idx, 'item': {'id': message_item_id, 'type': 'message', 'role': 'assistant', 'content': []}}).decode('utf-8')}\n\n" + yield f"data: {orjson.dumps({**base_event, 'type': 'response.content_part.added', 'output_index': current_idx, 'part_index': 0, 'part': {'type': 'output_text', 'text': ''}}).decode('utf-8')}\n\n" + message_item_added = True + yield f"data: {orjson.dumps({**base_event, 'type': 'response.output_text.delta', 'output_index': current_idx, 'part_index': 0, 'delta': remaining_from_suppressor}).decode('utf-8')}\n\n" + + # IMPORTANT: Process output now to get the final assistant_text + _thoughts, assistant_text, storage_output, detected_tool_calls = _process_llm_output( + full_thoughts, full_text, structured_requirement ) + response_contents: list[ResponseOutputContent] = [] + if message_item_added: + yield f"data: {orjson.dumps({**base_event, 'type': 'response.output_text.done', 'output_index': current_idx, 'part_index': 0}).decode('utf-8')}\n\n" + yield f"data: {orjson.dumps({**base_event, 'type': 'response.content_part.done', 'output_index': current_idx, 'part_index': 0}).decode('utf-8')}\n\n" + + msg_content = [{"type": "output_text", "text": assistant_text}] + yield f"data: {orjson.dumps({**base_event, 'type': 'response.output_item.done', 'output_index': current_idx, 'item': {'id': message_item_id, 'type': 'message', 'role': 'assistant', 'content': msg_content}}).decode('utf-8')}\n\n" + response_contents.append(ResponseOutputContent(type="output_text", text=assistant_text)) + current_idx += 1 + images = [] seen_urls = set() for out in all_outputs: @@ -1116,7 +1200,7 @@ async def generate_stream(): images.append(img) seen_urls.add(img.url) - response_contents, image_call_items = [], [] + image_call_items: list[ResponseImageGenerationCall] = [] seen_hashes = set() for image in images: try: @@ -1132,25 +1216,20 @@ async def generate_stream(): img_id = fname img_format = "png" if isinstance(image, GeneratedImage) else "jpeg" - image_url = f"![{fname}]({base_url}images/{fname}?token={get_image_token(fname)})" - image_call_items.append( - ResponseImageGenerationCall( - id=img_id, - result=b64, - output_format=img_format, - size=f"{w}x{h}" if w and h else None, - ) + img_item = ResponseImageGenerationCall( + id=img_id, + result=b64, + output_format=img_format, + size=f"{w}x{h}" if w and h else None, ) - response_contents.append(ResponseOutputContent(type="output_text", text=image_url)) + image_call_items.append(img_item) + + yield f"data: {orjson.dumps({**base_event, 'type': 'response.output_item.added', 'output_index': current_idx, 'item': img_item.model_dump(mode='json')}).decode('utf-8')}\n\n" + yield f"data: {orjson.dumps({**base_event, 'type': 'response.output_item.done', 'output_index': current_idx, 'item': img_item.model_dump(mode='json')}).decode('utf-8')}\n\n" + current_idx += 1 except Exception as exc: logger.warning(f"Failed to process image in stream: {exc}") - if assistant_text: - response_contents.append(ResponseOutputContent(type="output_text", text=assistant_text)) - if not response_contents: - response_contents.append(ResponseOutputContent(type="output_text", text="")) - - # Aggregate images for storage image_markdown = "" for img_call in image_call_items: fname = f"{img_call.id}.{img_call.output_format}" @@ -1160,21 +1239,28 @@ async def generate_stream(): if image_markdown: storage_output += image_markdown - yield f"data: {orjson.dumps({**base_event, 'type': 'response.output_item.done', 'output_index': 0, 'item': {'id': message_id, 'type': 'message', 'role': 'assistant', 'content': [c.model_dump(mode='json') for c in response_contents]}}).decode('utf-8')}\n\n" - - current_idx = 1 for call in detected_tool_calls: tc_item = ResponseToolCall(id=call.id, status="completed", function=call.function) yield f"data: {orjson.dumps({**base_event, 'type': 'response.output_item.added', 'output_index': current_idx, 'item': tc_item.model_dump(mode='json')}).decode('utf-8')}\n\n" yield f"data: {orjson.dumps({**base_event, 'type': 'response.output_item.done', 'output_index': current_idx, 'item': tc_item.model_dump(mode='json')}).decode('utf-8')}\n\n" current_idx += 1 - for img_call in image_call_items: - yield f"data: {orjson.dumps({**base_event, 'type': 'response.output_item.added', 'output_index': current_idx, 'item': img_call.model_dump(mode='json')}).decode('utf-8')}\n\n" - yield f"data: {orjson.dumps({**base_event, 'type': 'response.output_item.done', 'output_index': current_idx, 'item': img_call.model_dump(mode='json')}).decode('utf-8')}\n\n" - current_idx += 1 - p_tok, c_tok, t_tok = _calculate_usage(messages, assistant_text, detected_tool_calls) - usage = ResponseUsage(input_tokens=p_tok, output_tokens=c_tok, total_tokens=t_tok) + p_tok, c_tok, t_tok, r_tok = _calculate_usage( + messages, assistant_text, detected_tool_calls, full_thoughts + ) + usage = ResponseUsage( + input_tokens=p_tok, + output_tokens=c_tok, + total_tokens=t_tok, + output_tokens_details={"reasoning_tokens": r_tok}, + ) + + # Ensure we have at least one content item if none was created + if not response_contents: + response_contents.append( + ResponseOutputContent(type="output_text", text=assistant_text or "") + ) + payload = _create_responses_standard_payload( response_id, created_time, @@ -1185,6 +1271,7 @@ async def generate_stream(): usage, request, None, + full_thoughts, ) _persist_conversation( db, @@ -1194,8 +1281,10 @@ async def generate_stream(): messages, storage_output, detected_tool_calls, + full_thoughts, ) yield f"data: {orjson.dumps({**base_event, 'type': 'response.completed', 'response': payload.model_dump(mode='json')}).decode('utf-8')}\n\n" + yield f"data: {orjson.dumps({**base_event, 'type': 'response.done'})}\n\n" yield "data: [DONE]\n\n" return StreamingResponse(generate_stream(), media_type="text/event-stream") @@ -1301,7 +1390,7 @@ async def create_chat_completion( ) try: - raw_with_t = GeminiClientWrapper.extract_output(resp_or_stream, include_thoughts=True) + thoughts = resp_or_stream.thoughts raw_clean = GeminiClientWrapper.extract_output(resp_or_stream, include_thoughts=False) except Exception as exc: logger.exception("Gemini output parsing failed.") @@ -1309,8 +1398,8 @@ async def create_chat_completion( status_code=status.HTTP_502_BAD_GATEWAY, detail="Malformed response." ) from exc - visible_output, storage_output, tool_calls = _process_llm_output( - raw_with_t, raw_clean, structured_requirement + thoughts, visible_output, storage_output, tool_calls = _process_llm_output( + thoughts, raw_clean, structured_requirement ) # Process images for OpenAI non-streaming flow @@ -1338,8 +1427,15 @@ async def create_chat_completion( if tool_calls_payload: logger.debug(f"Detected tool calls: {reprlib.repr(tool_calls_payload)}") - p_tok, c_tok, t_tok = _calculate_usage(request.messages, visible_output, tool_calls) - usage = {"prompt_tokens": p_tok, "completion_tokens": c_tok, "total_tokens": t_tok} + p_tok, c_tok, t_tok, r_tok = _calculate_usage( + request.messages, visible_output, tool_calls, thoughts + ) + usage = { + "prompt_tokens": p_tok, + "completion_tokens": c_tok, + "total_tokens": t_tok, + "completion_tokens_details": {"reasoning_tokens": r_tok}, + } payload = _create_chat_completion_standard_payload( completion_id, created_time, @@ -1348,6 +1444,7 @@ async def create_chat_completion( tool_calls_payload, "tool_calls" if tool_calls else "stop", usage, + thoughts, ) _persist_conversation( db, @@ -1357,6 +1454,7 @@ async def create_chat_completion( msgs, # Use prepared messages 'msgs' storage_output, tool_calls, + thoughts, ) return payload @@ -1470,15 +1568,17 @@ async def create_response( ) try: - raw_t = GeminiClientWrapper.extract_output(resp_or_stream, include_thoughts=True) - raw_c = GeminiClientWrapper.extract_output(resp_or_stream, include_thoughts=False) + thoughts = resp_or_stream.thoughts + raw_clean = GeminiClientWrapper.extract_output(resp_or_stream, include_thoughts=False) except Exception as exc: logger.exception("Gemini parsing failed") raise HTTPException( status_code=status.HTTP_502_BAD_GATEWAY, detail="Malformed response." ) from exc - assistant_text, storage_output, tool_calls = _process_llm_output(raw_t, raw_c, struct_req) + thoughts, assistant_text, storage_output, tool_calls = _process_llm_output( + thoughts, raw_clean, struct_req + ) images = resp_or_stream.images or [] if ( request.tool_choice is not None and request.tool_choice.type == "image_generation" @@ -1533,8 +1633,13 @@ async def create_response( if image_markdown: storage_output += image_markdown - p_tok, c_tok, t_tok = _calculate_usage(messages, assistant_text, tool_calls) - usage = ResponseUsage(input_tokens=p_tok, output_tokens=c_tok, total_tokens=t_tok) + p_tok, c_tok, t_tok, r_tok = _calculate_usage(messages, assistant_text, tool_calls, thoughts) + usage = ResponseUsage( + input_tokens=p_tok, + output_tokens=c_tok, + total_tokens=t_tok, + output_tokens_details={"reasoning_tokens": r_tok}, + ) payload = _create_responses_standard_payload( response_id, created_time, @@ -1545,8 +1650,16 @@ async def create_response( usage, request, norm_input, + thoughts, ) _persist_conversation( - db, model.model_name, client.id, session.metadata, messages, storage_output, tool_calls + db, + model.model_name, + client.id, + session.metadata, + messages, + storage_output, + tool_calls, + thoughts, ) return payload diff --git a/app/services/lmdb.py b/app/services/lmdb.py index 8c4edb4..07f0a23 100644 --- a/app/services/lmdb.py +++ b/app/services/lmdb.py @@ -12,7 +12,6 @@ from app.models import ContentItem, ConversationInStore, Message from app.utils import g_config from app.utils.helper import ( - THINK_TAGS_RE, extract_tool_calls, normalize_llm_text, remove_tool_call_blocks, @@ -42,7 +41,6 @@ def _normalize_text(text: str | None, fuzzy: bool = False) -> str | None: text = normalize_llm_text(text) text = unescape_text(text) - text = LMDBConversationStore.remove_think_tags(text) text = remove_tool_call_blocks(text) if fuzzy: @@ -60,6 +58,9 @@ def _hash_message(message: Message, fuzzy: bool = False) -> str: "role": message.role, "name": message.name or None, "tool_call_id": message.tool_call_id or None, + "reasoning_content": _normalize_text(message.reasoning_content) + if message.reasoning_content + else None, } content = message.content @@ -584,21 +585,23 @@ def __del__(self): """Cleanup on destruction.""" self.close() - @staticmethod - def remove_think_tags(text: str) -> str: - """Remove all ... tags and strip whitespace.""" - if not text: - return text - cleaned_content = THINK_TAGS_RE.sub("", text) - return cleaned_content.strip() - @staticmethod def sanitize_messages(messages: list[Message]) -> list[Message]: """Clean all messages of internal markers, hints and normalize tool calls.""" cleaned_messages = [] for msg in messages: + update_data = {} + content_changed = False + + # Normalize reasoning_content + if msg.reasoning_content: + norm_reasoning = _normalize_text(msg.reasoning_content) + if norm_reasoning != msg.reasoning_content: + update_data["reasoning_content"] = norm_reasoning + content_changed = True + if isinstance(msg.content, str): - text = LMDBConversationStore.remove_think_tags(msg.content) + text = msg.content tool_calls = msg.tool_calls if msg.role == "assistant" and not tool_calls: @@ -608,48 +611,41 @@ def sanitize_messages(messages: list[Message]) -> list[Message]: normalized_content = text.strip() or None - if normalized_content != msg.content or tool_calls != msg.tool_calls: - cleaned_msg = msg.model_copy( - update={ - "content": normalized_content, - "tool_calls": tool_calls or None, - } - ) - cleaned_messages.append(cleaned_msg) - else: - cleaned_messages.append(msg) + if normalized_content != msg.content: + update_data["content"] = normalized_content + content_changed = True + if tool_calls != msg.tool_calls: + update_data["tool_calls"] = tool_calls or None + content_changed = True + elif isinstance(msg.content, list): new_content = [] all_extracted_calls = list(msg.tool_calls or []) - changed = False + list_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) + text = item.text if msg.role == "assistant" and not msg.tool_calls: text, extracted = extract_tool_calls(text) if extracted: all_extracted_calls.extend(extracted) - changed = True + list_changed = True else: text = strip_system_hints(text) if text != item.text: - changed = True + list_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) + if list_changed: + update_data["content"] = new_content + update_data["tool_calls"] = all_extracted_calls or None + content_changed = True + + if content_changed: + cleaned_messages.append(msg.model_copy(update=update_data)) else: cleaned_messages.append(msg) return cleaned_messages diff --git a/app/utils/helper.py b/app/utils/helper.py index 9a930b7..187f310 100644 --- a/app/utils/helper.py +++ b/app/utils/helper.py @@ -86,7 +86,6 @@ HINT_END_RE = ( re.compile(rf"\s*{TOOL_HINT_END_ESC}\n?", re.IGNORECASE) if TOOL_HINT_END_ESC else None ) -THINK_TAGS_RE = re.compile(r".*?", re.DOTALL | re.IGNORECASE) # --- Streaming Specific Patterns --- _START_PATTERNS = { From c73ebe27382a4d9cf24f986a2e627cd50ca0da3e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C6=B0u=20Quang=20V=C5=A9?= Date: Sat, 28 Feb 2026 21:57:43 +0700 Subject: [PATCH 164/291] Fix streaming response generation --- app/server/chat.py | 479 +++++++++++++++++++++++++++++++++++---------- 1 file changed, 373 insertions(+), 106 deletions(-) diff --git a/app/server/chat.py b/app/server/chat.py index 089db7c..7d3a6cf 100644 --- a/app/server/chat.py +++ b/app/server/chat.py @@ -1109,129 +1109,370 @@ def _create_responses_real_streaming_response( } async def generate_stream(): - yield f"data: {orjson.dumps({**base_event, 'type': 'response.created', 'response': {'id': response_id, 'object': 'response', 'created_at': created_time, 'model': model_name, 'status': 'in_progress', 'metadata': request.metadata, 'input': None, 'tools': request.tools, 'tool_choice': request.tool_choice}}).decode('utf-8')}\n\n" + yield f"data: { + orjson.dumps( + { + **base_event, + 'type': 'response.created', + 'response': { + 'id': response_id, + 'object': 'response', + 'created_at': created_time, + 'model': model_name, + 'status': 'in_progress', + 'metadata': request.metadata, + 'input': None, + 'tools': request.tools, + 'tool_choice': request.tool_choice, + }, + } + ).decode() + }\n\n" + + full_thoughts = "" + full_text = "" + all_outputs: list[ModelOutput] = [] - full_thoughts, full_text = "", "" thought_item_id = f"reason_{uuid.uuid4().hex}" message_item_id = f"msg_{uuid.uuid4().hex}" - thought_item_added = False - message_item_added = False - last_chunk_was_thought = False - current_idx = 0 - all_outputs: list[ModelOutput] = [] + thought_open = False + message_open = False + current_index = 0 + suppressor = StreamingOutputFilter() try: async for chunk in generator: all_outputs.append(chunk) - if t_delta := chunk.thoughts_delta: - if not thought_item_added: - yield f"data: {orjson.dumps({**base_event, 'type': 'response.output_item.added', 'output_index': current_idx, 'item': {'id': thought_item_id, 'type': 'reasoning', 'status': 'in_progress', 'content': []}}).decode('utf-8')}\n\n" - yield f"data: {orjson.dumps({**base_event, 'type': 'response.content_part.added', 'output_index': current_idx, 'part_index': 0, 'part': {'type': 'reasoning_text', 'text': ''}}).decode('utf-8')}\n\n" - thought_item_added = True + if chunk.thoughts_delta: + if not thought_open: + yield f"data: { + orjson.dumps( + { + **base_event, + 'type': 'response.output_item.added', + 'output_index': current_index, + 'item': { + 'id': thought_item_id, + 'type': 'reasoning', + 'status': 'in_progress', + 'content': [], + }, + } + ).decode() + }\n\n" - full_thoughts += t_delta - yield f"data: {orjson.dumps({**base_event, 'type': 'response.output_text.delta', 'output_index': current_idx, 'part_index': 0, 'delta': t_delta}).decode('utf-8')}\n\n" - last_chunk_was_thought = True + yield f"data: { + orjson.dumps( + { + **base_event, + 'type': 'response.content_part.added', + 'output_index': current_index, + 'part_index': 0, + 'part': {'type': 'reasoning_text', 'text': ''}, + } + ).decode() + }\n\n" - if text_delta := chunk.text_delta: - if last_chunk_was_thought: - yield f"data: {orjson.dumps({**base_event, 'type': 'response.output_text.done', 'output_index': current_idx, 'part_index': 0}).decode('utf-8')}\n\n" - yield f"data: {orjson.dumps({**base_event, 'type': 'response.content_part.done', 'output_index': current_idx, 'part_index': 0}).decode('utf-8')}\n\n" - yield f"data: {orjson.dumps({**base_event, 'type': 'response.output_item.done', 'output_index': current_idx, 'item': {'id': thought_item_id, 'type': 'reasoning', 'status': 'completed', 'content': [{'type': 'reasoning_text', 'text': full_thoughts}]}}).decode('utf-8')}\n\n" - current_idx += 1 - last_chunk_was_thought = False - - if not message_item_added: - yield f"data: {orjson.dumps({**base_event, 'type': 'response.output_item.added', 'output_index': current_idx, 'item': {'id': message_item_id, 'type': 'message', 'role': 'assistant', 'content': []}}).decode('utf-8')}\n\n" - yield f"data: {orjson.dumps({**base_event, 'type': 'response.content_part.added', 'output_index': current_idx, 'part_index': 0, 'part': {'type': 'output_text', 'text': ''}}).decode('utf-8')}\n\n" - message_item_added = True + thought_open = True - full_text += text_delta - if visible_delta := suppressor.process(text_delta): - yield f"data: {orjson.dumps({**base_event, 'type': 'response.output_text.delta', 'output_index': current_idx, 'part_index': 0, 'delta': visible_delta}).decode('utf-8')}\n\n" - except Exception as e: - logger.exception(f"Error during Responses API streaming: {e}") - yield f"data: {orjson.dumps({**base_event, 'type': 'error', 'error': {'message': 'Streaming error.'}}).decode('utf-8')}\n\n" + full_thoughts += chunk.thoughts_delta + + yield f"data: { + orjson.dumps( + { + **base_event, + 'type': 'response.output_text.delta', + 'output_index': current_index, + 'part_index': 0, + 'delta': chunk.thoughts_delta, + } + ).decode() + }\n\n" + + if chunk.text_delta: + if thought_open: + yield f"data: { + orjson.dumps( + { + **base_event, + 'type': 'response.output_text.done', + 'output_index': current_index, + 'part_index': 0, + } + ).decode() + }\n\n" + + yield f"data: { + orjson.dumps( + { + **base_event, + 'type': 'response.content_part.done', + 'output_index': current_index, + 'part_index': 0, + } + ).decode() + }\n\n" + + yield f"data: { + orjson.dumps( + { + **base_event, + 'type': 'response.output_item.done', + 'output_index': current_index, + 'item': { + 'id': thought_item_id, + 'type': 'reasoning', + 'status': 'completed', + 'content': [ + {'type': 'reasoning_text', 'text': full_thoughts} + ], + }, + } + ).decode() + }\n\n" + + current_index += 1 + thought_open = False + + if not message_open: + yield f"data: { + orjson.dumps( + { + **base_event, + 'type': 'response.output_item.added', + 'output_index': current_index, + 'item': { + 'id': message_item_id, + 'type': 'message', + 'role': 'assistant', + 'content': [], + }, + } + ).decode() + }\n\n" + + yield f"data: { + orjson.dumps( + { + **base_event, + 'type': 'response.content_part.added', + 'output_index': current_index, + 'part_index': 0, + 'part': {'type': 'output_text', 'text': ''}, + } + ).decode() + }\n\n" + + message_open = True + + full_text += chunk.text_delta + + if visible := suppressor.process(chunk.text_delta): + yield f"data: { + orjson.dumps( + { + **base_event, + 'type': 'response.output_text.delta', + 'output_index': current_index, + 'part_index': 0, + 'delta': visible, + } + ).decode() + }\n\n" + + except Exception: + logger.exception("Responses streaming error") + yield f"data: { + orjson.dumps( + { + **base_event, + 'type': 'error', + 'error': {'message': 'Streaming error.'}, + } + ).decode() + }\n\n" return if all_outputs: - final_chunk = all_outputs[-1] - if final_chunk.text: - full_text = final_chunk.text - if final_chunk.thoughts: - full_thoughts = final_chunk.thoughts - - if last_chunk_was_thought: - yield f"data: {orjson.dumps({**base_event, 'type': 'response.content_part.done', 'output_index': current_idx, 'part_index': 0}).decode('utf-8')}\n\n" - yield f"data: {orjson.dumps({**base_event, 'type': 'response.output_item.done', 'output_index': current_idx, 'item': {'id': thought_item_id, 'type': 'reasoning', 'status': 'completed', 'content': [{'type': 'reasoning_text', 'text': full_thoughts}]}}).decode('utf-8')}\n\n" - current_idx += 1 + last = all_outputs[-1] + if last.text: + full_text = last.text + if last.thoughts: + full_thoughts = last.thoughts + + if thought_open: + yield f"data: { + orjson.dumps( + { + **base_event, + 'type': 'response.output_text.done', + 'output_index': current_index, + 'part_index': 0, + } + ).decode() + }\n\n" + + yield f"data: { + orjson.dumps( + { + **base_event, + 'type': 'response.content_part.done', + 'output_index': current_index, + 'part_index': 0, + } + ).decode() + }\n\n" + + yield f"data: { + orjson.dumps( + { + **base_event, + 'type': 'response.output_item.done', + 'output_index': current_index, + 'item': { + 'id': thought_item_id, + 'type': 'reasoning', + 'status': 'completed', + 'content': [{'type': 'reasoning_text', 'text': full_thoughts}], + }, + } + ).decode() + }\n\n" + + current_index += 1 + + remaining = suppressor.flush() + if remaining and message_open: + yield f"data: { + orjson.dumps( + { + **base_event, + 'type': 'response.output_text.delta', + 'output_index': current_index, + 'part_index': 0, + 'delta': remaining, + } + ).decode() + }\n\n" + + if message_open: + yield f"data: { + orjson.dumps( + { + **base_event, + 'type': 'response.output_text.done', + 'output_index': current_index, + 'part_index': 0, + } + ).decode() + }\n\n" + + yield f"data: { + orjson.dumps( + { + **base_event, + 'type': 'response.content_part.done', + 'output_index': current_index, + 'part_index': 0, + } + ).decode() + }\n\n" + + yield f"data: { + orjson.dumps( + { + **base_event, + 'type': 'response.output_item.done', + 'output_index': current_index, + 'item': { + 'id': message_item_id, + 'type': 'message', + 'role': 'assistant', + 'content': [{'type': 'output_text', 'text': full_text}], + }, + } + ).decode() + }\n\n" - remaining_from_suppressor = suppressor.flush() - if remaining_from_suppressor: - if not message_item_added: - yield f"data: {orjson.dumps({**base_event, 'type': 'response.output_item.added', 'output_index': current_idx, 'item': {'id': message_item_id, 'type': 'message', 'role': 'assistant', 'content': []}}).decode('utf-8')}\n\n" - yield f"data: {orjson.dumps({**base_event, 'type': 'response.content_part.added', 'output_index': current_idx, 'part_index': 0, 'part': {'type': 'output_text', 'text': ''}}).decode('utf-8')}\n\n" - message_item_added = True - yield f"data: {orjson.dumps({**base_event, 'type': 'response.output_text.delta', 'output_index': current_idx, 'part_index': 0, 'delta': remaining_from_suppressor}).decode('utf-8')}\n\n" + current_index += 1 - # IMPORTANT: Process output now to get the final assistant_text _thoughts, assistant_text, storage_output, detected_tool_calls = _process_llm_output( full_thoughts, full_text, structured_requirement ) - response_contents: list[ResponseOutputContent] = [] - if message_item_added: - yield f"data: {orjson.dumps({**base_event, 'type': 'response.output_text.done', 'output_index': current_idx, 'part_index': 0}).decode('utf-8')}\n\n" - yield f"data: {orjson.dumps({**base_event, 'type': 'response.content_part.done', 'output_index': current_idx, 'part_index': 0}).decode('utf-8')}\n\n" - - msg_content = [{"type": "output_text", "text": assistant_text}] - yield f"data: {orjson.dumps({**base_event, 'type': 'response.output_item.done', 'output_index': current_idx, 'item': {'id': message_item_id, 'type': 'message', 'role': 'assistant', 'content': msg_content}}).decode('utf-8')}\n\n" - response_contents.append(ResponseOutputContent(type="output_text", text=assistant_text)) - current_idx += 1 + image_items: list[ResponseImageGenerationCall] = [] + final_response_contents: list[ResponseOutputContent] = [] + seen_hashes = set() - images = [] - seen_urls = set() for out in all_outputs: if out.images: - for img in out.images: - if img.url not in seen_urls: - images.append(img) - seen_urls.add(img.url) + for image in out.images: + try: + b64, w, h, fname, fhash = await _image_to_base64(image, image_store) + if fhash in seen_hashes: + continue + seen_hashes.add(fhash) + + if "." in fname: + img_id, fmt = fname.rsplit(".", 1) + else: + img_id = fname + fmt = "png" + + img_item = ResponseImageGenerationCall( + id=img_id, + result=b64, + output_format=fmt, + size=f"{w}x{h}" if w and h else None, + ) - image_call_items: list[ResponseImageGenerationCall] = [] - seen_hashes = set() - for image in images: - try: - b64, w, h, fname, fhash = await _image_to_base64(image, image_store) - if fhash in seen_hashes: - (image_store / fname).unlink(missing_ok=True) - continue - seen_hashes.add(fhash) + image_url = ( + f"![{fname}]({base_url}images/{fname}?token={get_image_token(fname)})" + ) + final_response_contents.append( + ResponseOutputContent(type="output_text", text=image_url) + ) - if "." in fname: - img_id, img_format = fname.rsplit(".", 1) - else: - img_id = fname - img_format = "png" if isinstance(image, GeneratedImage) else "jpeg" + yield f"data: { + orjson.dumps( + { + **base_event, + 'type': 'response.output_item.added', + 'output_index': current_index, + 'item': img_item.model_dump(mode='json'), + } + ).decode() + }\n\n" - img_item = ResponseImageGenerationCall( - id=img_id, - result=b64, - output_format=img_format, - size=f"{w}x{h}" if w and h else None, - ) - image_call_items.append(img_item) + yield f"data: { + orjson.dumps( + { + **base_event, + 'type': 'response.output_item.done', + 'output_index': current_index, + 'item': img_item.model_dump(mode='json'), + } + ).decode() + }\n\n" - yield f"data: {orjson.dumps({**base_event, 'type': 'response.output_item.added', 'output_index': current_idx, 'item': img_item.model_dump(mode='json')}).decode('utf-8')}\n\n" - yield f"data: {orjson.dumps({**base_event, 'type': 'response.output_item.done', 'output_index': current_idx, 'item': img_item.model_dump(mode='json')}).decode('utf-8')}\n\n" - current_idx += 1 - except Exception as exc: - logger.warning(f"Failed to process image in stream: {exc}") + current_index += 1 + image_items.append(img_item) + + except Exception: + logger.warning("Image processing failed") + + if assistant_text: + final_response_contents.append( + ResponseOutputContent(type="output_text", text=assistant_text) + ) + + if not final_response_contents: + final_response_contents.append(ResponseOutputContent(type="output_text", text="")) image_markdown = "" - for img_call in image_call_items: + for img_call in image_items: fname = f"{img_call.id}.{img_call.output_format}" img_url = f"![{fname}]({base_url}images/{fname}?token={get_image_token(fname)})" image_markdown += f"\n\n{img_url}" @@ -1241,13 +1482,35 @@ async def generate_stream(): for call in detected_tool_calls: tc_item = ResponseToolCall(id=call.id, status="completed", function=call.function) - yield f"data: {orjson.dumps({**base_event, 'type': 'response.output_item.added', 'output_index': current_idx, 'item': tc_item.model_dump(mode='json')}).decode('utf-8')}\n\n" - yield f"data: {orjson.dumps({**base_event, 'type': 'response.output_item.done', 'output_index': current_idx, 'item': tc_item.model_dump(mode='json')}).decode('utf-8')}\n\n" - current_idx += 1 + + yield f"data: { + orjson.dumps( + { + **base_event, + 'type': 'response.output_item.added', + 'output_index': current_index, + 'item': tc_item.model_dump(mode='json'), + } + ).decode() + }\n\n" + + yield f"data: { + orjson.dumps( + { + **base_event, + 'type': 'response.output_item.done', + 'output_index': current_index, + 'item': tc_item.model_dump(mode='json'), + } + ).decode() + }\n\n" + + current_index += 1 p_tok, c_tok, t_tok, r_tok = _calculate_usage( messages, assistant_text, detected_tool_calls, full_thoughts ) + usage = ResponseUsage( input_tokens=p_tok, output_tokens=c_tok, @@ -1255,24 +1518,19 @@ async def generate_stream(): output_tokens_details={"reasoning_tokens": r_tok}, ) - # Ensure we have at least one content item if none was created - if not response_contents: - response_contents.append( - ResponseOutputContent(type="output_text", text=assistant_text or "") - ) - payload = _create_responses_standard_payload( response_id, created_time, model_name, detected_tool_calls, - image_call_items, - response_contents, + image_items, + final_response_contents, usage, request, None, full_thoughts, ) + _persist_conversation( db, model.model_name, @@ -1283,8 +1541,17 @@ async def generate_stream(): detected_tool_calls, full_thoughts, ) - yield f"data: {orjson.dumps({**base_event, 'type': 'response.completed', 'response': payload.model_dump(mode='json')}).decode('utf-8')}\n\n" - yield f"data: {orjson.dumps({**base_event, 'type': 'response.done'})}\n\n" + + yield f"data: { + orjson.dumps( + { + **base_event, + 'type': 'response.completed', + 'response': payload.model_dump(mode='json'), + } + ).decode() + }\n\n" + yield "data: [DONE]\n\n" return StreamingResponse(generate_stream(), media_type="text/event-stream") From ab706d8c580e7c9778fda3be7a33e8f7da6016a8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C6=B0u=20Quang=20V=C5=A9?= Date: Sat, 28 Feb 2026 22:28:56 +0700 Subject: [PATCH 165/291] Update image storage configuration to use dynamic path from settings --- app/server/middleware.py | 2 +- app/utils/config.py | 4 ++++ config/config.yaml | 1 + 3 files changed, 6 insertions(+), 1 deletion(-) diff --git a/app/server/middleware.py b/app/server/middleware.py index b5bc55b..2fa016b 100644 --- a/app/server/middleware.py +++ b/app/server/middleware.py @@ -13,7 +13,7 @@ from app.utils import g_config # Persistent directory for storing generated images -IMAGE_STORE_DIR = Path(tempfile.gettempdir()) / "ai_generated_images" +IMAGE_STORE_DIR = Path(g_config.storage.images_path) IMAGE_STORE_DIR.mkdir(parents=True, exist_ok=True) diff --git a/app/utils/config.py b/app/utils/config.py index 7371623..7dd55f4 100644 --- a/app/utils/config.py +++ b/app/utils/config.py @@ -164,6 +164,10 @@ class StorageConfig(BaseModel): default="data/lmdb", description="Path to the storage directory where data will be saved", ) + images_path: str = Field( + default="data/images", + description="Path to the directory where generated images will be stored", + ) max_size: int = Field( default=1024**2 * 256, # 256 MB ge=1, diff --git a/config/config.yaml b/config/config.yaml index f38ef86..bd9fbc0 100644 --- a/config/config.yaml +++ b/config/config.yaml @@ -33,6 +33,7 @@ gemini: storage: path: "data/lmdb" # Database storage path + images_path: "data/images" # Image storage path max_size: 268435456 # Maximum database size (256 MB) retention_days: 14 # Number of days to retain conversations before cleanup From 74badb615c4a6629d5dc0081fea6eb357f9c8b94 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C6=B0u=20Quang=20V=C5=A9?= Date: Sun, 1 Mar 2026 11:09:01 +0700 Subject: [PATCH 166/291] Fix streaming response generation --- app/models/models.py | 1 + app/server/chat.py | 738 ++++++++++++++++++++++--------------------- 2 files changed, 371 insertions(+), 368 deletions(-) diff --git a/app/models/models.py b/app/models/models.py index 8dbe86c..e310c54 100644 --- a/app/models/models.py +++ b/app/models/models.py @@ -245,6 +245,7 @@ class ResponseOutputMessage(BaseModel): id: str type: Literal["message"] + status: Literal["in_progress", "completed", "incomplete"] = Field(default="completed") role: Literal["assistant"] content: list[ResponseOutputContent] diff --git a/app/server/chat.py b/app/server/chat.py index 7d3a6cf..0a12c8c 100644 --- a/app/server/chat.py +++ b/app/server/chat.py @@ -20,6 +20,8 @@ from app.models import ( ChatCompletionRequest, + ChatCompletionResponse, + Choice, ContentItem, ConversationInStore, Message, @@ -34,12 +36,14 @@ ResponseOutputContent, ResponseOutputMessage, ResponseReasoning, - ResponseReasoningContentPart, + ResponseSummaryPart, ResponseToolCall, ResponseToolChoice, ResponseUsage, Tool, + ToolCall, ToolChoiceFunction, + Usage, ) from app.server.middleware import ( get_image_store_dir, @@ -165,16 +169,17 @@ def _create_responses_standard_payload( full_thoughts: str | None = None, ) -> ResponseCreateResponse: """Unified factory for building ResponseCreateResponse objects.""" - message_id = f"msg_{uuid.uuid4().hex}" - reason_id = f"reason_{uuid.uuid4().hex}" + message_id = f"msg_{uuid.uuid4().hex[:24]}" + reason_id = f"rs_{uuid.uuid4().hex[:24]}" output_items: list[Any] = [] if full_thoughts: output_items.append( ResponseReasoning( id=reason_id, + type="reasoning", status="completed", - content=[ResponseReasoningContentPart(text=full_thoughts)], + summary=[ResponseSummaryPart(type="summary_text", text=full_thoughts)], ) ) @@ -182,6 +187,7 @@ def _create_responses_standard_payload( ResponseOutputMessage( id=message_id, type="message", + status="completed", role="assistant", content=response_contents, ) @@ -192,6 +198,7 @@ def _create_responses_standard_payload( [ ResponseToolCall( id=call.id if hasattr(call, "id") else call["id"], + type="tool_call", status="completed", function=call.function if hasattr(call, "function") else call["function"], ) @@ -203,15 +210,16 @@ def _create_responses_standard_payload( return ResponseCreateResponse( id=response_id, + object="response", created_at=created_time, model=model_name, output=output_items, status="completed", usage=usage, input=normalized_input or None, - metadata=request.metadata or None, - tools=request.tools, - tool_choice=request.tool_choice, + metadata=request.metadata or {}, + tools=request.tools or [], + tool_choice=request.tool_choice or "auto", ) @@ -224,27 +232,34 @@ def _create_chat_completion_standard_payload( finish_reason: str, usage: dict, reasoning_content: str | None = None, -) -> dict: - """Unified factory for building Chat Completion response dictionaries.""" - return { - "id": completion_id, - "object": "chat.completion", - "created": created_time, - "model": model_name, - "choices": [ - { - "index": 0, - "message": { - "role": "assistant", - "content": visible_output or None, - "tool_calls": tool_calls_payload or None, - "reasoning_content": reasoning_content or None, - }, - "finish_reason": finish_reason, - } +) -> ChatCompletionResponse: + """Unified factory for building Chat Completion response objects.""" + # Convert tool calls to Model objects if they are dicts + tool_calls = None + if tool_calls_payload: + tool_calls = [ToolCall.model_validate(tc) for tc in tool_calls_payload] + + message = Message( + role="assistant", + content=visible_output or None, + tool_calls=tool_calls, + reasoning_content=reasoning_content or None, + ) + + return ChatCompletionResponse( + id=completion_id, + object="chat.completion", + created=created_time, + model=model_name, + choices=[ + Choice( + index=0, + message=message, + finish_reason=finish_reason, + ) ], - "usage": usage, - } + usage=Usage(**usage), + ) def _process_llm_output( @@ -994,7 +1009,6 @@ async def generate_stream(): for out in all_outputs: if out.images: for img in out.images: - # Use the image URL as a stable identifier across chunks if img.url not in seen_urls: images.append(img) seen_urls.add(img.url) @@ -1006,7 +1020,6 @@ async def generate_stream(): image_store = get_image_store_dir() _, _, _, fname, fhash = await _image_to_base64(image, image_store) if fhash in seen_hashes: - # Duplicate content, delete the file and skip (image_store / fname).unlink(missing_ok=True) continue seen_hashes.add(fhash) @@ -1019,7 +1032,6 @@ async def generate_stream(): if image_markdown: assistant_text += image_markdown storage_output += image_markdown - # Send the image Markdown as a final text chunk before usage data = { "id": completion_id, "object": "chat.completion.chunk", @@ -1050,12 +1062,12 @@ async def generate_stream(): p_tok, c_tok, t_tok, r_tok = _calculate_usage( messages, assistant_text, tool_calls, full_thoughts ) - usage = { - "prompt_tokens": p_tok, - "completion_tokens": c_tok, - "total_tokens": t_tok, - "completion_tokens_details": {"reasoning_tokens": r_tok}, - } + usage = Usage( + prompt_tokens=p_tok, + completion_tokens=c_tok, + total_tokens=t_tok, + completion_tokens_details={"reasoning_tokens": r_tok}, + ) data = { "id": completion_id, "object": "chat.completion.chunk", @@ -1064,7 +1076,7 @@ async def generate_stream(): "choices": [ {"index": 0, "delta": {}, "finish_reason": "tool_calls" if tool_calls else "stop"} ], - "usage": usage, + "usage": usage.model_dump(mode="json"), } _persist_conversation( db, @@ -1099,7 +1111,7 @@ def _create_responses_real_streaming_response( ) -> StreamingResponse: """ Create a real-time streaming response for the Responses API. - Ensures final accumulated text and thoughts are synchronized. + Ensures final accumulated text and thoughts are synchronized and follow the formal event stream spec. """ base_event = { "id": response_id, @@ -1109,190 +1121,205 @@ def _create_responses_real_streaming_response( } async def generate_stream(): - yield f"data: { - orjson.dumps( - { - **base_event, - 'type': 'response.created', - 'response': { - 'id': response_id, - 'object': 'response', - 'created_at': created_time, - 'model': model_name, - 'status': 'in_progress', - 'metadata': request.metadata, - 'input': None, - 'tools': request.tools, - 'tool_choice': request.tool_choice, - }, - } - ).decode() - }\n\n" - - full_thoughts = "" - full_text = "" + seq = 0 + + def make_event(etype: str, data: dict) -> str: + nonlocal seq + data["sequence_number"] = seq + seq += 1 + return f"event: {etype}\ndata: {orjson.dumps(data).decode()}\n\n" + + yield make_event( + "response.created", + { + **base_event, + "type": "response.created", + "response": { + "id": response_id, + "object": "response", + "created_at": created_time, + "model": model_name, + "status": "in_progress", + "metadata": request.metadata or {}, + "input": None, + "tools": request.tools or [], + "tool_choice": request.tool_choice or "auto", + "output": [], + "usage": None, + }, + }, + ) + + yield make_event( + "response.in_progress", + { + **base_event, + "type": "response.in_progress", + "response": { + "id": response_id, + "object": "response", + "created_at": created_time, + "model": model_name, + "status": "in_progress", + "metadata": request.metadata or {}, + "output": [], + }, + }, + ) + + full_thoughts, full_text = "", "" all_outputs: list[ModelOutput] = [] - thought_item_id = f"reason_{uuid.uuid4().hex}" - message_item_id = f"msg_{uuid.uuid4().hex}" + thought_item_id = f"rs_{uuid.uuid4().hex[:24]}" + message_item_id = f"msg_{uuid.uuid4().hex[:24]}" - thought_open = False - message_open = False + thought_open, message_open = False, False current_index = 0 - suppressor = StreamingOutputFilter() try: async for chunk in generator: all_outputs.append(chunk) + if chunk.thoughts_delta: if not thought_open: - yield f"data: { - orjson.dumps( - { - **base_event, - 'type': 'response.output_item.added', - 'output_index': current_index, - 'item': { - 'id': thought_item_id, - 'type': 'reasoning', - 'status': 'in_progress', - 'content': [], - }, - } - ).decode() - }\n\n" - - yield f"data: { - orjson.dumps( - { - **base_event, - 'type': 'response.content_part.added', - 'output_index': current_index, - 'part_index': 0, - 'part': {'type': 'reasoning_text', 'text': ''}, - } - ).decode() - }\n\n" + yield make_event( + "response.output_item.added", + { + **base_event, + "type": "response.output_item.added", + "output_index": current_index, + "item": ResponseReasoning( + id=thought_item_id, + type="reasoning", + status="in_progress", + summary=[], + ).model_dump(mode="json"), + }, + ) + yield make_event( + "response.reasoning_summary_part.added", + { + **base_event, + "type": "response.reasoning_summary_part.added", + "item_id": thought_item_id, + "output_index": current_index, + "summary_index": 0, + "part": ResponseSummaryPart(text="").model_dump(mode="json"), + }, + ) thought_open = True full_thoughts += chunk.thoughts_delta - - yield f"data: { - orjson.dumps( - { - **base_event, - 'type': 'response.output_text.delta', - 'output_index': current_index, - 'part_index': 0, - 'delta': chunk.thoughts_delta, - } - ).decode() - }\n\n" + yield make_event( + "response.reasoning_summary_text.delta", + { + **base_event, + "type": "response.reasoning_summary_text.delta", + "item_id": thought_item_id, + "output_index": current_index, + "summary_index": 0, + "delta": chunk.thoughts_delta, + }, + ) if chunk.text_delta: if thought_open: - yield f"data: { - orjson.dumps( - { - **base_event, - 'type': 'response.output_text.done', - 'output_index': current_index, - 'part_index': 0, - } - ).decode() - }\n\n" - - yield f"data: { - orjson.dumps( - { - **base_event, - 'type': 'response.content_part.done', - 'output_index': current_index, - 'part_index': 0, - } - ).decode() - }\n\n" - - yield f"data: { - orjson.dumps( - { - **base_event, - 'type': 'response.output_item.done', - 'output_index': current_index, - 'item': { - 'id': thought_item_id, - 'type': 'reasoning', - 'status': 'completed', - 'content': [ - {'type': 'reasoning_text', 'text': full_thoughts} - ], - }, - } - ).decode() - }\n\n" - + yield make_event( + "response.reasoning_summary_text.done", + { + **base_event, + "type": "response.reasoning_summary_text.done", + "item_id": thought_item_id, + "output_index": current_index, + "summary_index": 0, + "text": full_thoughts, + }, + ) + yield make_event( + "response.reasoning_summary_part.done", + { + **base_event, + "type": "response.reasoning_summary_part.done", + "item_id": thought_item_id, + "output_index": current_index, + "summary_index": 0, + "part": ResponseSummaryPart(text=full_thoughts).model_dump( + mode="json" + ), + }, + ) + yield make_event( + "response.output_item.done", + { + **base_event, + "type": "response.output_item.done", + "output_index": current_index, + "item": ResponseReasoning( + id=thought_item_id, + type="reasoning", + status="completed", + summary=[ResponseSummaryPart(text=full_thoughts)], + ).model_dump(mode="json"), + }, + ) current_index += 1 thought_open = False if not message_open: - yield f"data: { - orjson.dumps( - { - **base_event, - 'type': 'response.output_item.added', - 'output_index': current_index, - 'item': { - 'id': message_item_id, - 'type': 'message', - 'role': 'assistant', - 'content': [], - }, - } - ).decode() - }\n\n" - - yield f"data: { - orjson.dumps( - { - **base_event, - 'type': 'response.content_part.added', - 'output_index': current_index, - 'part_index': 0, - 'part': {'type': 'output_text', 'text': ''}, - } - ).decode() - }\n\n" + yield make_event( + "response.output_item.added", + { + **base_event, + "type": "response.output_item.added", + "output_index": current_index, + "item": ResponseOutputMessage( + id=message_item_id, + type="message", + status="in_progress", + role="assistant", + content=[], + ).model_dump(mode="json"), + }, + ) + yield make_event( + "response.content_part.added", + { + **base_event, + "type": "response.content_part.added", + "item_id": message_item_id, + "output_index": current_index, + "content_index": 0, + "part": ResponseOutputContent( + type="output_text", text="" + ).model_dump(mode="json"), + }, + ) message_open = True full_text += chunk.text_delta - if visible := suppressor.process(chunk.text_delta): - yield f"data: { - orjson.dumps( - { - **base_event, - 'type': 'response.output_text.delta', - 'output_index': current_index, - 'part_index': 0, - 'delta': visible, - } - ).decode() - }\n\n" + yield make_event( + "response.output_text.delta", + { + **base_event, + "type": "response.output_text.delta", + "item_id": message_item_id, + "output_index": current_index, + "content_index": 0, + "delta": visible, + "logprobs": [], + }, + ) except Exception: logger.exception("Responses streaming error") - yield f"data: { - orjson.dumps( - { - **base_event, - 'type': 'error', - 'error': {'message': 'Streaming error.'}, - } - ).decode() - }\n\n" + yield make_event( + "error", + {**base_event, "type": "error", "error": {"message": "Streaming error."}}, + ) return if all_outputs: @@ -1302,106 +1329,105 @@ async def generate_stream(): if last.thoughts: full_thoughts = last.thoughts - if thought_open: - yield f"data: { - orjson.dumps( - { - **base_event, - 'type': 'response.output_text.done', - 'output_index': current_index, - 'part_index': 0, - } - ).decode() - }\n\n" - - yield f"data: { - orjson.dumps( - { - **base_event, - 'type': 'response.content_part.done', - 'output_index': current_index, - 'part_index': 0, - } - ).decode() - }\n\n" - - yield f"data: { - orjson.dumps( - { - **base_event, - 'type': 'response.output_item.done', - 'output_index': current_index, - 'item': { - 'id': thought_item_id, - 'type': 'reasoning', - 'status': 'completed', - 'content': [{'type': 'reasoning_text', 'text': full_thoughts}], - }, - } - ).decode() - }\n\n" - - current_index += 1 - remaining = suppressor.flush() if remaining and message_open: - yield f"data: { - orjson.dumps( - { - **base_event, - 'type': 'response.output_text.delta', - 'output_index': current_index, - 'part_index': 0, - 'delta': remaining, - } - ).decode() - }\n\n" - - if message_open: - yield f"data: { - orjson.dumps( - { - **base_event, - 'type': 'response.output_text.done', - 'output_index': current_index, - 'part_index': 0, - } - ).decode() - }\n\n" - - yield f"data: { - orjson.dumps( - { - **base_event, - 'type': 'response.content_part.done', - 'output_index': current_index, - 'part_index': 0, - } - ).decode() - }\n\n" - - yield f"data: { - orjson.dumps( - { - **base_event, - 'type': 'response.output_item.done', - 'output_index': current_index, - 'item': { - 'id': message_item_id, - 'type': 'message', - 'role': 'assistant', - 'content': [{'type': 'output_text', 'text': full_text}], - }, - } - ).decode() - }\n\n" + yield make_event( + "response.output_text.delta", + { + **base_event, + "type": "response.output_text.delta", + "item_id": message_item_id, + "output_index": current_index, + "content_index": 0, + "delta": remaining, + "logprobs": [], + }, + ) + if thought_open: + yield make_event( + "response.reasoning_summary_text.done", + { + **base_event, + "type": "response.reasoning_summary_text.done", + "item_id": thought_item_id, + "output_index": current_index, + "summary_index": 0, + "text": full_thoughts, + }, + ) + yield make_event( + "response.reasoning_summary_part.done", + { + **base_event, + "type": "response.reasoning_summary_part.done", + "item_id": thought_item_id, + "output_index": current_index, + "summary_index": 0, + "part": ResponseSummaryPart(text=full_thoughts).model_dump(mode="json"), + }, + ) + yield make_event( + "response.output_item.done", + { + **base_event, + "type": "response.output_item.done", + "output_index": current_index, + "item": ResponseReasoning( + id=thought_item_id, + type="reasoning", + status="completed", + summary=[ResponseSummaryPart(text=full_thoughts)], + ).model_dump(mode="json"), + }, + ) current_index += 1 _thoughts, assistant_text, storage_output, detected_tool_calls = _process_llm_output( full_thoughts, full_text, structured_requirement ) + if message_open: + yield make_event( + "response.output_text.done", + { + **base_event, + "type": "response.output_text.done", + "item_id": message_item_id, + "output_index": current_index, + "content_index": 0, + }, + ) + yield make_event( + "response.content_part.done", + { + **base_event, + "type": "response.content_part.done", + "item_id": message_item_id, + "output_index": current_index, + "content_index": 0, + "part": ResponseOutputContent( + type="output_text", text=assistant_text + ).model_dump(mode="json"), + }, + ) + yield make_event( + "response.output_item.done", + { + **base_event, + "type": "response.output_item.done", + "output_index": current_index, + "item": ResponseOutputMessage( + id=message_item_id, + type="message", + status="completed", + role="assistant", + content=[ResponseOutputContent(type="output_text", text=assistant_text)], + ).model_dump(mode="json"), + }, + ) + current_index += 1 + image_items: list[ResponseImageGenerationCall] = [] final_response_contents: list[ResponseOutputContent] = [] seen_hashes = set() @@ -1415,11 +1441,9 @@ async def generate_stream(): continue seen_hashes.add(fhash) - if "." in fname: - img_id, fmt = fname.rsplit(".", 1) - else: - img_id = fname - fmt = "png" + parts = fname.rsplit(".", 1) + img_id = parts[0] + fmt = parts[1] if len(parts) > 1 else "png" img_item = ResponseImageGenerationCall( id=img_id, @@ -1435,89 +1459,67 @@ async def generate_stream(): ResponseOutputContent(type="output_text", text=image_url) ) - yield f"data: { - orjson.dumps( - { - **base_event, - 'type': 'response.output_item.added', - 'output_index': current_index, - 'item': img_item.model_dump(mode='json'), - } - ).decode() - }\n\n" - - yield f"data: { - orjson.dumps( - { - **base_event, - 'type': 'response.output_item.done', - 'output_index': current_index, - 'item': img_item.model_dump(mode='json'), - } - ).decode() - }\n\n" + yield make_event( + "response.output_item.added", + { + **base_event, + "type": "response.output_item.added", + "output_index": current_index, + "item": img_item.model_dump(mode="json"), + }, + ) + yield make_event( + "response.output_item.done", + { + **base_event, + "type": "response.output_item.done", + "output_index": current_index, + "item": img_item.model_dump(mode="json"), + }, + ) current_index += 1 image_items.append(img_item) - + storage_output += f"\n\n{image_url}" except Exception: - logger.warning("Image processing failed") - - if assistant_text: - final_response_contents.append( - ResponseOutputContent(type="output_text", text=assistant_text) - ) - - if not final_response_contents: - final_response_contents.append(ResponseOutputContent(type="output_text", text="")) - - image_markdown = "" - for img_call in image_items: - fname = f"{img_call.id}.{img_call.output_format}" - img_url = f"![{fname}]({base_url}images/{fname}?token={get_image_token(fname)})" - image_markdown += f"\n\n{img_url}" - - if image_markdown: - storage_output += image_markdown + logger.warning("Image processing failed in stream") for call in detected_tool_calls: tc_item = ResponseToolCall(id=call.id, status="completed", function=call.function) - - yield f"data: { - orjson.dumps( - { - **base_event, - 'type': 'response.output_item.added', - 'output_index': current_index, - 'item': tc_item.model_dump(mode='json'), - } - ).decode() - }\n\n" - - yield f"data: { - orjson.dumps( - { - **base_event, - 'type': 'response.output_item.done', - 'output_index': current_index, - 'item': tc_item.model_dump(mode='json'), - } - ).decode() - }\n\n" - + yield make_event( + "response.output_item.added", + { + **base_event, + "type": "response.output_item.added", + "output_index": current_index, + "item": tc_item.model_dump(mode="json"), + }, + ) + yield make_event( + "response.output_item.done", + { + **base_event, + "type": "response.output_item.done", + "output_index": current_index, + "item": tc_item.model_dump(mode="json"), + }, + ) current_index += 1 + if assistant_text: + final_response_contents.insert( + 0, ResponseOutputContent(type="output_text", text=assistant_text) + ) + p_tok, c_tok, t_tok, r_tok = _calculate_usage( messages, assistant_text, detected_tool_calls, full_thoughts ) - usage = ResponseUsage( input_tokens=p_tok, output_tokens=c_tok, total_tokens=t_tok, output_tokens_details={"reasoning_tokens": r_tok}, ) - payload = _create_responses_standard_payload( response_id, created_time, @@ -1530,7 +1532,6 @@ async def generate_stream(): None, full_thoughts, ) - _persist_conversation( db, model.model_name, @@ -1542,15 +1543,14 @@ async def generate_stream(): full_thoughts, ) - yield f"data: { - orjson.dumps( - { - **base_event, - 'type': 'response.completed', - 'response': payload.model_dump(mode='json'), - } - ).decode() - }\n\n" + yield make_event( + "response.completed", + { + **base_event, + "type": "response.completed", + "response": payload.model_dump(mode="json"), + }, + ) yield "data: [DONE]\n\n" @@ -1862,11 +1862,13 @@ async def create_response( continue seen_hashes.add(fhash) - if "." in fname: - img_id, img_format = fname.rsplit(".", 1) - else: - img_id = fname - img_format = "png" if isinstance(img, GeneratedImage) else "jpeg" + parts = fname.rsplit(".", 1) + img_id = parts[0] + img_format = ( + parts[1] + if len(parts) > 1 + else ("png" if isinstance(img, GeneratedImage) else "jpeg") + ) contents.append( ResponseOutputContent( From 2dec77c302c644e2cc62adc3f8f40b357970f123 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C6=B0u=20Quang=20V=C5=A9?= Date: Sun, 1 Mar 2026 11:42:09 +0700 Subject: [PATCH 167/291] Fix streaming response generation --- app/models/__init__.py | 4 ++++ app/models/models.py | 29 ++++++++++++++++++++++------- app/server/chat.py | 8 ++++++++ 3 files changed, 34 insertions(+), 7 deletions(-) diff --git a/app/models/__init__.py b/app/models/__init__.py index 1378f1f..7f95131 100644 --- a/app/models/__init__.py +++ b/app/models/__init__.py @@ -20,6 +20,8 @@ ResponseReasoning, ResponseReasoningContentPart, ResponseSummaryPart, + ResponseTextConfig, + ResponseTextFormat, ResponseToolCall, ResponseToolChoice, ResponseUsage, @@ -53,6 +55,8 @@ "ResponseReasoning", "ResponseReasoningContentPart", "ResponseSummaryPart", + "ResponseTextConfig", + "ResponseTextFormat", "ResponseToolCall", "ResponseToolChoice", "ResponseUsage", diff --git a/app/models/models.py b/app/models/models.py index e310c54..fccfba1 100644 --- a/app/models/models.py +++ b/app/models/models.py @@ -228,8 +228,8 @@ class ResponseUsage(BaseModel): input_tokens: int output_tokens: int total_tokens: int - input_tokens_details: dict[str, int] | None = Field(default=None) - output_tokens_details: dict[str, int] | None = Field(default=None) + input_tokens_details: dict[str, Any] = Field(default_factory=lambda: {"cached_tokens": 0}) + output_tokens_details: dict[str, Any] = Field(default_factory=lambda: {"reasoning_tokens": 0}) class ResponseOutputContent(BaseModel): @@ -238,6 +238,7 @@ class ResponseOutputContent(BaseModel): type: Literal["output_text"] text: str | None = Field(default="") annotations: list[dict[str, Any]] = Field(default_factory=list) + logprobs: list[dict[str, Any]] | None = Field(default=None) class ResponseOutputMessage(BaseModel): @@ -269,7 +270,7 @@ class ResponseReasoning(BaseModel): id: str type: Literal["reasoning"] = Field(default="reasoning") - status: Literal["in_progress", "completed", "incomplete"] = Field(default="completed") + status: Literal["in_progress", "completed", "incomplete"] | None = Field(default=None) summary: list[ResponseSummaryPart] | None = Field(default=None) content: list[ResponseReasoningContentPart] | None = Field(default=None) @@ -297,12 +298,25 @@ class ResponseToolCall(BaseModel): function: FunctionCall +class ResponseTextFormat(BaseModel): + """Text format configuration for Responses API.""" + + type: Literal["text", "json_schema"] = Field(default="text") + + +class ResponseTextConfig(BaseModel): + """Text configuration for Responses API.""" + + format: ResponseTextFormat = Field(default_factory=ResponseTextFormat) + + class ResponseCreateResponse(BaseModel): """Responses API response payload.""" id: str object: Literal["response"] = Field(default="response") created_at: int + completed_at: int | None = Field(default=None) model: str output: list[ ResponseReasoning | ResponseOutputMessage | ResponseImageGenerationCall | ResponseToolCall @@ -315,12 +329,13 @@ class ResponseCreateResponse(BaseModel): "cancelled", "requires_action", ] = Field(default="completed") - tool_choice: str | ResponseToolChoice | None = Field(default=None) - tools: list[Tool | ResponseImageTool] | None = Field(default=None) - usage: ResponseUsage + tool_choice: str | ToolChoiceFunction | ResponseToolChoice = Field(default="auto") + tools: list[Tool | ResponseImageTool] = Field(default_factory=list) + usage: ResponseUsage | None = Field(default=None) error: dict[str, Any] | None = Field(default=None) - metadata: dict[str, Any] | None = Field(default=None) + metadata: dict[str, Any] = Field(default_factory=dict) input: str | list[ResponseInputItem] | None = Field(default=None) + text: ResponseTextConfig | None = Field(default_factory=ResponseTextConfig) # Rebuild models with forward references diff --git a/app/server/chat.py b/app/server/chat.py index 0a12c8c..8d8be91 100644 --- a/app/server/chat.py +++ b/app/server/chat.py @@ -37,6 +37,7 @@ ResponseOutputMessage, ResponseReasoning, ResponseSummaryPart, + ResponseTextConfig, ResponseToolCall, ResponseToolChoice, ResponseUsage, @@ -171,6 +172,7 @@ def _create_responses_standard_payload( """Unified factory for building ResponseCreateResponse objects.""" message_id = f"msg_{uuid.uuid4().hex[:24]}" reason_id = f"rs_{uuid.uuid4().hex[:24]}" + now_ts = int(datetime.now(tz=UTC).timestamp()) output_items: list[Any] = [] if full_thoughts: @@ -208,10 +210,15 @@ def _create_responses_standard_payload( output_items.extend(image_call_items) + text_config = ResponseTextConfig() + if request.response_format and request.response_format.get("type") == "json_schema": + text_config.format.type = "json_schema" + return ResponseCreateResponse( id=response_id, object="response", created_at=created_time, + completed_at=now_ts, model=model_name, output=output_items, status="completed", @@ -220,6 +227,7 @@ def _create_responses_standard_payload( metadata=request.metadata or {}, tools=request.tools or [], tool_choice=request.tool_choice or "auto", + text=text_config, ) From e4da69e5138866960ee27669f431306279446752 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C6=B0u=20Quang=20V=C5=A9?= Date: Sun, 1 Mar 2026 18:22:57 +0700 Subject: [PATCH 168/291] Remove optional custom cookies parameter --- app/services/pool.py | 25 ++++++------------------- app/utils/config.py | 13 ------------- pyproject.toml | 2 +- uv.lock | 8 ++++---- 4 files changed, 11 insertions(+), 37 deletions(-) diff --git a/app/services/pool.py b/app/services/pool.py index 3c26e3d..3b4197c 100644 --- a/app/services/pool.py +++ b/app/services/pool.py @@ -1,8 +1,6 @@ import asyncio -import inspect from collections import deque -from gemini_webapi import GeminiClient from loguru import logger from app.utils import g_config @@ -24,23 +22,12 @@ def __init__(self) -> None: raise ValueError("No Gemini clients configured") for c in g_config.gemini.clients: - kwargs = { - "client_id": c.id, - "secure_1psid": c.secure_1psid, - "secure_1psidts": c.secure_1psidts, - "proxy": c.proxy, - } - if c.cookies: - sig = inspect.signature(GeminiClient.__init__) - if "cookies" in sig.parameters: - kwargs["cookies"] = c.cookies - else: - logger.debug( - f"Ignoring 'cookies' in config for client {c.id} because " - "the current version of gemini_webapi doesn't support it." - ) - - client = GeminiClientWrapper(**kwargs) + client = GeminiClientWrapper( + client_id=c.id, + secure_1psid=c.secure_1psid, + secure_1psidts=c.secure_1psidts, + proxy=c.proxy, + ) self._clients.append(client) self._id_map[c.id] = client self._round_robin.append(client) diff --git a/app/utils/config.py b/app/utils/config.py index 7dd55f4..69af2e1 100644 --- a/app/utils/config.py +++ b/app/utils/config.py @@ -42,9 +42,6 @@ class GeminiClientSettings(BaseModel): secure_1psid: str = Field(..., description="Gemini Secure 1PSID") secure_1psidts: str = Field(..., description="Gemini Secure 1PSIDTS") proxy: str | None = Field(default=None, description="Proxy URL for this Gemini client") - cookies: dict[str, str] | None = Field( - default=None, description="Optional custom cookies for this Gemini client" - ) @field_validator("proxy", mode="before") @classmethod @@ -54,16 +51,6 @@ def _blank_proxy_to_none(cls, value: str | None) -> str | None: stripped = value.strip() return stripped or None - @field_validator("cookies", mode="before") - @classmethod - def _parse_cookies(cls, v: Any) -> Any: - if isinstance(v, str) and v.strip().startswith("{"): - try: - return orjson.loads(v) - except orjson.JSONDecodeError: - pass - return v - class GeminiModelConfig(BaseModel): """Configuration for a custom Gemini model.""" diff --git a/pyproject.toml b/pyproject.toml index 8638b03..90a2d5c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,7 +6,7 @@ readme = "README.md" requires-python = "==3.13.*" dependencies = [ "curl-cffi>=0.14.0", - "fastapi>=0.134.0", + "fastapi>=0.135.0", "gemini-webapi>=1.19.2", "httptools>=0.7.1", "lmdb>=1.7.5", diff --git a/uv.lock b/uv.lock index 8fb1f99..470045b 100644 --- a/uv.lock +++ b/uv.lock @@ -110,7 +110,7 @@ wheels = [ [[package]] name = "fastapi" -version = "0.134.0" +version = "0.135.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "annotated-doc" }, @@ -119,9 +119,9 @@ dependencies = [ { name = "typing-extensions" }, { name = "typing-inspection" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/96/15/647ea81cb73b55b48fb095158a9cd64e42e9e4f1d34dbb5cc4a4939779d6/fastapi-0.134.0.tar.gz", hash = "sha256:3122b1ea0dbeaab48b5976e80b99ca7eda02be154bf03e126a33220e73255a9a", size = 385667, upload-time = "2026-02-27T21:18:12.931Z" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/b5/386a9579a299a32365b34097e4eac6a0544ce0d7aa4bb95ce0d71607a999/fastapi-0.135.0.tar.gz", hash = "sha256:bd37903acf014d1284bda027096e460814dca9699f9dacfe11c275749d949f4d", size = 393855, upload-time = "2026-03-01T09:28:46.714Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e3/e6/fd49c28a54b7d6f5c64045155e40f6cff9ed4920055043fb5ac7969f7f2f/fastapi-0.134.0-py3-none-any.whl", hash = "sha256:f4e7214f24b2262258492e05c48cf21125e4ffc427e30dd32fb4f74049a3d56a", size = 110404, upload-time = "2026-02-27T21:18:10.809Z" }, + { url = "https://files.pythonhosted.org/packages/56/38/fa5dd0e677e1e2e38f858933c4a125e80103e551151f1f661dd4f227210d/fastapi-0.135.0-py3-none-any.whl", hash = "sha256:31e2ddc78d6406c6f7d5d7b9996a057985e2600fbe7e9ba6ace8205d48dff688", size = 114496, upload-time = "2026-03-01T09:28:48.162Z" }, ] [[package]] @@ -155,7 +155,7 @@ dev = [ [package.metadata] requires-dist = [ { name = "curl-cffi", specifier = ">=0.14.0" }, - { name = "fastapi", specifier = ">=0.133.1" }, + { name = "fastapi", specifier = ">=0.135.0" }, { name = "gemini-webapi", specifier = ">=1.19.2" }, { name = "httptools", specifier = ">=0.7.1" }, { name = "lmdb", specifier = ">=1.7.5" }, From 54dd826542db2cdb54e50d7329547eff0940df34 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C6=B0u=20Quang=20V=C5=A9?= Date: Mon, 2 Mar 2026 23:45:01 +0700 Subject: [PATCH 169/291] Change `watchdog_timeout` to 90s --- app/utils/config.py | 2 +- config/config.yaml | 2 +- pyproject.toml | 2 +- uv.lock | 14 +++++++------- 4 files changed, 10 insertions(+), 10 deletions(-) diff --git a/app/utils/config.py b/app/utils/config.py index 69af2e1..fec8eee 100644 --- a/app/utils/config.py +++ b/app/utils/config.py @@ -83,7 +83,7 @@ class GeminiConfig(BaseModel): description="Strategy for loading models: 'append' merges custom with default, 'overwrite' uses only custom", ) timeout: int = Field(default=600, ge=30, description="Init timeout in seconds") - watchdog_timeout: int = Field(default=300, ge=30, description="Watchdog timeout in seconds") + watchdog_timeout: int = Field(default=90, ge=30, description="Watchdog timeout in seconds") auto_refresh: bool = Field(True, description="Enable auto-refresh for Gemini cookies") refresh_interval: int = Field( default=600, diff --git a/config/config.yaml b/config/config.yaml index bd9fbc0..71301d0 100644 --- a/config/config.yaml +++ b/config/config.yaml @@ -23,7 +23,7 @@ gemini: secure_1psidts: "YOUR_SECURE_1PSIDTS_HERE" proxy: null # Optional proxy URL (null/empty means direct connection) timeout: 600 # Init timeout in seconds (Not less than 30s) - watchdog_timeout: 300 # Watchdog timeout in seconds (Not less than 30s) + watchdog_timeout: 90 # Watchdog timeout in seconds (Not less than 30s) auto_refresh: true # Auto-refresh session cookies refresh_interval: 600 # Refresh interval in seconds (Not less than 60s) verbose: false # Enable verbose logging for Gemini requests diff --git a/pyproject.toml b/pyproject.toml index 90a2d5c..83a1b87 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,7 +6,7 @@ readme = "README.md" requires-python = "==3.13.*" dependencies = [ "curl-cffi>=0.14.0", - "fastapi>=0.135.0", + "fastapi>=0.135.1", "gemini-webapi>=1.19.2", "httptools>=0.7.1", "lmdb>=1.7.5", diff --git a/uv.lock b/uv.lock index 470045b..fd1a7df 100644 --- a/uv.lock +++ b/uv.lock @@ -110,7 +110,7 @@ wheels = [ [[package]] name = "fastapi" -version = "0.135.0" +version = "0.135.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "annotated-doc" }, @@ -119,9 +119,9 @@ dependencies = [ { name = "typing-extensions" }, { name = "typing-inspection" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/a2/b5/386a9579a299a32365b34097e4eac6a0544ce0d7aa4bb95ce0d71607a999/fastapi-0.135.0.tar.gz", hash = "sha256:bd37903acf014d1284bda027096e460814dca9699f9dacfe11c275749d949f4d", size = 393855, upload-time = "2026-03-01T09:28:46.714Z" } +sdist = { url = "https://files.pythonhosted.org/packages/e7/7b/f8e0211e9380f7195ba3f3d40c292594fd81ba8ec4629e3854c353aaca45/fastapi-0.135.1.tar.gz", hash = "sha256:d04115b508d936d254cea545b7312ecaa58a7b3a0f84952535b4c9afae7668cd", size = 394962, upload-time = "2026-03-01T18:18:29.369Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/56/38/fa5dd0e677e1e2e38f858933c4a125e80103e551151f1f661dd4f227210d/fastapi-0.135.0-py3-none-any.whl", hash = "sha256:31e2ddc78d6406c6f7d5d7b9996a057985e2600fbe7e9ba6ace8205d48dff688", size = 114496, upload-time = "2026-03-01T09:28:48.162Z" }, + { url = "https://files.pythonhosted.org/packages/e4/72/42e900510195b23a56bde950d26a51f8b723846bfcaa0286e90287f0422b/fastapi-0.135.1-py3-none-any.whl", hash = "sha256:46e2fc5745924b7c840f71ddd277382af29ce1cdb7d5eab5bf697e3fb9999c9e", size = 116999, upload-time = "2026-03-01T18:18:30.831Z" }, ] [[package]] @@ -155,7 +155,7 @@ dev = [ [package.metadata] requires-dist = [ { name = "curl-cffi", specifier = ">=0.14.0" }, - { name = "fastapi", specifier = ">=0.135.0" }, + { name = "fastapi", specifier = ">=0.135.1" }, { name = "gemini-webapi", specifier = ">=1.19.2" }, { name = "httptools", specifier = ">=0.7.1" }, { name = "lmdb", specifier = ">=1.7.5" }, @@ -457,11 +457,11 @@ wheels = [ [[package]] name = "python-dotenv" -version = "1.2.1" +version = "1.2.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f0/26/19cadc79a718c5edbec86fd4919a6b6d3f681039a2f6d66d14be94e75fb9/python_dotenv-1.2.1.tar.gz", hash = "sha256:42667e897e16ab0d66954af0e60a9caa94f0fd4ecf3aaf6d2d260eec1aa36ad6", size = 44221, upload-time = "2025-10-26T15:12:10.434Z" } +sdist = { url = "https://files.pythonhosted.org/packages/82/ed/0301aeeac3e5353ef3d94b6ec08bbcabd04a72018415dcb29e588514bba8/python_dotenv-1.2.2.tar.gz", hash = "sha256:2c371a91fbd7ba082c2c1dc1f8bf89ca22564a087c2c287cd9b662adde799cf3", size = 50135, upload-time = "2026-03-01T16:00:26.196Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/14/1b/a298b06749107c305e1fe0f814c6c74aea7b2f1e10989cb30f544a1b3253/python_dotenv-1.2.1-py3-none-any.whl", hash = "sha256:b81ee9561e9ca4004139c6cbba3a238c32b03e4894671e181b671e8cb8425d61", size = 21230, upload-time = "2025-10-26T15:12:09.109Z" }, + { url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101, upload-time = "2026-03-01T16:00:25.09Z" }, ] [[package]] From 91a64cdc8efd66bad42e204950582b86b4e4b32a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C6=B0u=20Quang=20V=C5=A9?= Date: Tue, 3 Mar 2026 21:57:03 +0700 Subject: [PATCH 170/291] Refactored Models to improving type safety. --- app/models/__init__.py | 92 +++++--- app/models/models.py | 458 +++++++++++++++++++++++++-------------- app/server/chat.py | 443 ++++++++++++++++++++++--------------- app/server/middleware.py | 2 +- app/services/client.py | 35 +-- app/services/lmdb.py | 124 +++++++---- app/utils/config.py | 2 +- app/utils/helper.py | 14 +- scripts/dump_lmdb.py | 5 +- 9 files changed, 732 insertions(+), 443 deletions(-) diff --git a/app/models/__init__.py b/app/models/__init__.py index 7f95131..1e02dcb 100644 --- a/app/models/__init__.py +++ b/app/models/__init__.py @@ -1,69 +1,93 @@ from .models import ( + ChatCompletionAssistantContentItem, + ChatCompletionChoice, + ChatCompletionContentItem, + ChatCompletionFunctionTool, + ChatCompletionMessage, + ChatCompletionMessageToolCall, + ChatCompletionNamedToolChoice, + ChatCompletionNamedToolChoiceFunction, ChatCompletionRequest, + ChatCompletionRequestContentItem, ChatCompletionResponse, - Choice, - ContentItem, + CompletionUsage, ConversationInStore, FunctionCall, + FunctionCallOutput, + FunctionDefinition, + FunctionTool, HealthCheckResponse, - Message, + ImageGeneration, + ImageGenerationCall, ModelData, ModelListResponse, + ReasoningTextContent, ResponseCreateRequest, ResponseCreateResponse, - ResponseImageGenerationCall, - ResponseImageTool, - ResponseInputContent, - ResponseInputItem, + ResponseFormatText, + ResponseFormatTextJSONSchemaConfig, + ResponseFunctionToolCall, + ResponseInputFile, + ResponseInputImage, + ResponseInputMessage, + ResponseInputMessageContentList, + ResponseInputText, ResponseOutputContent, ResponseOutputMessage, - ResponseReasoning, - ResponseReasoningContentPart, - ResponseSummaryPart, + ResponseOutputRefusal, + ResponseOutputText, + ResponseReasoningItem, ResponseTextConfig, - ResponseTextFormat, ResponseToolCall, - ResponseToolChoice, ResponseUsage, - Tool, - ToolCall, + SummaryTextContent, ToolChoiceFunction, - ToolChoiceFunctionDetail, - ToolFunctionDefinition, - Usage, + ToolChoiceTypes, ) __all__ = [ + "ChatCompletionAssistantContentItem", + "ChatCompletionChoice", + "ChatCompletionContentItem", + "ChatCompletionFunctionTool", + "ChatCompletionMessage", + "ChatCompletionMessageToolCall", + "ChatCompletionNamedToolChoice", + "ChatCompletionNamedToolChoiceFunction", "ChatCompletionRequest", + "ChatCompletionRequestContentItem", "ChatCompletionResponse", - "Choice", - "ContentItem", + "CompletionUsage", "ConversationInStore", "FunctionCall", + "FunctionCallOutput", + "FunctionDefinition", + "FunctionTool", "HealthCheckResponse", - "Message", + "ImageGeneration", + "ImageGenerationCall", "ModelData", "ModelListResponse", + "ReasoningTextContent", "ResponseCreateRequest", "ResponseCreateResponse", - "ResponseImageGenerationCall", - "ResponseImageTool", - "ResponseInputContent", - "ResponseInputItem", + "ResponseFormatText", + "ResponseFormatTextJSONSchemaConfig", + "ResponseFunctionToolCall", + "ResponseInputFile", + "ResponseInputImage", + "ResponseInputMessage", + "ResponseInputMessageContentList", + "ResponseInputText", "ResponseOutputContent", "ResponseOutputMessage", - "ResponseReasoning", - "ResponseReasoningContentPart", - "ResponseSummaryPart", + "ResponseOutputRefusal", + "ResponseOutputText", + "ResponseReasoningItem", "ResponseTextConfig", - "ResponseTextFormat", "ResponseToolCall", - "ResponseToolChoice", "ResponseUsage", - "Tool", - "ToolCall", + "SummaryTextContent", "ToolChoiceFunction", - "ToolChoiceFunctionDetail", - "ToolFunctionDefinition", - "Usage", + "ToolChoiceTypes", ] diff --git a/app/models/models.py b/app/models/models.py index fccfba1..7284b5f 100644 --- a/app/models/models.py +++ b/app/models/models.py @@ -6,92 +6,99 @@ from pydantic import BaseModel, Field, model_validator -class ContentItem(BaseModel): - """Individual content item (text, image, or file) within a message.""" +class FunctionCall(BaseModel): + """Executed function call payload.""" + + name: str + arguments: str + + +class FunctionDefinition(BaseModel): + """Schema of a callable function exposed to the model.""" + + name: str + description: str | None = Field(default=None) + parameters: dict[str, Any] | None = Field(default=None) + + +class ChatCompletionRequestContentItem(BaseModel): + """Content item for user / system / tool messages.""" type: Literal["text", "image_url", "file", "input_audio"] text: str | None = Field(default=None) image_url: dict[str, Any] | None = Field(default=None) input_audio: dict[str, Any] | None = Field(default=None) file: dict[str, Any] | None = Field(default=None) - annotations: list[dict[str, Any]] = Field(default_factory=list) -class Message(BaseModel): - """Message model""" +class ChatCompletionAssistantContentItem(BaseModel): + """Content item for assistant messages. - role: str - content: str | list[ContentItem] | None = Field(default=None) - name: str | None = Field(default=None) - tool_calls: list[ToolCall] | None = Field(default=None) - tool_call_id: str | None = Field(default=None) + ``refusal`` is an official OpenAI content part. + ``reasoning`` is a community extension used to persist chain-of-thought + text for reusable-session matching. + """ + + type: Literal["text", "refusal", "reasoning"] + text: str | None = Field(default=None) refusal: str | None = Field(default=None) - reasoning_content: str | None = Field(default=None) - audio: dict[str, Any] | None = Field(default=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 +ChatCompletionContentItem = ChatCompletionRequestContentItem | ChatCompletionAssistantContentItem -class Choice(BaseModel): - """Choice model""" - index: int - message: Message - finish_reason: str - logprobs: dict[str, Any] | None = Field(default=None) - - -class FunctionCall(BaseModel): - """Function call payload""" - - name: str - arguments: str - - -class ToolCall(BaseModel): - """Tool call item""" +class ChatCompletionMessageToolCall(BaseModel): + """A single tool call emitted by the assistant.""" id: str type: Literal["function"] function: FunctionCall -class ToolFunctionDefinition(BaseModel): - """Function definition for tool.""" +class ChatCompletionMessage(BaseModel): + """A single message in a Chat Completions conversation.""" - name: str - description: str | None = Field(default=None) - parameters: dict[str, Any] | None = Field(default=None) + role: Literal["developer", "system", "user", "assistant", "tool", "function"] + content: ( + str | list[ChatCompletionRequestContentItem | ChatCompletionAssistantContentItem] | None + ) = Field(default=None) + name: str | None = Field(default=None) + tool_calls: list[ChatCompletionMessageToolCall] | None = Field(default=None) + tool_call_id: str | None = Field(default=None) + refusal: str | None = Field(default=None) + reasoning_content: str | None = Field(default=None) + audio: dict[str, Any] | None = Field(default=None) + annotations: list[dict[str, Any]] = Field(default_factory=list) + + @model_validator(mode="after") + def normalize_role(self) -> ChatCompletionMessage: + """Normalize ``developer`` role to ``system`` for Gemini compatibility.""" + if self.role == "developer": + self.role = "system" + return self -class Tool(BaseModel): - """Tool specification.""" +class ChatCompletionFunctionTool(BaseModel): + """A function tool for the Chat Completions API.""" type: Literal["function"] - function: ToolFunctionDefinition - + function: FunctionDefinition -class ToolChoiceFunctionDetail(BaseModel): - """Detail of a tool choice function.""" +class ChatCompletionNamedToolChoiceFunction(BaseModel): name: str -class ToolChoiceFunction(BaseModel): - """Tool choice forcing a specific function.""" +class ChatCompletionNamedToolChoice(BaseModel): + """Forces the model to call a specific named function.""" type: Literal["function"] - function: ToolChoiceFunctionDetail + function: ChatCompletionNamedToolChoiceFunction -class Usage(BaseModel): - """Usage statistics model""" +class CompletionUsage(BaseModel): + """Token-usage statistics for a Chat Completions response.""" prompt_tokens: int completion_tokens: int @@ -100,130 +107,155 @@ class Usage(BaseModel): completion_tokens_details: dict[str, int] | None = Field(default=None) -class ModelData(BaseModel): - """Model data model""" +class ChatCompletionChoice(BaseModel): + """A single completion choice.""" - id: str - object: str = "model" - created: int - owned_by: str = "google" + index: int + message: ChatCompletionMessage + finish_reason: Literal["stop", "length", "tool_calls", "content_filter"] + logprobs: dict[str, Any] | None = Field(default=None) class ChatCompletionRequest(BaseModel): - """Chat completion request model""" + """Request body for POST /v1/chat/completions.""" model: str - messages: list[Message] + messages: list[ChatCompletionMessage] stream: bool | None = Field(default=False) - user: str | None = Field(default=None) - temperature: float | None = Field(default=0.7) - top_p: float | None = Field(default=1.0) - max_tokens: int | None = Field(default=None) - tools: list[Tool] | None = Field(default=None) - tool_choice: ( - Literal["none"] | Literal["auto"] | Literal["required"] | ToolChoiceFunction | None - ) = Field(default=None) + stream_options: dict[str, Any] | None = Field(default=None) + prompt_cache_key: str | None = Field(default=None) + temperature: float | None = Field(default=1, ge=0, le=2) + top_p: float | None = Field(default=1, ge=0, le=1) + max_completion_tokens: int | None = Field(default=None) + tools: list[ChatCompletionFunctionTool] | None = Field(default=None) + tool_choice: Literal["none", "auto", "required"] | ChatCompletionNamedToolChoice | None = Field( + default=None + ) response_format: dict[str, Any] | None = Field(default=None) + parallel_tool_calls: bool | None = Field(default=True) class ChatCompletionResponse(BaseModel): - """Chat completion response model""" + """Response body for POST /v1/chat/completions.""" id: str object: str = "chat.completion" created: int model: str - choices: list[Choice] - usage: Usage + choices: list[ChatCompletionChoice] + usage: CompletionUsage + system_fingerprint: str | None = Field(default=None) -class ModelListResponse(BaseModel): - """Model list model""" +class ResponseInputText(BaseModel): + """Text content item in a Responses API input message.""" - object: str = "list" - data: list[ModelData] + type: Literal["input_text"] + text: str | None = Field(default=None) -class HealthCheckResponse(BaseModel): - """Health check response model""" +class ResponseInputImage(BaseModel): + """Image content item in a Responses API input message.""" - ok: bool - storage: dict[str, Any] | None = Field(default=None) - clients: dict[str, bool] | None = Field(default=None) - error: str | None = Field(default=None) + type: Literal["input_image"] + detail: Literal["auto", "low", "high"] | None = Field(default=None) + file_id: str | None = Field(default=None) + image_url: str | None = Field(default=None) -class ConversationInStore(BaseModel): - """Conversation model for storing in the database.""" +class ResponseInputFile(BaseModel): + """File content item in a Responses API input message.""" - created_at: datetime | None = Field(default=None) - updated_at: datetime | None = Field(default=None) + type: Literal["input_file"] + file_id: str | None = Field(default=None) + file_url: str | None = Field(default=None) + file_data: str | None = Field(default=None) + filename: str | None = Field(default=None) - # Gemini Web API does not support changing models once a conversation is created. - model: str = Field(..., description="Model used for the conversation") - client_id: str = Field(..., description="Identifier of the Gemini client") - metadata: list[str | None] = Field( - ..., description="Metadata for Gemini API to locate the conversation" - ) - messages: list[Message] = Field(..., description="Message contents in the conversation") +class ResponseInputMessageContentList(BaseModel): + """Normalised content item stored on ``ResponseInputMessage`` server-side. -class ResponseInputContent(BaseModel): - """Content item for Responses API input.""" + Superset of all input content types (text, image, file, reasoning) so they + can be represented in a single model after round-tripping through the server. + """ type: Literal["input_text", "output_text", "reasoning_text", "input_image", "input_file"] text: str | None = Field(default=None) image_url: str | None = Field(default=None) detail: Literal["auto", "low", "high"] | None = Field(default=None) + file_id: str | None = Field(default=None) file_url: str | None = Field(default=None) file_data: str | None = Field(default=None) filename: str | None = Field(default=None) - annotations: list[dict[str, Any]] = Field(default_factory=list) -class ResponseInputItem(BaseModel): - """Single input item for Responses API.""" +class ResponseInputMessage(BaseModel): + """A single conversation turn in a Responses API input list.""" type: Literal["message"] | None = Field(default="message") - role: Literal["user", "assistant", "system", "developer"] - content: str | list[ResponseInputContent] + role: Literal["user", "system", "developer", "assistant"] + content: str | list[ResponseInputText | ResponseInputImage | ResponseInputFile] + status: Literal["in_progress", "completed", "incomplete"] = Field(default="completed") + +class ResponseFunctionToolCall(BaseModel): + """An assistant function-call item replayed as part of the input history.""" -class ResponseToolChoice(BaseModel): - """Tool choice enforcing a specific tool in Responses API.""" + type: Literal["function_call"] | None = Field(default="function_call") + id: str | None = Field(default=None) + call_id: str + name: str + arguments: str + status: Literal["in_progress", "completed", "incomplete"] = Field(default="completed") - type: Literal["function", "image_generation"] - function: ToolChoiceFunctionDetail | None = Field(default=None) +class FunctionCallOutput(BaseModel): + """A tool-result item providing function output back to the model.""" -class ResponseImageTool(BaseModel): - """Image generation tool specification for Responses API.""" + type: Literal["function_call_output"] | None = Field(default="function_call_output") + id: str | None = Field(default=None) + call_id: str + output: str | list[ResponseInputText | ResponseInputImage | ResponseInputFile] + status: Literal["in_progress", "completed", "incomplete"] = Field(default="completed") + + +class FunctionTool(BaseModel): + """A function tool for the Responses API (flat schema).""" + + type: Literal["function"] + name: str + description: str | None = Field(default=None) + parameters: dict[str, Any] | None = Field(default=None) + strict: bool | None = Field(default=None) + + +class ImageGeneration(BaseModel): + """Image-generation built-in tool for the Responses API.""" type: Literal["image_generation"] + action: Literal["generate", "edit", "auto"] = Field(default="auto") model: str | None = Field(default=None) - output_format: str | None = Field(default=None) + output_format: Literal["png", "webp", "jpeg"] = Field(default="png") + quality: Literal["low", "medium", "high", "auto"] = Field(default="auto") + size: str = Field(default="auto") -class ResponseCreateRequest(BaseModel): - """Responses API request payload.""" +class ToolChoiceFunction(BaseModel): + """Forces the model to call a specific named function (Responses API).""" - model: str - input: str | list[ResponseInputItem] - instructions: str | list[ResponseInputItem] | None = Field(default=None) - temperature: float | None = Field(default=0.7) - top_p: float | None = Field(default=1.0) - max_output_tokens: int | None = Field(default=None) - stream: bool | None = Field(default=False) - tool_choice: str | ResponseToolChoice | None = Field(default=None) - tools: list[Tool | ResponseImageTool] | None = Field(default=None) - store: bool | None = Field(default=None) - user: str | None = Field(default=None) - response_format: dict[str, Any] | None = Field(default=None) - metadata: dict[str, Any] | None = Field(default=None) + type: Literal["function"] + name: str + + +class ToolChoiceTypes(BaseModel): + """Forces the model to use a specific built-in tool type.""" + + type: Literal["image_generation"] class ResponseUsage(BaseModel): - """Usage statistics for Responses API.""" + """Token-usage statistics for a Responses API response.""" input_tokens: int output_tokens: int @@ -232,51 +264,73 @@ class ResponseUsage(BaseModel): output_tokens_details: dict[str, Any] = Field(default_factory=lambda: {"reasoning_tokens": 0}) -class ResponseOutputContent(BaseModel): - """Content item for Responses API output.""" +class ResponseOutputText(BaseModel): + """Text content part inside a Responses API output message.""" type: Literal["output_text"] - text: str | None = Field(default="") + text: str | None = Field(default=None) annotations: list[dict[str, Any]] = Field(default_factory=list) logprobs: list[dict[str, Any]] | None = Field(default=None) +class ResponseOutputRefusal(BaseModel): + """Refusal content part inside a Responses API output message.""" + + type: Literal["refusal"] + refusal: str | None = Field(default=None) + + +ResponseOutputContent = ResponseOutputText | ResponseOutputRefusal + + class ResponseOutputMessage(BaseModel): - """Assistant message returned by Responses API.""" + """Assistant message output item in a Responses API response.""" id: str type: Literal["message"] status: Literal["in_progress", "completed", "incomplete"] = Field(default="completed") role: Literal["assistant"] - content: list[ResponseOutputContent] + content: list[ResponseOutputText | ResponseOutputRefusal] -class ResponseSummaryPart(BaseModel): - """Summary part for reasoning.""" +class SummaryTextContent(BaseModel): + """Summary text part inside a reasoning item.""" type: Literal["summary_text"] = Field(default="summary_text") text: str -class ResponseReasoningContentPart(BaseModel): - """Content part for reasoning.""" +class ReasoningTextContent(BaseModel): + """Full reasoning text part inside a reasoning item.""" type: Literal["reasoning_text"] = Field(default="reasoning_text") text: str -class ResponseReasoning(BaseModel): - """Reasoning item returned by Responses API.""" +class ResponseReasoningItem(BaseModel): + """A reasoning output item emitted by a thinking model.""" id: str type: Literal["reasoning"] = Field(default="reasoning") status: Literal["in_progress", "completed", "incomplete"] | None = Field(default=None) - summary: list[ResponseSummaryPart] | None = Field(default=None) - content: list[ResponseReasoningContentPart] | None = Field(default=None) + summary: list[SummaryTextContent] | None = Field(default=None) + content: list[ReasoningTextContent] | None = Field(default=None) + encrypted_content: str | None = Field(default=None) + + +class ResponseToolCall(BaseModel): + """A function-call output item emitted by the model.""" + + id: str + type: Literal["function_call"] = Field(default="function_call") + call_id: str + name: str + arguments: str + status: Literal["in_progress", "completed", "incomplete"] = Field(default="completed") -class ResponseImageGenerationCall(BaseModel): - """Image generation call record emitted in Responses API.""" +class ImageGenerationCall(BaseModel): + """An image-generation output item emitted by the Responses API.""" id: str type: Literal["image_generation_call"] = Field(default="image_generation_call") @@ -287,31 +341,67 @@ class ResponseImageGenerationCall(BaseModel): revised_prompt: str | None = Field(default=None) -class ResponseToolCall(BaseModel): - """Tool call record emitted in Responses API.""" +class ResponseFormatText(BaseModel): + """Plain-text output format.""" + + type: Literal["text"] = Field(default="text") - id: str - type: Literal["tool_call"] = Field(default="tool_call") - status: Literal["in_progress", "completed", "failed", "requires_action"] = Field( - default="completed" - ) - function: FunctionCall +class ResponseFormatTextJSONSchemaConfig(BaseModel): + """JSON-schema-constrained output format.""" -class ResponseTextFormat(BaseModel): - """Text format configuration for Responses API.""" + model_config = {"protected_namespaces": (), "arbitrary_types_allowed": True} - type: Literal["text", "json_schema"] = Field(default="text") + type: Literal["json_schema"] = Field(default="json_schema") + name: str | None = Field(default=None) + schema_: dict[str, Any] | None = Field( + default=None, alias="schema", serialization_alias="schema" + ) + description: str | None = Field(default=None) class ResponseTextConfig(BaseModel): - """Text configuration for Responses API.""" + """Top-level text configuration block in a Responses API response.""" + + format: ResponseFormatText | ResponseFormatTextJSONSchemaConfig = Field( + default_factory=ResponseFormatText + ) - format: ResponseTextFormat = Field(default_factory=ResponseTextFormat) + +class ResponseCreateRequest(BaseModel): + """Request body for POST /v1/responses.""" + + model: str + input: ( + str + | list[ + ResponseInputMessage + | ResponseOutputMessage + | ResponseReasoningItem + | ResponseFunctionToolCall + | FunctionCallOutput + | ImageGenerationCall + ] + ) + instructions: str | None = Field(default=None) + temperature: float | None = Field(default=1, ge=0, le=2) + top_p: float | None = Field(default=1, ge=0, le=1) + max_output_tokens: int | None = Field(default=None) + stream: bool | None = Field(default=False) + stream_options: dict[str, Any] | None = Field(default=None) + tool_choice: ( + Literal["none", "auto", "required"] | ToolChoiceFunction | ToolChoiceTypes | None + ) = Field(default=None) + tools: list[FunctionTool | ImageGeneration] | None = Field(default=None) + store: bool | None = Field(default=None) + prompt_cache_key: str | None = Field(default=None) + response_format: dict[str, Any] | None = Field(default=None) + metadata: dict[str, Any] | None = Field(default=None) + parallel_tool_calls: bool | None = Field(default=True) class ResponseCreateResponse(BaseModel): - """Responses API response payload.""" + """Response body for POST /v1/responses.""" id: str object: Literal["response"] = Field(default="response") @@ -319,26 +409,64 @@ class ResponseCreateResponse(BaseModel): completed_at: int | None = Field(default=None) model: str output: list[ - ResponseReasoning | ResponseOutputMessage | ResponseImageGenerationCall | ResponseToolCall + ResponseReasoningItem + | ResponseOutputMessage + | ResponseFunctionToolCall + | ImageGenerationCall ] - status: Literal[ - "in_progress", - "completed", - "failed", - "incomplete", - "cancelled", - "requires_action", - ] = Field(default="completed") - tool_choice: str | ToolChoiceFunction | ResponseToolChoice = Field(default="auto") - tools: list[Tool | ResponseImageTool] = Field(default_factory=list) + status: Literal["completed", "failed", "in_progress", "cancelled", "queued", "incomplete"] = ( + Field(default="completed") + ) + tool_choice: ( + Literal["none", "auto", "required"] | ToolChoiceFunction | ToolChoiceTypes | None + ) = Field(default=None) + tools: list[FunctionTool | ImageGeneration] = Field(default_factory=list) usage: ResponseUsage | None = Field(default=None) error: dict[str, Any] | None = Field(default=None) metadata: dict[str, Any] = Field(default_factory=dict) - input: str | list[ResponseInputItem] | None = Field(default=None) text: ResponseTextConfig | None = Field(default_factory=ResponseTextConfig) -# Rebuild models with forward references -Message.model_rebuild() -ToolCall.model_rebuild() +class ModelData(BaseModel): + """Single model entry in the model list.""" + + id: str + object: str = "model" + created: int + owned_by: str = "google" + + +class ModelListResponse(BaseModel): + """Response body for GET /v1/models.""" + + object: str = "list" + data: list[ModelData] + + +class HealthCheckResponse(BaseModel): + """Response body for the health check endpoint.""" + + ok: bool + storage: dict[str, Any] | None = Field(default=None) + clients: dict[str, bool] | None = Field(default=None) + error: str | None = Field(default=None) + + +class ConversationInStore(BaseModel): + """Persisted conversation record stored in LMDB.""" + + created_at: datetime | None = Field(default=None) + updated_at: datetime | None = Field(default=None) + model: str = Field(..., description="Model used for the conversation") + client_id: str = Field(..., description="Identifier of the Gemini client") + metadata: list[str | None] = Field( + ..., description="Metadata for Gemini API to locate the conversation" + ) + messages: list[ChatCompletionMessage] = Field( + ..., description="Message contents in the conversation" + ) + + +ChatCompletionMessage.model_rebuild() +ChatCompletionMessageToolCall.model_rebuild() ChatCompletionRequest.model_rebuild() diff --git a/app/server/chat.py b/app/server/chat.py index 8d8be91..0386bd3 100644 --- a/app/server/chat.py +++ b/app/server/chat.py @@ -7,7 +7,7 @@ from dataclasses import dataclass from datetime import UTC, datetime from pathlib import Path -from typing import Any +from typing import Any, Literal import orjson from fastapi import APIRouter, Depends, HTTPException, Request, status @@ -19,32 +19,38 @@ from loguru import logger from app.models import ( + ChatCompletionChoice, + ChatCompletionContentItem, + ChatCompletionFunctionTool, + ChatCompletionMessage, + ChatCompletionMessageToolCall, + ChatCompletionNamedToolChoice, ChatCompletionRequest, + ChatCompletionRequestContentItem, ChatCompletionResponse, - Choice, - ContentItem, + CompletionUsage, ConversationInStore, - Message, + FunctionCall, + FunctionCallOutput, + FunctionTool, + ImageGeneration, + ImageGenerationCall, ModelData, ModelListResponse, ResponseCreateRequest, ResponseCreateResponse, - ResponseImageGenerationCall, - ResponseImageTool, - ResponseInputContent, - ResponseInputItem, + ResponseFormatTextJSONSchemaConfig, + ResponseFunctionToolCall, + ResponseInputMessage, ResponseOutputContent, ResponseOutputMessage, - ResponseReasoning, - ResponseSummaryPart, + ResponseOutputText, + ResponseReasoningItem, ResponseTextConfig, - ResponseToolCall, - ResponseToolChoice, ResponseUsage, - Tool, - ToolCall, + SummaryTextContent, ToolChoiceFunction, - Usage, + ToolChoiceTypes, ) from app.server.middleware import ( get_image_store_dir, @@ -124,7 +130,7 @@ async def _image_to_base64( def _calculate_usage( - messages: list[Message], + messages: list[ChatCompletionMessage], assistant_text: str | None, tool_calls: list[Any] | None, thoughts: str | None = None, @@ -162,11 +168,10 @@ def _create_responses_standard_payload( created_time: int, model_name: str, detected_tool_calls: list[Any] | None, - image_call_items: list[ResponseImageGenerationCall], + image_call_items: list[ImageGenerationCall], response_contents: list[ResponseOutputContent], usage: ResponseUsage, request: ResponseCreateRequest, - normalized_input: Any, full_thoughts: str | None = None, ) -> ResponseCreateResponse: """Unified factory for building ResponseCreateResponse objects.""" @@ -177,11 +182,11 @@ def _create_responses_standard_payload( output_items: list[Any] = [] if full_thoughts: output_items.append( - ResponseReasoning( + ResponseReasoningItem( id=reason_id, type="reasoning", status="completed", - summary=[ResponseSummaryPart(type="summary_text", text=full_thoughts)], + summary=[SummaryTextContent(type="summary_text", text=full_thoughts)], ) ) @@ -198,11 +203,20 @@ def _create_responses_standard_payload( if detected_tool_calls: output_items.extend( [ - ResponseToolCall( + ResponseFunctionToolCall( id=call.id if hasattr(call, "id") else call["id"], - type="tool_call", + call_id=call.id if hasattr(call, "id") else call["id"], + name=( + call.function.name + if hasattr(call, "function") + else call["function"]["name"] + ), + arguments=( + call.function.arguments + if hasattr(call, "function") + else call["function"]["arguments"] + ), status="completed", - function=call.function if hasattr(call, "function") else call["function"], ) for call in detected_tool_calls ] @@ -212,7 +226,7 @@ def _create_responses_standard_payload( text_config = ResponseTextConfig() if request.response_format and request.response_format.get("type") == "json_schema": - text_config.format.type = "json_schema" + text_config.format = ResponseFormatTextJSONSchemaConfig() return ResponseCreateResponse( id=response_id, @@ -223,10 +237,9 @@ def _create_responses_standard_payload( output=output_items, status="completed", usage=usage, - input=normalized_input or None, metadata=request.metadata or {}, tools=request.tools or [], - tool_choice=request.tool_choice or "auto", + tool_choice=request.tool_choice if request.tool_choice is not None else "auto", text=text_config, ) @@ -237,7 +250,7 @@ def _create_chat_completion_standard_payload( model_name: str, visible_output: str | None, tool_calls_payload: list[dict] | None, - finish_reason: str, + finish_reason: Literal["stop", "length", "tool_calls", "content_filter"], usage: dict, reasoning_content: str | None = None, ) -> ChatCompletionResponse: @@ -245,9 +258,9 @@ def _create_chat_completion_standard_payload( # Convert tool calls to Model objects if they are dicts tool_calls = None if tool_calls_payload: - tool_calls = [ToolCall.model_validate(tc) for tc in tool_calls_payload] + tool_calls = [ChatCompletionMessageToolCall.model_validate(tc) for tc in tool_calls_payload] - message = Message( + message = ChatCompletionMessage( role="assistant", content=visible_output or None, tool_calls=tool_calls, @@ -260,13 +273,13 @@ def _create_chat_completion_standard_payload( created=created_time, model=model_name, choices=[ - Choice( + ChatCompletionChoice( index=0, message=message, finish_reason=finish_reason, ) ], - usage=Usage(**usage), + usage=CompletionUsage(**usage), ) @@ -313,14 +326,14 @@ def _persist_conversation( model_name: str, client_id: str, metadata: list[str | None], - messages: list[Message], + messages: list[ChatCompletionMessage], storage_output: str | None, tool_calls: list[Any] | None, thoughts: str | None = None, ) -> str | None: """Unified logic to save conversation history to LMDB.""" try: - current_assistant_message = Message( + current_assistant_message = ChatCompletionMessage( role="assistant", content=storage_output or None, tool_calls=tool_calls or None, @@ -397,8 +410,14 @@ def _build_structured_requirement( def _build_tool_prompt( - tools: list[Tool], - tool_choice: str | ToolChoiceFunction | None, + tools: list[ChatCompletionFunctionTool], + tool_choice: ( + Literal["none", "auto", "required"] + | ChatCompletionNamedToolChoice + | ToolChoiceFunction + | ToolChoiceTypes + | None + ), ) -> str: """Generate a system prompt describing available tools and the PascalCase protocol.""" if not tools: @@ -429,7 +448,7 @@ def _build_tool_prompt( lines.append( "You must call at least one tool before responding to the user. Do not provide a final user-facing answer until a tool call has been issued." ) - elif isinstance(tool_choice, ToolChoiceFunction): + elif isinstance(tool_choice, ChatCompletionNamedToolChoice): target = tool_choice.function.name lines.append( f"You are required to call the tool named `{target}`. Do not call any other tool." @@ -441,8 +460,8 @@ def _build_tool_prompt( def _build_image_generation_instruction( - tools: list[ResponseImageTool] | None, - tool_choice: ResponseToolChoice | None, + tools: list[ImageGeneration] | None, + tool_choice: ToolChoiceFunction | None, ) -> str | None: """Construct explicit guidance so Gemini emits images when requested.""" has_forced_choice = tool_choice is not None and tool_choice.type == "image_generation" @@ -467,7 +486,7 @@ def _build_image_generation_instruction( return "\n\n".join(instructions) -def _append_tool_hint_to_last_user_message(messages: list[Message]) -> None: +def _append_tool_hint_to_last_user_message(messages: list[ChatCompletionMessage]) -> None: """Ensure the last user message carries the tool wrap hint.""" for msg in reversed(messages): if msg.role != "user" or msg.content is None: @@ -482,24 +501,28 @@ def _append_tool_hint_to_last_user_message(messages: list[Message]) -> None: for part in reversed(msg.content): if getattr(part, "type", None) != "text": continue - text_value = part.text or "" + text_value = getattr(part, "text", "") or "" if TOOL_HINT_STRIPPED in text_value: return part.text = f"{text_value}\n{TOOL_WRAP_HINT}" return messages_text = TOOL_WRAP_HINT.strip() - msg.content.append(ContentItem(type="text", text=messages_text)) + msg.content.append(ChatCompletionRequestContentItem(type="text", text=messages_text)) return def _prepare_messages_for_model( - source_messages: list[Message], - tools: list[Tool] | None, - tool_choice: str | ToolChoiceFunction | None, + source_messages: list[ChatCompletionMessage], + tools: list[ChatCompletionFunctionTool] | None, + tool_choice: Literal["none", "auto", "required"] + | ChatCompletionNamedToolChoice + | ToolChoiceFunction + | ToolChoiceTypes + | None, extra_instructions: list[str] | None = None, inject_system_defaults: bool = True, -) -> list[Message]: +) -> list[ChatCompletionMessage]: """Return a copy of messages enriched with tool instructions when needed.""" prepared = [msg.model_copy(deep=True) for msg in source_messages] @@ -541,7 +564,7 @@ def _prepare_messages_for_model( separator = "\n\n" if existing else "" prepared[0].content = f"{existing}{separator}{combined_instructions}" else: - prepared.insert(0, Message(role="system", content=combined_instructions)) + prepared.insert(0, ChatCompletionMessage(role="system", content=combined_instructions)) if tools and tool_choice != "none" and not tool_prompt_injected: _append_tool_hint_to_last_user_message(prepared) @@ -550,92 +573,134 @@ def _prepare_messages_for_model( def _response_items_to_messages( - items: str | list[ResponseInputItem], -) -> tuple[list[Message], str | list[ResponseInputItem]]: - """Convert Responses API input items into internal Message objects and normalized input.""" - messages: list[Message] = [] + items: Any, +) -> list[ChatCompletionMessage]: + """Convert Responses API input items into internal Message objects.""" + messages: list[ChatCompletionMessage] = [] if isinstance(items, str): - messages.append(Message(role="user", content=items)) + messages.append(ChatCompletionMessage(role="user", content=items)) logger.debug("Normalized Responses input: single string message.") - return messages, items + return messages - normalized_input: list[ResponseInputItem] = [] for item in items: - role = item.role - content = item.content - normalized_contents: list[ResponseInputContent] = [] - if isinstance(content, str): - normalized_contents.append(ResponseInputContent(type="input_text", text=content)) - messages.append(Message(role=role, content=content)) - else: - converted: list[ContentItem] = [] - reasoning_parts: list[str] = [] - for part in content: - if part.type in ("input_text", "output_text"): - text_value = part.text or "" - normalized_contents.append( - ResponseInputContent(type=part.type, text=text_value) - ) - if text_value: - converted.append(ContentItem(type="text", text=text_value)) - elif part.type == "reasoning_text": - text_value = part.text or "" - normalized_contents.append( - ResponseInputContent(type="reasoning_text", text=text_value) - ) - if text_value: - reasoning_parts.append(text_value) - elif part.type == "input_image": - image_url = part.image_url - if image_url: - normalized_contents.append( - ResponseInputContent( - type="input_image", - image_url=image_url, - detail=part.detail if part.detail else "auto", + if isinstance(item, (ResponseInputMessage, ResponseOutputMessage)): + role = item.role + content = item.content + if isinstance(content, str): + messages.append(ChatCompletionMessage(role=role, content=content)) + else: + converted: list[ChatCompletionContentItem] = [] + reasoning_parts: list[str] = [] + for part in content: + if part.type in ("input_text", "output_text"): + text_value = getattr(part, "text", "") or "" + if text_value: + converted.append( + ChatCompletionRequestContentItem(type="text", text=text_value) ) - ) - converted.append( - ContentItem( - type="image_url", - image_url={ - "url": image_url, - "detail": part.detail if part.detail else "auto", - }, + elif part.type == "reasoning_text": + text_value = getattr(part, "text", "") or "" + if text_value: + reasoning_parts.append(text_value) + elif part.type == "input_image": + image_url = getattr(part, "image_url", None) + if image_url: + converted.append( + ChatCompletionRequestContentItem( + type="image_url", + image_url={ + "url": image_url, + "detail": getattr(part, "detail", "auto") or "auto", + }, + ) ) + elif part.type == "input_file": + file_url = getattr(part, "file_url", None) + file_data = getattr(part, "file_data", None) + if file_url or file_data: + file_info = {} + if file_data: + file_info["file_data"] = file_data + file_info["filename"] = getattr(part, "filename", None) + if file_url: + file_info["url"] = file_url + converted.append( + ChatCompletionRequestContentItem(type="file", file=file_info) + ) + reasoning_val = "\n\n".join(reasoning_parts) if reasoning_parts else None + messages.append( + ChatCompletionMessage( + role=role, + content=converted or None, + reasoning_content=reasoning_val, + ) + ) + + elif isinstance(item, ResponseFunctionToolCall): + messages.append( + ChatCompletionMessage( + role="assistant", + tool_calls=[ + ChatCompletionMessageToolCall( + id=item.call_id, + type="function", + function=FunctionCall(name=item.name, arguments=item.arguments), ) - elif part.type == "input_file": - if part.file_url or part.file_data: - normalized_contents.append(part) - file_info = {} - if part.file_data: - file_info["file_data"] = part.file_data - file_info["filename"] = part.filename - if part.file_url: - file_info["url"] = part.file_url - converted.append(ContentItem(type="file", file=file_info)) - messages.append(Message(role=role, content=converted or None)) - - normalized_input.append( - ResponseInputItem(type="message", role=item.role, content=normalized_contents or []) - ) + ], + ) + ) + elif isinstance(item, FunctionCallOutput): + output_content = str(item.output) if isinstance(item.output, list) else item.output + messages.append( + ChatCompletionMessage( + role="tool", + tool_call_id=item.call_id, + content=output_content, + ) + ) + elif isinstance(item, ResponseReasoningItem): + reasoning_val = None + if item.content: + reasoning_val = "\n\n".join(x.text for x in item.content if x.text) + messages.append( + ChatCompletionMessage( + role="assistant", + reasoning_content=reasoning_val, + ) + ) + elif isinstance(item, ImageGenerationCall): + messages.append( + ChatCompletionMessage( + role="assistant", + content=item.result or None, + ) + ) - logger.debug(f"Normalized Responses input: {len(normalized_input)} message items.") - return messages, normalized_input + else: + if hasattr(item, "role"): + messages.append( + ChatCompletionMessage( + role=item.role, + content=str(getattr(item, "content", "")), + ) + ) + + logger.debug(f"Normalized Responses input: {len(messages)} message items.") + return messages def _instructions_to_messages( - instructions: str | list[ResponseInputItem] | None, -) -> list[Message]: + instructions: str | list[ResponseInputMessage] | None, +) -> list[ChatCompletionMessage]: """Normalize instructions payload into Message objects.""" if not instructions: return [] if isinstance(instructions, str): - return [Message(role="system", content=instructions)] + return [ChatCompletionMessage(role="system", content=instructions)] - instruction_messages: list[Message] = [] + instruction_messages: list[ChatCompletionMessage] = [] for item in instructions: if item.type and item.type != "message": continue @@ -643,42 +708,48 @@ def _instructions_to_messages( role = item.role content = item.content if isinstance(content, str): - instruction_messages.append(Message(role=role, content=content)) + instruction_messages.append(ChatCompletionMessage(role=role, content=content)) else: - converted: list[ContentItem] = [] + converted: list[ChatCompletionContentItem] = [] reasoning_parts: list[str] = [] for part in content: if part.type in ("input_text", "output_text"): - text_value = part.text or "" + text_value = getattr(part, "text", "") or "" if text_value: - converted.append(ContentItem(type="text", text=text_value)) + converted.append( + ChatCompletionRequestContentItem(type="text", text=text_value) + ) elif part.type == "reasoning_text": - text_value = part.text or "" + text_value = getattr(part, "text", "") or "" if text_value: reasoning_parts.append(text_value) elif part.type == "input_image": - image_url = part.image_url + image_url = getattr(part, "image_url", None) if image_url: converted.append( - ContentItem( + ChatCompletionRequestContentItem( type="image_url", image_url={ "url": image_url, - "detail": part.detail if part.detail else "auto", + "detail": getattr(part, "detail", "auto") or "auto", }, ) ) elif part.type == "input_file": - file_info = {} - if part.file_data: - file_info["file_data"] = part.file_data - file_info["filename"] = part.filename - if part.file_url: - file_info["url"] = part.file_url - if file_info: - converted.append(ContentItem(type="file", file=file_info)) + file_data = getattr(part, "file_data", None) + file_url = getattr(part, "file_url", None) + if file_data or file_url: + file_info = {} + if file_data: + file_info["file_data"] = file_data + file_info["filename"] = getattr(part, "filename", None) + if file_url: + file_info["url"] = file_url + converted.append( + ChatCompletionRequestContentItem(type="file", file=file_info) + ) instruction_messages.append( - Message( + ChatCompletionMessage( role=role, content=converted or None, reasoning_content="\n".join(reasoning_parts) if reasoning_parts else None, @@ -712,7 +783,7 @@ def _get_available_models() -> list[ModelData]: for m in custom_models: models_data.append( ModelData( - id=m.model_name, + id=m.model_name or "", created=now, owned_by="custom", ) @@ -742,8 +813,8 @@ async def _find_reusable_session( db: LMDBConversationStore, pool: GeminiClientPool, model: Model, - messages: list[Message], -) -> tuple[ChatSession | None, GeminiClientWrapper | None, list[Message]]: + messages: list[ChatCompletionMessage], +) -> tuple[ChatSession | None, GeminiClientWrapper | None, list[ChatCompletionMessage]]: """Find an existing chat session matching the longest suitable history prefix.""" if len(messages) < 2: return None, None, messages @@ -786,7 +857,7 @@ async def _find_reusable_session( async def _send_with_split( session: ChatSession, text: str, - files: list[Path | str | io.BytesIO] | None = None, + files: list[str | Path | bytes | io.BytesIO] | None = None, stream: bool = False, ) -> AsyncGenerator[ModelOutput] | ModelOutput: """Send text to Gemini, splitting or converting to attachment if too long.""" @@ -805,7 +876,7 @@ async def _send_with_split( file_obj = io.BytesIO(text.encode("utf-8")) file_obj.name = "message.txt" try: - final_files = list(files) if files else [] + final_files: list[str | Path | bytes | io.BytesIO] = list(files) if files else [] final_files.append(file_obj) instruction = ( "The user's input exceeds the character limit and is provided in the attached file `message.txt`.\n\n" @@ -874,7 +945,7 @@ def process(self, chunk: str) -> str: if self._is_outputting(): output.append(pre_text) - if matched_group.endswith("_START"): + if matched_group and matched_group.endswith("_START"): m_type = matched_group.split("_")[0] if m_type == "TAG": self.stack.append("IN_TAG_HEADER") @@ -916,7 +987,7 @@ def _create_real_streaming_response( completion_id: str, created_time: int, model_name: str, - messages: list[Message], + messages: list[ChatCompletionMessage], db: LMDBConversationStore, model: Model, client_wrapper: GeminiClientWrapper, @@ -1070,7 +1141,7 @@ async def generate_stream(): p_tok, c_tok, t_tok, r_tok = _calculate_usage( messages, assistant_text, tool_calls, full_thoughts ) - usage = Usage( + usage = CompletionUsage( prompt_tokens=p_tok, completion_tokens=c_tok, total_tokens=t_tok, @@ -1107,7 +1178,7 @@ def _create_responses_real_streaming_response( response_id: str, created_time: int, model_name: str, - messages: list[Message], + messages: list[ChatCompletionMessage], db: LMDBConversationStore, model: Model, client_wrapper: GeminiClientWrapper, @@ -1197,7 +1268,7 @@ def make_event(etype: str, data: dict) -> str: **base_event, "type": "response.output_item.added", "output_index": current_index, - "item": ResponseReasoning( + "item": ResponseReasoningItem( id=thought_item_id, type="reasoning", status="in_progress", @@ -1214,7 +1285,7 @@ def make_event(etype: str, data: dict) -> str: "item_id": thought_item_id, "output_index": current_index, "summary_index": 0, - "part": ResponseSummaryPart(text="").model_dump(mode="json"), + "part": SummaryTextContent(text="").model_dump(mode="json"), }, ) thought_open = True @@ -1253,7 +1324,7 @@ def make_event(etype: str, data: dict) -> str: "item_id": thought_item_id, "output_index": current_index, "summary_index": 0, - "part": ResponseSummaryPart(text=full_thoughts).model_dump( + "part": SummaryTextContent(text=full_thoughts).model_dump( mode="json" ), }, @@ -1264,11 +1335,11 @@ def make_event(etype: str, data: dict) -> str: **base_event, "type": "response.output_item.done", "output_index": current_index, - "item": ResponseReasoning( + "item": ResponseReasoningItem( id=thought_item_id, type="reasoning", status="completed", - summary=[ResponseSummaryPart(text=full_thoughts)], + summary=[SummaryTextContent(text=full_thoughts)], ).model_dump(mode="json"), }, ) @@ -1300,9 +1371,9 @@ def make_event(etype: str, data: dict) -> str: "item_id": message_item_id, "output_index": current_index, "content_index": 0, - "part": ResponseOutputContent( - type="output_text", text="" - ).model_dump(mode="json"), + "part": ResponseOutputText(type="output_text", text="").model_dump( + mode="json" + ), }, ) message_open = True @@ -1372,7 +1443,7 @@ def make_event(etype: str, data: dict) -> str: "item_id": thought_item_id, "output_index": current_index, "summary_index": 0, - "part": ResponseSummaryPart(text=full_thoughts).model_dump(mode="json"), + "part": SummaryTextContent(text=full_thoughts).model_dump(mode="json"), }, ) yield make_event( @@ -1381,11 +1452,11 @@ def make_event(etype: str, data: dict) -> str: **base_event, "type": "response.output_item.done", "output_index": current_index, - "item": ResponseReasoning( + "item": ResponseReasoningItem( id=thought_item_id, type="reasoning", status="completed", - summary=[ResponseSummaryPart(text=full_thoughts)], + summary=[SummaryTextContent(text=full_thoughts)], ).model_dump(mode="json"), }, ) @@ -1414,9 +1485,9 @@ def make_event(etype: str, data: dict) -> str: "item_id": message_item_id, "output_index": current_index, "content_index": 0, - "part": ResponseOutputContent( - type="output_text", text=assistant_text - ).model_dump(mode="json"), + "part": ResponseOutputText(type="output_text", text=assistant_text).model_dump( + mode="json" + ), }, ) yield make_event( @@ -1430,13 +1501,13 @@ def make_event(etype: str, data: dict) -> str: type="message", status="completed", role="assistant", - content=[ResponseOutputContent(type="output_text", text=assistant_text)], + content=[ResponseOutputText(type="output_text", text=assistant_text)], ).model_dump(mode="json"), }, ) current_index += 1 - image_items: list[ResponseImageGenerationCall] = [] + image_items: list[ImageGenerationCall] = [] final_response_contents: list[ResponseOutputContent] = [] seen_hashes = set() @@ -1453,7 +1524,7 @@ def make_event(etype: str, data: dict) -> str: img_id = parts[0] fmt = parts[1] if len(parts) > 1 else "png" - img_item = ResponseImageGenerationCall( + img_item = ImageGenerationCall( id=img_id, result=b64, output_format=fmt, @@ -1464,7 +1535,7 @@ def make_event(etype: str, data: dict) -> str: f"![{fname}]({base_url}images/{fname}?token={get_image_token(fname)})" ) final_response_contents.append( - ResponseOutputContent(type="output_text", text=image_url) + ResponseOutputText(type="output_text", text=image_url) ) yield make_event( @@ -1493,7 +1564,13 @@ def make_event(etype: str, data: dict) -> str: logger.warning("Image processing failed in stream") for call in detected_tool_calls: - tc_item = ResponseToolCall(id=call.id, status="completed", function=call.function) + tc_item = ResponseFunctionToolCall( + id=call.id, + call_id=call.id, + name=call.function.name, + arguments=call.function.arguments, + status="completed", + ) yield make_event( "response.output_item.added", { @@ -1516,7 +1593,7 @@ def make_event(etype: str, data: dict) -> str: if assistant_text: final_response_contents.insert( - 0, ResponseOutputContent(type="output_text", text=assistant_text) + 0, ResponseOutputText(type="output_text", text=assistant_text) ) p_tok, c_tok, t_tok, r_tok = _calculate_usage( @@ -1537,7 +1614,6 @@ def make_event(etype: str, data: dict) -> str: final_response_contents, usage, request, - None, full_thoughts, ) _persist_conversation( @@ -1643,13 +1719,15 @@ async def create_chat_completion( f"Client ID: {client.id}, Input length: {len(m_input)}, files count: {len(files)}" ) resp_or_stream = await _send_with_split( - session, m_input, files=files, stream=request.stream + session, m_input, files=files, stream=bool(request.stream) ) except Exception as e: logger.exception("Gemini API error") raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail=str(e)) from e if request.stream: + # Narrow for type checker + assert not isinstance(resp_or_stream, ModelOutput) return _create_real_streaming_response( resp_or_stream, completion_id, @@ -1664,6 +1742,9 @@ async def create_chat_completion( structured_requirement, ) + # Narrow for type checker + assert isinstance(resp_or_stream, ModelOutput) + try: thoughts = resp_or_stream.thoughts raw_clean = GeminiClientWrapper.extract_output(resp_or_stream, include_thoughts=False) @@ -1743,33 +1824,35 @@ async def create_response( image_store: Path = Depends(get_image_store_dir), ): base_url = str(raw_request.base_url) - base_messages, norm_input = _response_items_to_messages(request.input) + base_messages = _response_items_to_messages(request.input) struct_req = _build_structured_requirement(request.response_format) extra_instr = [struct_req.instruction] if struct_req else [] standard_tools, image_tools = [], [] if request.tools: for t in request.tools: - if isinstance(t, Tool): + if isinstance(t, FunctionTool): standard_tools.append(t) - elif isinstance(t, ResponseImageTool): + elif isinstance(t, ImageGeneration): image_tools.append(t) elif isinstance(t, dict): if t.get("type") == "function": - standard_tools.append(Tool.model_validate(t)) + standard_tools.append(FunctionTool.model_validate(t)) elif t.get("type") == "image_generation": - image_tools.append(ResponseImageTool.model_validate(t)) + image_tools.append(ImageGeneration.model_validate(t)) img_instr = _build_image_generation_instruction( image_tools, - request.tool_choice if isinstance(request.tool_choice, ResponseToolChoice) else None, + request.tool_choice if isinstance(request.tool_choice, ToolChoiceFunction) else None, ) if img_instr: extra_instr.append(img_instr) preface = _instructions_to_messages(request.instructions) conv_messages = [*preface, *base_messages] if preface else base_messages model_tool_choice = ( - request.tool_choice if isinstance(request.tool_choice, (str, ToolChoiceFunction)) else None + request.tool_choice + if isinstance(request.tool_choice, (str, ChatCompletionNamedToolChoice)) + else None ) messages = _prepare_messages_for_model( @@ -1788,7 +1871,7 @@ async def create_response( if session: msgs = _prepare_messages_for_model( remain, - request.tools, + request.tools, # type: ignore request.tool_choice, None, False, @@ -1819,13 +1902,15 @@ async def create_response( f"Client ID: {client.id}, Input length: {len(m_input)}, files count: {len(files)}" ) resp_or_stream = await _send_with_split( - session, m_input, files=files, stream=request.stream + session, m_input, files=files, stream=bool(request.stream) ) except Exception as e: logger.exception("Gemini API error") raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail=str(e)) from e if request.stream: + # Narrow for type checker + assert not isinstance(resp_or_stream, ModelOutput) return _create_responses_real_streaming_response( resp_or_stream, response_id, @@ -1842,6 +1927,9 @@ async def create_response( struct_req, ) + # Narrow for type checker + assert isinstance(resp_or_stream, ModelOutput) + try: thoughts = resp_or_stream.thoughts raw_clean = GeminiClientWrapper.extract_output(resp_or_stream, include_thoughts=False) @@ -1856,7 +1944,9 @@ async def create_response( ) images = resp_or_stream.images or [] if ( - request.tool_choice is not None and request.tool_choice.type == "image_generation" + request.tool_choice is not None + and hasattr(request.tool_choice, "type") + and request.tool_choice.type == "image_generation" ) and not images: raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail="No images returned.") @@ -1879,13 +1969,13 @@ async def create_response( ) contents.append( - ResponseOutputContent( + ResponseOutputText( type="output_text", text=f"![{fname}]({base_url}images/{fname}?token={get_image_token(fname)})", ) ) img_calls.append( - ResponseImageGenerationCall( + ImageGenerationCall( id=img_id, result=b64, output_format=img_format, @@ -1896,9 +1986,9 @@ async def create_response( logger.warning(f"Image error: {e}") if assistant_text: - contents.append(ResponseOutputContent(type="output_text", text=assistant_text)) + contents.append(ResponseOutputText(type="output_text", text=assistant_text)) if not contents: - contents.append(ResponseOutputContent(type="output_text", text="")) + contents.append(ResponseOutputText(type="output_text", text="")) # Aggregate images for storage image_markdown = "" @@ -1926,7 +2016,6 @@ async def create_response( contents, usage, request, - norm_input, thoughts, ) _persist_conversation( diff --git a/app/server/middleware.py b/app/server/middleware.py index 2fa016b..457ac0f 100644 --- a/app/server/middleware.py +++ b/app/server/middleware.py @@ -113,7 +113,7 @@ def add_cors_middleware(app: FastAPI): if g_config.cors.enabled: cors = g_config.cors app.add_middleware( - CORSMiddleware, + CORSMiddleware, # type: ignore allow_origins=cors.allow_origins, allow_credentials=cors.allow_credentials, allow_methods=cors.allow_methods, diff --git a/app/services/client.py b/app/services/client.py index b8f976b..77dfdf6 100644 --- a/app/services/client.py +++ b/app/services/client.py @@ -1,3 +1,4 @@ +import io from pathlib import Path from typing import Any, cast @@ -5,7 +6,7 @@ from gemini_webapi import GeminiClient, ModelOutput from loguru import logger -from app.models import Message +from app.models import ChatCompletionMessage from app.utils import g_config from app.utils.helper import ( add_tag, @@ -28,7 +29,7 @@ def __init__(self, client_id: str, **kwargs): super().__init__(**kwargs) self.id = client_id - async def init( + async def init( # type: ignore self, timeout: float = cast(float, _UNSET), watchdog_timeout: float = cast(float, _UNSET), @@ -68,7 +69,10 @@ def running(self) -> bool: @staticmethod async def process_message( - message: Message, tempdir: Path | None = None, tagged: bool = True, wrap_tool: bool = True + message: ChatCompletionMessage, + tempdir: Path | None = None, + tagged: bool = True, + wrap_tool: bool = True, ) -> tuple[str, list[Path | str]]: """ Process a Message into Gemini API format using the PascalCase technical protocol. @@ -83,22 +87,25 @@ async def process_message( elif isinstance(message.content, list): for item in message.content: if item.type == "text": - if item.text or message.role == "tool": - text_fragments.append(item.text or "") + item_text = getattr(item, "text", "") or "" + if item_text or message.role == "tool": + text_fragments.append(item_text) elif item.type == "image_url": - if not item.image_url: + item_image_url = getattr(item, "image_url", None) + if not item_image_url: raise ValueError("Image URL cannot be empty") - if url := item.image_url.get("url", None): + if url := item_image_url.get("url", None): files.append(await save_url_to_tempfile(url, tempdir)) else: raise ValueError("Image URL must contain 'url' key") elif item.type == "file": - if not item.file: + item_file = getattr(item, "file", None) + if not item_file: raise ValueError("File cannot be empty") - if file_data := item.file.get("file_data", None): - filename = item.file.get("filename", "") + if file_data := item_file.get("file_data", None): + filename = item_file.get("filename", "") files.append(await save_file_to_tempfile(file_data, filename, tempdir)) - elif url := item.file.get("url", None): + elif url := item_file.get("url", None): files.append(await save_url_to_tempfile(url, tempdir)) else: raise ValueError("File must contain 'file_data' or 'url' key") @@ -154,10 +161,10 @@ async def process_message( @staticmethod async def process_conversation( - messages: list[Message], tempdir: Path | None = None - ) -> tuple[str, list[Path | str]]: + messages: list[ChatCompletionMessage], tempdir: Path | None = None + ) -> tuple[str, list[str | Path | bytes | io.BytesIO]]: conversation: list[str] = [] - files: list[Path | str] = [] + files: list[str | Path | bytes | io.BytesIO] = [] i = 0 while i < len(messages): diff --git a/app/services/lmdb.py b/app/services/lmdb.py index 07f0a23..7a915d4 100644 --- a/app/services/lmdb.py +++ b/app/services/lmdb.py @@ -1,5 +1,6 @@ import hashlib import string +from collections.abc import Generator from contextlib import contextmanager from datetime import datetime, timedelta from pathlib import Path @@ -7,9 +8,15 @@ import lmdb import orjson +from lmdb import Environment, Error, Transaction from loguru import logger -from app.models import ContentItem, ConversationInStore, Message +from app.models import ( + ChatCompletionAssistantContentItem, + ChatCompletionMessage, + ChatCompletionRequestContentItem, + ConversationInStore, +) from app.utils import g_config from app.utils.helper import ( extract_tool_calls, @@ -50,7 +57,7 @@ def _normalize_text(text: str | None, fuzzy: bool = False) -> str | None: return text.strip() if text.strip() else None -def _hash_message(message: Message, fuzzy: bool = False) -> str: +def _hash_message(message: ChatCompletionMessage, fuzzy: bool = False) -> str: """ Generate a stable, canonical hash for a single message. """ @@ -69,33 +76,36 @@ def _hash_message(message: Message, fuzzy: bool = False) -> str: elif isinstance(content, str): core_data["content"] = _normalize_text(content, fuzzy=fuzzy) elif isinstance(content, list): + _ContentItem = (ChatCompletionRequestContentItem, ChatCompletionAssistantContentItem) text_parts = [] for item in content: text_val = "" - if isinstance(item, ContentItem) and item.type == "text": - text_val = item.text + if isinstance(item, _ContentItem) and item.type == "text": + text_val = item.text or "" elif isinstance(item, dict) and item.get("type") == "text": - text_val = item.get("text") + text_val = item.get("text") or "" if text_val: normalized_part = _normalize_text(text_val, fuzzy=fuzzy) if normalized_part: text_parts.append(normalized_part) - elif isinstance(item, (ContentItem, dict)): - item_type = item.type if isinstance(item, ContentItem) else item.get("type") + elif isinstance(item, _ContentItem): + item_type = item.type if item_type == "image_url": - url = ( - item.image_url.get("url") - if isinstance(item, ContentItem) and item.image_url - else item.get("image_url", {}).get("url") - ) + item_image_url = getattr(item, "image_url", None) + url = item_image_url.get("url") if item_image_url else None text_parts.append(f"[image_url:{url}]") elif item_type == "file": - url = ( - item.file.get("url") or item.file.get("filename") - if isinstance(item, ContentItem) and item.file - else item.get("file", {}).get("url") or item.get("file", {}).get("filename") - ) + item_file = getattr(item, "file", None) + url = (item_file.get("url") or item_file.get("filename")) if item_file else None + text_parts.append(f"[file:{url}]") + elif isinstance(item, dict): + item_type = item.get("type") + if item_type == "image_url": + url = item.get("image_url", {}).get("url") + text_parts.append(f"[image_url:{url}]") + elif item_type == "file": + url = item.get("file", {}).get("url") or item.get("file", {}).get("filename") text_parts.append(f"[file:{url}]") core_data["content"] = "\n".join(text_parts) if text_parts else None @@ -126,7 +136,7 @@ def _hash_message(message: Message, fuzzy: bool = False) -> str: def _hash_conversation( - client_id: str, model: str, messages: list[Message], fuzzy: bool = False + client_id: str, model: str, messages: list[ChatCompletionMessage], fuzzy: bool = False ) -> str: """Generate a hash for a list of messages and model name, tied to a specific client_id.""" combined_hash = hashlib.sha256() @@ -168,7 +178,7 @@ def __init__( self.db_path: Path = Path(db_path) self.max_db_size: int = max_db_size self.retention_days: int = max(0, int(retention_days)) - self._env: lmdb.Environment | None = None + self._env: Environment | None = None self._ensure_db_path() self._init_environment() @@ -189,12 +199,12 @@ def _init_environment(self) -> None: meminit=False, ) logger.info(f"LMDB environment initialized at {self.db_path}") - except lmdb.Error as e: + except Error as e: logger.error(f"Failed to initialize LMDB environment: {e}") raise @contextmanager - def _get_transaction(self, write: bool = False): + def _get_transaction(self, write: bool = False) -> Generator[Transaction]: """ Context manager for LMDB transactions. @@ -204,12 +214,12 @@ def _get_transaction(self, write: bool = False): if not self._env: raise RuntimeError("LMDB environment not initialized") - txn: lmdb.Transaction = self._env.begin(write=write) + txn: Transaction = self._env.begin(write=write) try: yield txn if write: txn.commit() - except lmdb.Error: + except Error: if write: txn.abort() raise @@ -236,24 +246,22 @@ def _decode_index_value(data: bytes) -> list[str]: except UnicodeDecodeError: return [] - @staticmethod - def _update_index(txn: lmdb.Transaction, prefix: str, hash_val: str, storage_key: str): + def _update_index(self, txn: Transaction, prefix: str, hash_val: str, storage_key: str): """Add a storage key to the index for a given hash, avoiding duplicates.""" idx_key = f"{prefix}{hash_val}".encode() existing = txn.get(idx_key) - keys = LMDBConversationStore._decode_index_value(existing) if existing else [] + keys = self._decode_index_value(existing) if existing else [] if storage_key not in keys: keys.append(storage_key) txn.put(idx_key, orjson.dumps(keys)) - @staticmethod - def _remove_from_index(txn: lmdb.Transaction, prefix: str, hash_val: str, storage_key: str): + def _remove_from_index(self, txn: Transaction, prefix: str, hash_val: str, storage_key: str): """Remove a specific storage key from the index for a given hash.""" idx_key = f"{prefix}{hash_val}".encode() existing = txn.get(idx_key) if not existing: return - keys = LMDBConversationStore._decode_index_value(existing) + keys = self._decode_index_value(existing) if storage_key in keys: keys.remove(storage_key) if keys: @@ -304,7 +312,7 @@ def store( logger.debug(f"Stored {len(conv.messages)} messages with key: {storage_key[:12]}") return storage_key - except lmdb.Error as e: + except Error as e: logger.error(f"LMDB error while storing messages with key {storage_key[:12]}: {e}") raise except Exception as e: @@ -334,14 +342,14 @@ def get(self, key: str) -> ConversationInStore | None: logger.debug(f"Retrieved {len(conv.messages)} messages with key: {key[:12]}") return conv - except (lmdb.Error, orjson.JSONDecodeError) as e: + except (Error, orjson.JSONDecodeError) as e: logger.error(f"Failed to retrieve/parse messages with key {key[:12]}: {e}") return None except Exception as e: logger.error(f"Unexpected error retrieving messages with key {key[:12]}: {e}") return None - def find(self, model: str, messages: list[Message]) -> ConversationInStore | None: + def find(self, model: str, messages: list[ChatCompletionMessage]) -> ConversationInStore | None: """ Search conversation data by message list. Tries raw matching, then sanitized matching, and finally fuzzy matching. @@ -381,7 +389,7 @@ def find(self, model: str, messages: list[Message]) -> ConversationInStore | Non def _find_by_message_list( self, model: str, - messages: list[Message], + messages: list[ChatCompletionMessage], fuzzy: bool = False, ) -> ConversationInStore | None: """ @@ -423,7 +431,7 @@ def _find_by_message_list( if match_found: return conv - except lmdb.Error as e: + except Error as e: logger.error( f"LMDB error while searching for hash {message_hash} and client {c.id}: {e}" ) @@ -438,7 +446,7 @@ def exists(self, key: str) -> bool: try: with self._get_transaction(write=False) as txn: return txn.get(key.encode("utf-8")) is not None - except lmdb.Error as e: + except Error as e: logger.error(f"Failed to check existence of key {key}: {e}") return False @@ -464,7 +472,7 @@ def delete(self, key: str) -> ConversationInStore | None: logger.debug(f"Deleted messages with key: {key[:12]}") return conv - except (lmdb.Error, orjson.JSONDecodeError) as e: + except (Error, orjson.JSONDecodeError) as e: logger.error(f"Failed to delete messages with key {key[:12]}: {e}") return None @@ -490,7 +498,7 @@ def keys(self, prefix: str = "", limit: int | None = None) -> list[str]: count += 1 if limit and count >= limit: break - except lmdb.Error as e: + except Error as e: logger.error(f"Failed to list keys: {e}") return keys @@ -529,7 +537,7 @@ def cleanup_expired(self, retention_days: int | None = None) -> int: if timestamp < cutoff: expired_entries.append((key_str, conv)) - except lmdb.Error as exc: + except Error as exc: logger.error(f"Failed to scan LMDB for retention cleanup: {exc}") raise @@ -552,7 +560,7 @@ def cleanup_expired(self, retention_days: int | None = None) -> int: ) self._remove_from_index(txn, self.FUZZY_LOOKUP_PREFIX, fuzzy_hash, key_str) removed += 1 - except lmdb.Error as exc: + except Error as exc: logger.error(f"Failed to delete expired conversations: {exc}") raise @@ -570,7 +578,7 @@ def stats(self) -> dict[str, Any]: return {} try: return self._env.stat() - except lmdb.Error as e: + except Error as e: logger.error(f"Failed to get database stats: {e}") return {} @@ -586,7 +594,7 @@ def __del__(self): self.close() @staticmethod - def sanitize_messages(messages: list[Message]) -> list[Message]: + def sanitize_messages(messages: list[ChatCompletionMessage]) -> list[ChatCompletionMessage]: """Clean all messages of internal markers, hints and normalize tool calls.""" cleaned_messages = [] for msg in messages: @@ -622,9 +630,30 @@ def sanitize_messages(messages: list[Message]) -> list[Message]: new_content = [] all_extracted_calls = list(msg.tool_calls or []) list_changed = False + reasoning_parts = [] for item in msg.content: - if isinstance(item, ContentItem) and item.type == "text" and item.text: + # Extract reasoning items and move them to reasoning_content + if ( + isinstance(item, ChatCompletionAssistantContentItem) + and item.type == "reasoning" + ): + val = item.text + if val: + norm_val = _normalize_text(val) + if norm_val: + reasoning_parts.append(norm_val) + list_changed = True + continue + + if ( + isinstance( + item, + (ChatCompletionRequestContentItem, ChatCompletionAssistantContentItem), + ) + and item.type == "text" + and item.text + ): text = item.text if msg.role == "assistant" and not msg.tool_calls: text, extracted = extract_tool_calls(text) @@ -639,8 +668,17 @@ def sanitize_messages(messages: list[Message]) -> list[Message]: item = item.model_copy(update={"text": text.strip() or None}) new_content.append(item) + if reasoning_parts: + existing_reason = update_data.get("reasoning_content") or msg.reasoning_content + all_reasoning = "\n\n".join( + r for r in ([existing_reason, *reasoning_parts]) if r + ) + if all_reasoning: + update_data["reasoning_content"] = all_reasoning + content_changed = True + if list_changed: - update_data["content"] = new_content + update_data["content"] = new_content if new_content else None update_data["tool_calls"] = all_extracted_calls or None content_changed = True diff --git a/app/utils/config.py b/app/utils/config.py index fec8eee..9b4f1a3 100644 --- a/app/utils/config.py +++ b/app/utils/config.py @@ -352,7 +352,7 @@ def initialize_config() -> Config: env_models_overrides = extract_gemini_models_env() # Then, initialize Config with pydantic_settings - config = Config() # type: ignore + config = Config() # Synthesize clients config.gemini.clients = _merge_clients_with_env( diff --git a/app/utils/helper.py b/app/utils/helper.py index 187f310..8bc4940 100644 --- a/app/utils/helper.py +++ b/app/utils/helper.py @@ -14,7 +14,7 @@ from curl_cffi.requests import AsyncSession from loguru import logger -from app.models import FunctionCall, Message, ToolCall +from app.models import ChatCompletionMessage, ChatCompletionMessageToolCall, FunctionCall VALID_TAG_ROLES = {"user", "assistant", "system", "tool"} TOOL_WRAP_HINT = ( @@ -278,7 +278,9 @@ def strip_system_hints(text: str) -> str: return cleaned -def _process_tools_internal(text: str, extract: bool = True) -> tuple[str, list[ToolCall]]: +def _process_tools_internal( + text: str, extract: bool = True +) -> tuple[str, list[ChatCompletionMessageToolCall]]: """ Extract tool metadata and return text stripped of technical markers. Arguments are parsed into JSON and assigned deterministic call IDs. @@ -286,7 +288,7 @@ def _process_tools_internal(text: str, extract: bool = True) -> tuple[str, list[ if not text: return text, [] - tool_calls: list[ToolCall] = [] + tool_calls: list[ChatCompletionMessageToolCall] = [] def _create_tool_call(name: str, raw_args: str) -> None: if not extract: @@ -321,7 +323,7 @@ def _create_tool_call(name: str, raw_args: str) -> None: call_id = f"call_{hashlib.sha256(seed).hexdigest()[:24]}" tool_calls.append( - ToolCall( + ChatCompletionMessageToolCall( id=call_id, type="function", function=FunctionCall(name=name, arguments=arguments), @@ -341,12 +343,12 @@ def remove_tool_call_blocks(text: str) -> str: return cleaned -def extract_tool_calls(text: str) -> tuple[str, list[ToolCall]]: +def extract_tool_calls(text: str) -> tuple[str, list[ChatCompletionMessageToolCall]]: """Extract tool calls and return cleaned text.""" return _process_tools_internal(text, extract=True) -def text_from_message(message: Message) -> str: +def text_from_message(message: ChatCompletionMessage) -> str: """Concatenate text and tool arguments from a message for token estimation.""" base_text = "" if isinstance(message.content, str): diff --git a/scripts/dump_lmdb.py b/scripts/dump_lmdb.py index 889af4f..15ce5df 100644 --- a/scripts/dump_lmdb.py +++ b/scripts/dump_lmdb.py @@ -5,6 +5,7 @@ import lmdb import orjson +from lmdb import Transaction def _decode_value(value: bytes) -> Any: @@ -15,7 +16,7 @@ def _decode_value(value: bytes) -> Any: return value.decode("utf-8", errors="replace") -def _dump_all(txn: lmdb.Transaction) -> list[dict[str, Any]]: +def _dump_all(txn: Transaction) -> list[dict[str, Any]]: """Return all records from the database.""" result: list[dict[str, Any]] = [] for key, value in txn.cursor(): @@ -23,7 +24,7 @@ def _dump_all(txn: lmdb.Transaction) -> list[dict[str, Any]]: return result -def _dump_selected(txn: lmdb.Transaction, keys: Iterable[str]) -> list[dict[str, Any]]: +def _dump_selected(txn: Transaction, keys: Iterable[str]) -> list[dict[str, Any]]: """Return records for the provided keys.""" result: list[dict[str, Any]] = [] for key in keys: From 1a9f7de4e61db7a028f63e4356365de572e03a69 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C6=B0u=20Quang=20V=C5=A9?= Date: Tue, 3 Mar 2026 22:03:22 +0700 Subject: [PATCH 171/291] Add type check using `ty` --- .github/workflows/docker.yaml | 13 +++++++----- .github/workflows/lint.yaml | 37 +++++++++++++++++++++++++++++++++++ .github/workflows/ruff.yaml | 30 ---------------------------- .github/workflows/track.yml | 6 ++---- pyproject.toml | 1 + uv.lock | 26 ++++++++++++++++++++++++ 6 files changed, 74 insertions(+), 39 deletions(-) create mode 100644 .github/workflows/lint.yaml delete mode 100644 .github/workflows/ruff.yaml diff --git a/.github/workflows/docker.yaml b/.github/workflows/docker.yaml index 1c5a2ee..6fb71e8 100644 --- a/.github/workflows/docker.yaml +++ b/.github/workflows/docker.yaml @@ -6,11 +6,14 @@ on: - main tags: - "v*" - paths-ignore: - - "**/*.md" - - ".github/*" - - "LICENSE" - - ".gitignore" + paths: + - "app/**" + - "config/**" + - "pyproject.toml" + - "uv.lock" + - "Dockerfile" + - "run.py" + - ".github/workflows/docker.yaml" env: REGISTRY: ghcr.io diff --git a/.github/workflows/lint.yaml b/.github/workflows/lint.yaml new file mode 100644 index 0000000..f513a31 --- /dev/null +++ b/.github/workflows/lint.yaml @@ -0,0 +1,37 @@ +name: Lint and Type Check + +on: + push: + branches: + - main + paths: + - "**.py" + - "pyproject.toml" + - "uv.lock" + - ".github/workflows/lint.yaml" + pull_request: + paths: + - "**.py" + - "pyproject.toml" + - "uv.lock" + - ".github/workflows/lint.yaml" + +jobs: + lint: + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@v6 + + - name: Install uv + uses: astral-sh/setup-uv@v7 + + - name: Install dependencies + run: uv sync --all-groups + + - name: Run Ruff + run: uv run ruff check . + + - name: Run Ty Check + run: uv run ty check diff --git a/.github/workflows/ruff.yaml b/.github/workflows/ruff.yaml deleted file mode 100644 index 5e13127..0000000 --- a/.github/workflows/ruff.yaml +++ /dev/null @@ -1,30 +0,0 @@ -name: Ruff Lint - -on: - push: - branches: - - main - pull_request: - types: - - opened - -jobs: - lint: - runs-on: ubuntu-latest - - steps: - - name: Checkout repository - uses: actions/checkout@v6 - - - name: Set up Python - uses: actions/setup-python@v6 - with: - python-version: "3.13" - - - name: Install Ruff - run: | - python -m pip install --upgrade pip - pip install ruff - - - name: Run Ruff - run: ruff check . diff --git a/.github/workflows/track.yml b/.github/workflows/track.yml index 85b087f..778ef03 100644 --- a/.github/workflows/track.yml +++ b/.github/workflows/track.yml @@ -16,8 +16,6 @@ jobs: - name: Install uv uses: astral-sh/setup-uv@v7 - with: - version: "latest" - name: Update gemini-webapi id: update @@ -33,8 +31,8 @@ jobs: fi echo "Current gemini-webapi version: $OLD_VERSION" - # Update the package using uv, which handles pyproject.toml and uv.lock - uv add --upgrade gemini-webapi + # Update gemini-webapi to the latest version + uv lock --upgrade-package gemini-webapi # Get new version of gemini-webapi after upgrade NEW_VERSION=$(uv pip show gemini-webapi | grep ^Version: | awk '{print $2}') diff --git a/pyproject.toml b/pyproject.toml index 83a1b87..f961758 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -24,6 +24,7 @@ Repository = "https://github.com/Nativu5/Gemini-FastAPI" dev = [ "pytest>=9.0.2", "ruff>=0.15.4", + "ty>=0.0.20", ] [dependency-groups] diff --git a/uv.lock b/uv.lock index fd1a7df..f6415bd 100644 --- a/uv.lock +++ b/uv.lock @@ -145,6 +145,7 @@ dependencies = [ dev = [ { name = "pytest" }, { name = "ruff" }, + { name = "ty" }, ] [package.dev-dependencies] @@ -164,6 +165,7 @@ requires-dist = [ { name = "pydantic-settings", extras = ["yaml"], specifier = ">=2.13.1" }, { name = "pytest", marker = "extra == 'dev'", specifier = ">=9.0.2" }, { name = "ruff", marker = "extra == 'dev'", specifier = ">=0.15.4" }, + { name = "ty", marker = "extra == 'dev'", specifier = ">=0.0.20" }, { name = "uvicorn", specifier = ">=0.41.0" }, { name = "uvloop", marker = "sys_platform != 'win32'", specifier = ">=0.22.1" }, ] @@ -519,6 +521,30 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/81/0d/13d1d239a25cbfb19e740db83143e95c772a1fe10202dda4b76792b114dd/starlette-0.52.1-py3-none-any.whl", hash = "sha256:0029d43eb3d273bc4f83a08720b4912ea4b071087a3b48db01b7c839f7954d74", size = 74272, upload-time = "2026-01-18T13:34:09.188Z" }, ] +[[package]] +name = "ty" +version = "0.0.20" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/56/95/8de69bb98417227b01f1b1d743c819d6456c9fd140255b6124b05b17dfd6/ty-0.0.20.tar.gz", hash = "sha256:ebba6be7974c14efbb2a9adda6ac59848f880d7259f089dfa72a093039f1dcc6", size = 5262529, upload-time = "2026-03-02T15:51:36.587Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/2c/718abe48393e521bf852cd6b0f984766869b09c258d6e38a118768a91731/ty-0.0.20-py3-none-linux_armv6l.whl", hash = "sha256:7cc12769c169c9709a829c2248ee2826b7aae82e92caeac813d856f07c021eae", size = 10333656, upload-time = "2026-03-02T15:51:56.461Z" }, + { url = "https://files.pythonhosted.org/packages/41/0e/eb1c4cc4a12862e2327b72657bcebb10b7d9f17046f1bdcd6457a0211615/ty-0.0.20-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:3b777c1bf13bc0a95985ebb8a324b8668a4a9b2e514dde5ccf09e4d55d2ff232", size = 10168505, upload-time = "2026-03-02T15:51:51.895Z" }, + { url = "https://files.pythonhosted.org/packages/89/7f/10230798e673f0dd3094dfd16e43bfd90e9494e7af6e8e7db516fb431ddf/ty-0.0.20-py3-none-macosx_11_0_arm64.whl", hash = "sha256:b2a4a7db48bf8cba30365001bc2cad7fd13c1a5aacdd704cc4b7925de8ca5eb3", size = 9678510, upload-time = "2026-03-02T15:51:48.451Z" }, + { url = "https://files.pythonhosted.org/packages/7a/3d/59d9159577494edd1728f7db77b51bb07884bd21384f517963114e3ab5f6/ty-0.0.20-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6846427b8b353a43483e9c19936dc6a25612573b44c8f7d983dfa317e7f00d4c", size = 10162926, upload-time = "2026-03-02T15:51:40.558Z" }, + { url = "https://files.pythonhosted.org/packages/9c/a8/b7273eec3e802f78eb913fbe0ce0c16ef263723173e06a5776a8359b2c66/ty-0.0.20-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:245ceef5bd88df366869385cf96411cb14696334f8daa75597cf7e41c3012eb8", size = 10171702, upload-time = "2026-03-02T15:51:44.069Z" }, + { url = "https://files.pythonhosted.org/packages/9f/32/5f1144f2f04a275109db06e3498450c4721554215b80ae73652ef412eeab/ty-0.0.20-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c4d21d1cdf67a444d3c37583c17291ddba9382a9871021f3f5d5735e09e85efe", size = 10682552, upload-time = "2026-03-02T15:51:33.102Z" }, + { url = "https://files.pythonhosted.org/packages/6a/db/9f1f637310792f12bd6ed37d5fc8ab39ba1a9b0c6c55a33865e9f1cad840/ty-0.0.20-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:bd4ffd907d1bd70e46af9e9a2f88622f215e1bf44658ea43b32c2c0b357299e4", size = 11242605, upload-time = "2026-03-02T15:51:34.895Z" }, + { url = "https://files.pythonhosted.org/packages/1a/68/cc9cae2e732fcfd20ccdffc508407905a023fc8493b8771c392d915528dc/ty-0.0.20-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b6594b58d8b0e9d16a22b3045fc1305db4b132c8d70c17784ab8c7a7cc986807", size = 10974655, upload-time = "2026-03-02T15:51:46.011Z" }, + { url = "https://files.pythonhosted.org/packages/1c/c1/b9e3e3f28fe63486331e653f6aeb4184af8b1fe80542fcf74d2dda40a93d/ty-0.0.20-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3662f890518ce6cf4d7568f57d03906912d2afbf948a01089a28e325b1ef198c", size = 10761325, upload-time = "2026-03-02T15:51:26.818Z" }, + { url = "https://files.pythonhosted.org/packages/39/9e/67db935bdedf219a00fb69ec5437ba24dab66e0f2e706dd54a4eca234b84/ty-0.0.20-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:0e3ffbae58f9f0d17cdc4ac6d175ceae560b7ed7d54f9ddfb1c9f31054bcdc2c", size = 10145793, upload-time = "2026-03-02T15:51:38.562Z" }, + { url = "https://files.pythonhosted.org/packages/c7/de/b0eb815d4dc5a819c7e4faddc2a79058611169f7eef07ccc006531ce228c/ty-0.0.20-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:176e52bc8bb00b0e84efd34583962878a447a3a0e34ecc45fd7097a37554261b", size = 10189640, upload-time = "2026-03-02T15:51:50.202Z" }, + { url = "https://files.pythonhosted.org/packages/b8/71/63734923965cbb70df1da3e93e4b8875434e326b89e9f850611122f279bf/ty-0.0.20-py3-none-musllinux_1_2_i686.whl", hash = "sha256:b2bc73025418e976ca4143dde71fb9025a90754a08ac03e6aa9b80d4bed1294b", size = 10370568, upload-time = "2026-03-02T15:51:42.295Z" }, + { url = "https://files.pythonhosted.org/packages/32/a0/a532c2048533347dff48e9ca98bd86d2c224356e101688a8edaf8d6973fb/ty-0.0.20-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:d52f7c9ec6e363e094b3c389c344d5a140401f14a77f0625e3f28c21918552f5", size = 10853999, upload-time = "2026-03-02T15:51:58.963Z" }, + { url = "https://files.pythonhosted.org/packages/48/88/36c652c658fe96658043e4abc8ea97801de6fb6e63ab50aaa82807bff1d8/ty-0.0.20-py3-none-win32.whl", hash = "sha256:c7d32bfe93f8fcaa52b6eef3f1b930fd7da410c2c94e96f7412c30cfbabf1d17", size = 9744206, upload-time = "2026-03-02T15:51:54.183Z" }, + { url = "https://files.pythonhosted.org/packages/ff/a7/a4a13bed1d7fd9d97aaa3c5bb5e6d3e9a689e6984806cbca2ab4c9233cac/ty-0.0.20-py3-none-win_amd64.whl", hash = "sha256:a5e10f40fc4a0a1cbcb740a4aad5c7ce35d79f030836ea3183b7a28f43170248", size = 10711999, upload-time = "2026-03-02T15:51:29.212Z" }, + { url = "https://files.pythonhosted.org/packages/8d/7e/6bfd748a9f4ff9267ed3329b86a0f02cdf6ab49f87bc36c8a164852f99fc/ty-0.0.20-py3-none-win_arm64.whl", hash = "sha256:53f7a5c12c960e71f160b734f328eff9a35d578af4b67a36b0bb5990ac5cdc27", size = 10150143, upload-time = "2026-03-02T15:51:31.283Z" }, +] + [[package]] name = "typing-extensions" version = "4.15.0" From da54b7389720a7c6b01de7a0381e07e84e2a7083 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C6=B0u=20Quang=20V=C5=A9?= Date: Tue, 3 Mar 2026 22:10:33 +0700 Subject: [PATCH 172/291] Temporarily switch to the `move-httpx-to-curl_cffi` branch for testing purposes. --- pyproject.toml | 3 ++ uv.lock | 76 +++----------------------------------------------- 2 files changed, 7 insertions(+), 72 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index f961758..03d5661 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -63,3 +63,6 @@ extend-immutable-calls = [ [tool.ruff.format] quote-style = "double" indent-style = "space" + +[tool.uv.sources] +gemini-webapi = { git = "https://github.com/luuquangvu/Gemini-API.git", rev = "move-httpx-to-curl_cffi" } diff --git a/uv.lock b/uv.lock index f6415bd..f0019d4 100644 --- a/uv.lock +++ b/uv.lock @@ -157,7 +157,7 @@ dev = [ requires-dist = [ { name = "curl-cffi", specifier = ">=0.14.0" }, { name = "fastapi", specifier = ">=0.135.1" }, - { name = "gemini-webapi", specifier = ">=1.19.2" }, + { name = "gemini-webapi", git = "https://github.com/luuquangvu/Gemini-API.git?rev=move-httpx-to-curl_cffi" }, { name = "httptools", specifier = ">=0.7.1" }, { name = "lmdb", specifier = ">=1.7.5" }, { name = "loguru", specifier = ">=0.7.3" }, @@ -176,18 +176,14 @@ dev = [{ name = "gemini-fastapi", extras = ["dev"] }] [[package]] name = "gemini-webapi" -version = "1.19.2" -source = { registry = "https://pypi.org/simple" } +version = "0.0.post232" +source = { git = "https://github.com/luuquangvu/Gemini-API.git?rev=move-httpx-to-curl_cffi#645a17d962288e9f99e805db0c1a7385718afcb6" } dependencies = [ - { name = "httpx", extra = ["http2"] }, + { name = "curl-cffi" }, { name = "loguru" }, { name = "orjson" }, { name = "pydantic" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/d4/d3/b4ff659bfb0fff378b16f934429d53b7f78451ac184406ab2f9ddda9357e/gemini_webapi-1.19.2.tar.gz", hash = "sha256:f6e96e28f3f1e78be6176fbb8b2eca25ad509aec6cfacf99c415559f27691b71", size = 266805, upload-time = "2026-02-14T05:26:04.103Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/2d/e7/a676f721980e3daa43e05abe94884a84648efdc6203889e7a0f5c8ca2e98/gemini_webapi-1.19.2-py3-none-any.whl", hash = "sha256:fdc088ca35361301f40ea807a58c4bec18886b17a54164a1a8f3d639eadc6a66", size = 63524, upload-time = "2026-02-14T05:26:02.173Z" }, -] [[package]] name = "h11" @@ -198,41 +194,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, ] -[[package]] -name = "h2" -version = "4.3.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "hpack" }, - { name = "hyperframe" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/1d/17/afa56379f94ad0fe8defd37d6eb3f89a25404ffc71d4d848893d270325fc/h2-4.3.0.tar.gz", hash = "sha256:6c59efe4323fa18b47a632221a1888bd7fde6249819beda254aeca909f221bf1", size = 2152026, upload-time = "2025-08-23T18:12:19.778Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/69/b2/119f6e6dcbd96f9069ce9a2665e0146588dc9f88f29549711853645e736a/h2-4.3.0-py3-none-any.whl", hash = "sha256:c438f029a25f7945c69e0ccf0fb951dc3f73a5f6412981daee861431b70e2bdd", size = 61779, upload-time = "2025-08-23T18:12:17.779Z" }, -] - -[[package]] -name = "hpack" -version = "4.1.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/2c/48/71de9ed269fdae9c8057e5a4c0aa7402e8bb16f2c6e90b3aa53327b113f8/hpack-4.1.0.tar.gz", hash = "sha256:ec5eca154f7056aa06f196a557655c5b009b382873ac8d1e66e79e87535f1dca", size = 51276, upload-time = "2025-01-22T21:44:58.347Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/07/c6/80c95b1b2b94682a72cbdbfb85b81ae2daffa4291fbfa1b1464502ede10d/hpack-4.1.0-py3-none-any.whl", hash = "sha256:157ac792668d995c657d93111f46b4535ed114f0c9c8d672271bbec7eae1b496", size = 34357, upload-time = "2025-01-22T21:44:56.92Z" }, -] - -[[package]] -name = "httpcore" -version = "1.0.9" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "certifi" }, - { name = "h11" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, -] - [[package]] name = "httptools" version = "0.7.1" @@ -248,35 +209,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/fd/82/88e8d6d2c51edc1cc391b6e044c6c435b6aebe97b1abc33db1b0b24cd582/httptools-0.7.1-cp313-cp313-win_amd64.whl", hash = "sha256:322d00c2068d125bd570f7bf78b2d367dad02b919d8581d7476d8b75b294e3e6", size = 85743, upload-time = "2025-10-10T03:54:53.448Z" }, ] -[[package]] -name = "httpx" -version = "0.28.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "anyio" }, - { name = "certifi" }, - { name = "httpcore" }, - { name = "idna" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, -] - -[package.optional-dependencies] -http2 = [ - { name = "h2" }, -] - -[[package]] -name = "hyperframe" -version = "6.1.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/02/e7/94f8232d4a74cc99514c13a9f995811485a6903d48e5d952771ef6322e30/hyperframe-6.1.0.tar.gz", hash = "sha256:f630908a00854a7adeabd6382b43923a4c4cd4b821fcb527e6ab9e15382a3b08", size = 26566, upload-time = "2025-01-22T21:41:49.302Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/48/30/47d0bf6072f7252e6521f3447ccfa40b421b6824517f82854703d0f5a98b/hyperframe-6.1.0-py3-none-any.whl", hash = "sha256:b03380493a519fce58ea5af42e4a42317bf9bd425596f7a0835ffce80f1a42e5", size = 13007, upload-time = "2025-01-22T21:41:47.295Z" }, -] - [[package]] name = "idna" version = "3.11" From 22edc6b8203f92e0941ae023f92a16cbe066e795 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C6=B0u=20Quang=20V=C5=A9?= Date: Tue, 3 Mar 2026 22:13:33 +0700 Subject: [PATCH 173/291] Add workflow_dispatch trigger to Docker workflow --- .github/workflows/docker.yaml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/docker.yaml b/.github/workflows/docker.yaml index 6fb71e8..4b15e84 100644 --- a/.github/workflows/docker.yaml +++ b/.github/workflows/docker.yaml @@ -14,6 +14,7 @@ on: - "Dockerfile" - "run.py" - ".github/workflows/docker.yaml" + workflow_dispatch: env: REGISTRY: ghcr.io From d2937d5caa1fc73aaa066153627700b476129c37 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C6=B0u=20Quang=20V=C5=A9?= Date: Tue, 3 Mar 2026 22:25:57 +0700 Subject: [PATCH 174/291] Revert "Temporarily switch to the `move-httpx-to-curl_cffi` branch for testing purposes." This reverts commit da54b7389720a7c6b01de7a0381e07e84e2a7083. --- pyproject.toml | 3 -- uv.lock | 76 +++++++++++++++++++++++++++++++++++++++++++++++--- 2 files changed, 72 insertions(+), 7 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 03d5661..f961758 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -63,6 +63,3 @@ extend-immutable-calls = [ [tool.ruff.format] quote-style = "double" indent-style = "space" - -[tool.uv.sources] -gemini-webapi = { git = "https://github.com/luuquangvu/Gemini-API.git", rev = "move-httpx-to-curl_cffi" } diff --git a/uv.lock b/uv.lock index f0019d4..f6415bd 100644 --- a/uv.lock +++ b/uv.lock @@ -157,7 +157,7 @@ dev = [ requires-dist = [ { name = "curl-cffi", specifier = ">=0.14.0" }, { name = "fastapi", specifier = ">=0.135.1" }, - { name = "gemini-webapi", git = "https://github.com/luuquangvu/Gemini-API.git?rev=move-httpx-to-curl_cffi" }, + { name = "gemini-webapi", specifier = ">=1.19.2" }, { name = "httptools", specifier = ">=0.7.1" }, { name = "lmdb", specifier = ">=1.7.5" }, { name = "loguru", specifier = ">=0.7.3" }, @@ -176,14 +176,18 @@ dev = [{ name = "gemini-fastapi", extras = ["dev"] }] [[package]] name = "gemini-webapi" -version = "0.0.post232" -source = { git = "https://github.com/luuquangvu/Gemini-API.git?rev=move-httpx-to-curl_cffi#645a17d962288e9f99e805db0c1a7385718afcb6" } +version = "1.19.2" +source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "curl-cffi" }, + { name = "httpx", extra = ["http2"] }, { name = "loguru" }, { name = "orjson" }, { name = "pydantic" }, ] +sdist = { url = "https://files.pythonhosted.org/packages/d4/d3/b4ff659bfb0fff378b16f934429d53b7f78451ac184406ab2f9ddda9357e/gemini_webapi-1.19.2.tar.gz", hash = "sha256:f6e96e28f3f1e78be6176fbb8b2eca25ad509aec6cfacf99c415559f27691b71", size = 266805, upload-time = "2026-02-14T05:26:04.103Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2d/e7/a676f721980e3daa43e05abe94884a84648efdc6203889e7a0f5c8ca2e98/gemini_webapi-1.19.2-py3-none-any.whl", hash = "sha256:fdc088ca35361301f40ea807a58c4bec18886b17a54164a1a8f3d639eadc6a66", size = 63524, upload-time = "2026-02-14T05:26:02.173Z" }, +] [[package]] name = "h11" @@ -194,6 +198,41 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, ] +[[package]] +name = "h2" +version = "4.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "hpack" }, + { name = "hyperframe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1d/17/afa56379f94ad0fe8defd37d6eb3f89a25404ffc71d4d848893d270325fc/h2-4.3.0.tar.gz", hash = "sha256:6c59efe4323fa18b47a632221a1888bd7fde6249819beda254aeca909f221bf1", size = 2152026, upload-time = "2025-08-23T18:12:19.778Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/69/b2/119f6e6dcbd96f9069ce9a2665e0146588dc9f88f29549711853645e736a/h2-4.3.0-py3-none-any.whl", hash = "sha256:c438f029a25f7945c69e0ccf0fb951dc3f73a5f6412981daee861431b70e2bdd", size = 61779, upload-time = "2025-08-23T18:12:17.779Z" }, +] + +[[package]] +name = "hpack" +version = "4.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2c/48/71de9ed269fdae9c8057e5a4c0aa7402e8bb16f2c6e90b3aa53327b113f8/hpack-4.1.0.tar.gz", hash = "sha256:ec5eca154f7056aa06f196a557655c5b009b382873ac8d1e66e79e87535f1dca", size = 51276, upload-time = "2025-01-22T21:44:58.347Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/07/c6/80c95b1b2b94682a72cbdbfb85b81ae2daffa4291fbfa1b1464502ede10d/hpack-4.1.0-py3-none-any.whl", hash = "sha256:157ac792668d995c657d93111f46b4535ed114f0c9c8d672271bbec7eae1b496", size = 34357, upload-time = "2025-01-22T21:44:56.92Z" }, +] + +[[package]] +name = "httpcore" +version = "1.0.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, +] + [[package]] name = "httptools" version = "0.7.1" @@ -209,6 +248,35 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/fd/82/88e8d6d2c51edc1cc391b6e044c6c435b6aebe97b1abc33db1b0b24cd582/httptools-0.7.1-cp313-cp313-win_amd64.whl", hash = "sha256:322d00c2068d125bd570f7bf78b2d367dad02b919d8581d7476d8b75b294e3e6", size = 85743, upload-time = "2025-10-10T03:54:53.448Z" }, ] +[[package]] +name = "httpx" +version = "0.28.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "certifi" }, + { name = "httpcore" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, +] + +[package.optional-dependencies] +http2 = [ + { name = "h2" }, +] + +[[package]] +name = "hyperframe" +version = "6.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/02/e7/94f8232d4a74cc99514c13a9f995811485a6903d48e5d952771ef6322e30/hyperframe-6.1.0.tar.gz", hash = "sha256:f630908a00854a7adeabd6382b43923a4c4cd4b821fcb527e6ab9e15382a3b08", size = 26566, upload-time = "2025-01-22T21:41:49.302Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/48/30/47d0bf6072f7252e6521f3447ccfa40b421b6824517f82854703d0f5a98b/hyperframe-6.1.0-py3-none-any.whl", hash = "sha256:b03380493a519fce58ea5af42e4a42317bf9bd425596f7a0835ffce80f1a42e5", size = 13007, upload-time = "2025-01-22T21:41:47.295Z" }, +] + [[package]] name = "idna" version = "3.11" From ca4475a427159bc6413ab21aa373c092b4754b42 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C6=B0u=20Quang=20V=C5=A9?= Date: Tue, 3 Mar 2026 22:30:34 +0700 Subject: [PATCH 175/291] Fix type checking --- app/server/chat.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/server/chat.py b/app/server/chat.py index 0386bd3..109abb2 100644 --- a/app/server/chat.py +++ b/app/server/chat.py @@ -857,7 +857,7 @@ async def _find_reusable_session( async def _send_with_split( session: ChatSession, text: str, - files: list[str | Path | bytes | io.BytesIO] | None = None, + files: list[Any] | None = None, stream: bool = False, ) -> AsyncGenerator[ModelOutput] | ModelOutput: """Send text to Gemini, splitting or converting to attachment if too long.""" @@ -876,7 +876,7 @@ async def _send_with_split( file_obj = io.BytesIO(text.encode("utf-8")) file_obj.name = "message.txt" try: - final_files: list[str | Path | bytes | io.BytesIO] = list(files) if files else [] + final_files: list[Any] = list(files) if files else [] final_files.append(file_obj) instruction = ( "The user's input exceeds the character limit and is provided in the attached file `message.txt`.\n\n" From 91105e86b005c5e74803d0ddf2ca43739cbf2217 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C6=B0u=20Quang=20V=C5=A9?= Date: Wed, 4 Mar 2026 10:01:52 +0700 Subject: [PATCH 176/291] Update dependencies to latest versions --- pyproject.toml | 2 +- uv.lock | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index f961758..ad31996 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -7,7 +7,7 @@ requires-python = "==3.13.*" dependencies = [ "curl-cffi>=0.14.0", "fastapi>=0.135.1", - "gemini-webapi>=1.19.2", + "gemini-webapi>=1.20.0", "httptools>=0.7.1", "lmdb>=1.7.5", "loguru>=0.7.3", diff --git a/uv.lock b/uv.lock index f6415bd..7fbcb24 100644 --- a/uv.lock +++ b/uv.lock @@ -176,7 +176,7 @@ dev = [{ name = "gemini-fastapi", extras = ["dev"] }] [[package]] name = "gemini-webapi" -version = "1.19.2" +version = "1.20.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "httpx", extra = ["http2"] }, @@ -184,9 +184,9 @@ dependencies = [ { name = "orjson" }, { name = "pydantic" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/d4/d3/b4ff659bfb0fff378b16f934429d53b7f78451ac184406ab2f9ddda9357e/gemini_webapi-1.19.2.tar.gz", hash = "sha256:f6e96e28f3f1e78be6176fbb8b2eca25ad509aec6cfacf99c415559f27691b71", size = 266805, upload-time = "2026-02-14T05:26:04.103Z" } +sdist = { url = "https://files.pythonhosted.org/packages/ad/65/4c283c0c7a1004a92ef575fa3deb5d2cab8d66fa3a8de6500031491d2d6b/gemini_webapi-1.20.0.tar.gz", hash = "sha256:540e77aee4c28f57be4a9a3cb7845f509411763fc6958dc0af984822e2dd3bc1", size = 267233, upload-time = "2026-03-03T22:48:14.046Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/2d/e7/a676f721980e3daa43e05abe94884a84648efdc6203889e7a0f5c8ca2e98/gemini_webapi-1.19.2-py3-none-any.whl", hash = "sha256:fdc088ca35361301f40ea807a58c4bec18886b17a54164a1a8f3d639eadc6a66", size = 63524, upload-time = "2026-02-14T05:26:02.173Z" }, + { url = "https://files.pythonhosted.org/packages/4c/93/bd3f3bc00da483fbc8d4c329a602b037dcf410cd63d42d1d5c3ee81ec9e4/gemini_webapi-1.20.0-py3-none-any.whl", hash = "sha256:49fe29d7ce80c686c20f45a3218d1832bdd0673978e18ea86d8fb8c3ebff6c18", size = 63759, upload-time = "2026-03-03T22:48:12.769Z" }, ] [[package]] From d858c2b31aab51424db97d5f2791b03acec394be Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C6=B0u=20Quang=20V=C5=A9?= Date: Wed, 4 Mar 2026 10:03:19 +0700 Subject: [PATCH 177/291] Temporarily switch to the `move-httpx-to-curl_cffi` branch for testing purposes. --- Dockerfile | 2 +- pyproject.toml | 3 ++ uv.lock | 76 +++----------------------------------------------- 3 files changed, 8 insertions(+), 73 deletions(-) diff --git a/Dockerfile b/Dockerfile index 62ce9d1..5c669c2 100644 --- a/Dockerfile +++ b/Dockerfile @@ -6,7 +6,7 @@ LABEL org.opencontainers.image.title="Gemini-FastAPI" \ WORKDIR /app RUN apt-get update && apt-get install -y --no-install-recommends \ - tini \ + tini git \ && rm -rf /var/lib/apt/lists/* ENV UV_COMPILE_BYTECODE=1 \ diff --git a/pyproject.toml b/pyproject.toml index ad31996..9a8f666 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -63,3 +63,6 @@ extend-immutable-calls = [ [tool.ruff.format] quote-style = "double" indent-style = "space" + +[tool.uv.sources] +gemini-webapi = { git = "https://github.com/luuquangvu/Gemini-API.git", rev = "move-httpx-to-curl_cffi" } diff --git a/uv.lock b/uv.lock index 7fbcb24..b262688 100644 --- a/uv.lock +++ b/uv.lock @@ -157,7 +157,7 @@ dev = [ requires-dist = [ { name = "curl-cffi", specifier = ">=0.14.0" }, { name = "fastapi", specifier = ">=0.135.1" }, - { name = "gemini-webapi", specifier = ">=1.19.2" }, + { name = "gemini-webapi", git = "https://github.com/luuquangvu/Gemini-API.git?rev=move-httpx-to-curl_cffi" }, { name = "httptools", specifier = ">=0.7.1" }, { name = "lmdb", specifier = ">=1.7.5" }, { name = "loguru", specifier = ">=0.7.3" }, @@ -176,18 +176,14 @@ dev = [{ name = "gemini-fastapi", extras = ["dev"] }] [[package]] name = "gemini-webapi" -version = "1.20.0" -source = { registry = "https://pypi.org/simple" } +version = "0.0.post243" +source = { git = "https://github.com/luuquangvu/Gemini-API.git?rev=move-httpx-to-curl_cffi#79d86aff4785dd45bd951910b8be75cf8122e9cd" } dependencies = [ - { name = "httpx", extra = ["http2"] }, + { name = "curl-cffi" }, { name = "loguru" }, { name = "orjson" }, { name = "pydantic" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/ad/65/4c283c0c7a1004a92ef575fa3deb5d2cab8d66fa3a8de6500031491d2d6b/gemini_webapi-1.20.0.tar.gz", hash = "sha256:540e77aee4c28f57be4a9a3cb7845f509411763fc6958dc0af984822e2dd3bc1", size = 267233, upload-time = "2026-03-03T22:48:14.046Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/4c/93/bd3f3bc00da483fbc8d4c329a602b037dcf410cd63d42d1d5c3ee81ec9e4/gemini_webapi-1.20.0-py3-none-any.whl", hash = "sha256:49fe29d7ce80c686c20f45a3218d1832bdd0673978e18ea86d8fb8c3ebff6c18", size = 63759, upload-time = "2026-03-03T22:48:12.769Z" }, -] [[package]] name = "h11" @@ -198,41 +194,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, ] -[[package]] -name = "h2" -version = "4.3.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "hpack" }, - { name = "hyperframe" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/1d/17/afa56379f94ad0fe8defd37d6eb3f89a25404ffc71d4d848893d270325fc/h2-4.3.0.tar.gz", hash = "sha256:6c59efe4323fa18b47a632221a1888bd7fde6249819beda254aeca909f221bf1", size = 2152026, upload-time = "2025-08-23T18:12:19.778Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/69/b2/119f6e6dcbd96f9069ce9a2665e0146588dc9f88f29549711853645e736a/h2-4.3.0-py3-none-any.whl", hash = "sha256:c438f029a25f7945c69e0ccf0fb951dc3f73a5f6412981daee861431b70e2bdd", size = 61779, upload-time = "2025-08-23T18:12:17.779Z" }, -] - -[[package]] -name = "hpack" -version = "4.1.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/2c/48/71de9ed269fdae9c8057e5a4c0aa7402e8bb16f2c6e90b3aa53327b113f8/hpack-4.1.0.tar.gz", hash = "sha256:ec5eca154f7056aa06f196a557655c5b009b382873ac8d1e66e79e87535f1dca", size = 51276, upload-time = "2025-01-22T21:44:58.347Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/07/c6/80c95b1b2b94682a72cbdbfb85b81ae2daffa4291fbfa1b1464502ede10d/hpack-4.1.0-py3-none-any.whl", hash = "sha256:157ac792668d995c657d93111f46b4535ed114f0c9c8d672271bbec7eae1b496", size = 34357, upload-time = "2025-01-22T21:44:56.92Z" }, -] - -[[package]] -name = "httpcore" -version = "1.0.9" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "certifi" }, - { name = "h11" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, -] - [[package]] name = "httptools" version = "0.7.1" @@ -248,35 +209,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/fd/82/88e8d6d2c51edc1cc391b6e044c6c435b6aebe97b1abc33db1b0b24cd582/httptools-0.7.1-cp313-cp313-win_amd64.whl", hash = "sha256:322d00c2068d125bd570f7bf78b2d367dad02b919d8581d7476d8b75b294e3e6", size = 85743, upload-time = "2025-10-10T03:54:53.448Z" }, ] -[[package]] -name = "httpx" -version = "0.28.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "anyio" }, - { name = "certifi" }, - { name = "httpcore" }, - { name = "idna" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, -] - -[package.optional-dependencies] -http2 = [ - { name = "h2" }, -] - -[[package]] -name = "hyperframe" -version = "6.1.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/02/e7/94f8232d4a74cc99514c13a9f995811485a6903d48e5d952771ef6322e30/hyperframe-6.1.0.tar.gz", hash = "sha256:f630908a00854a7adeabd6382b43923a4c4cd4b821fcb527e6ab9e15382a3b08", size = 26566, upload-time = "2025-01-22T21:41:49.302Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/48/30/47d0bf6072f7252e6521f3447ccfa40b421b6824517f82854703d0f5a98b/hyperframe-6.1.0-py3-none-any.whl", hash = "sha256:b03380493a519fce58ea5af42e4a42317bf9bd425596f7a0835ffce80f1a42e5", size = 13007, upload-time = "2025-01-22T21:41:47.295Z" }, -] - [[package]] name = "idna" version = "3.11" From 592132fefa1bffec95bebb01f696cc927afc3e96 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C6=B0u=20Quang=20V=C5=A9?= Date: Wed, 4 Mar 2026 11:15:24 +0700 Subject: [PATCH 178/291] Resolve HTTP 422 "Input should be a valid string" --- app/models/models.py | 58 +++++++++++++++++++++++--------------------- 1 file changed, 30 insertions(+), 28 deletions(-) diff --git a/app/models/models.py b/app/models/models.py index 7284b5f..9b3c0ff 100644 --- a/app/models/models.py +++ b/app/models/models.py @@ -150,14 +150,14 @@ class ChatCompletionResponse(BaseModel): class ResponseInputText(BaseModel): """Text content item in a Responses API input message.""" - type: Literal["input_text"] + type: Literal["input_text"] | None = Field(default="input_text") text: str | None = Field(default=None) class ResponseInputImage(BaseModel): """Image content item in a Responses API input message.""" - type: Literal["input_image"] + type: Literal["input_image"] | None = Field(default="input_image") detail: Literal["auto", "low", "high"] | None = Field(default=None) file_id: str | None = Field(default=None) image_url: str | None = Field(default=None) @@ -166,7 +166,7 @@ class ResponseInputImage(BaseModel): class ResponseInputFile(BaseModel): """File content item in a Responses API input message.""" - type: Literal["input_file"] + type: Literal["input_file"] | None = Field(default="input_file") file_id: str | None = Field(default=None) file_url: str | None = Field(default=None) file_data: str | None = Field(default=None) @@ -204,10 +204,10 @@ class ResponseFunctionToolCall(BaseModel): type: Literal["function_call"] | None = Field(default="function_call") id: str | None = Field(default=None) - call_id: str - name: str - arguments: str - status: Literal["in_progress", "completed", "incomplete"] = Field(default="completed") + call_id: str | None = Field(default=None) + name: str | None = Field(default=None) + arguments: str | None = Field(default=None) + status: Literal["in_progress", "completed", "incomplete"] | None = Field(default="completed") class FunctionCallOutput(BaseModel): @@ -215,9 +215,11 @@ class FunctionCallOutput(BaseModel): type: Literal["function_call_output"] | None = Field(default="function_call_output") id: str | None = Field(default=None) - call_id: str - output: str | list[ResponseInputText | ResponseInputImage | ResponseInputFile] - status: Literal["in_progress", "completed", "incomplete"] = Field(default="completed") + call_id: str | None = Field(default=None) + output: str | list[ResponseInputText | ResponseInputImage | ResponseInputFile] | None = Field( + default=None + ) + status: Literal["in_progress", "completed", "incomplete"] | None = Field(default="completed") class FunctionTool(BaseModel): @@ -267,7 +269,7 @@ class ResponseUsage(BaseModel): class ResponseOutputText(BaseModel): """Text content part inside a Responses API output message.""" - type: Literal["output_text"] + type: Literal["output_text"] | None = Field(default="output_text") text: str | None = Field(default=None) annotations: list[dict[str, Any]] = Field(default_factory=list) logprobs: list[dict[str, Any]] | None = Field(default=None) @@ -276,7 +278,7 @@ class ResponseOutputText(BaseModel): class ResponseOutputRefusal(BaseModel): """Refusal content part inside a Responses API output message.""" - type: Literal["refusal"] + type: Literal["refusal"] | None = Field(default="refusal") refusal: str | None = Field(default=None) @@ -286,8 +288,8 @@ class ResponseOutputRefusal(BaseModel): class ResponseOutputMessage(BaseModel): """Assistant message output item in a Responses API response.""" - id: str - type: Literal["message"] + id: str | None = Field(default=None) + type: Literal["message"] | None = Field(default="message") status: Literal["in_progress", "completed", "incomplete"] = Field(default="completed") role: Literal["assistant"] content: list[ResponseOutputText | ResponseOutputRefusal] @@ -296,22 +298,22 @@ class ResponseOutputMessage(BaseModel): class SummaryTextContent(BaseModel): """Summary text part inside a reasoning item.""" - type: Literal["summary_text"] = Field(default="summary_text") - text: str + type: Literal["summary_text"] | None = Field(default="summary_text") + text: str | None = Field(default=None) class ReasoningTextContent(BaseModel): """Full reasoning text part inside a reasoning item.""" - type: Literal["reasoning_text"] = Field(default="reasoning_text") - text: str + type: Literal["reasoning_text"] | None = Field(default="reasoning_text") + text: str | None = Field(default=None) class ResponseReasoningItem(BaseModel): """A reasoning output item emitted by a thinking model.""" - id: str - type: Literal["reasoning"] = Field(default="reasoning") + id: str | None = Field(default=None) + type: Literal["reasoning"] | None = Field(default="reasoning") status: Literal["in_progress", "completed", "incomplete"] | None = Field(default=None) summary: list[SummaryTextContent] | None = Field(default=None) content: list[ReasoningTextContent] | None = Field(default=None) @@ -321,19 +323,19 @@ class ResponseReasoningItem(BaseModel): class ResponseToolCall(BaseModel): """A function-call output item emitted by the model.""" - id: str - type: Literal["function_call"] = Field(default="function_call") - call_id: str - name: str - arguments: str - status: Literal["in_progress", "completed", "incomplete"] = Field(default="completed") + id: str | None = Field(default=None) + type: Literal["function_call"] | None = Field(default="function_call") + call_id: str | None = Field(default=None) + name: str | None = Field(default=None) + arguments: str | None = Field(default=None) + status: Literal["in_progress", "completed", "incomplete"] | None = Field(default="completed") class ImageGenerationCall(BaseModel): """An image-generation output item emitted by the Responses API.""" - id: str - type: Literal["image_generation_call"] = Field(default="image_generation_call") + id: str | None = Field(default=None) + type: Literal["image_generation_call"] | None = Field(default="image_generation_call") status: Literal["completed", "in_progress", "generating", "failed"] = Field(default="completed") result: str | None = Field(default=None) output_format: str | None = Field(default=None) From 80750ad2c79bb409416663050e5d6cca18e6ef56 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C6=B0u=20Quang=20V=C5=A9?= Date: Wed, 4 Mar 2026 15:25:12 +0700 Subject: [PATCH 179/291] Move the content storage in LMDB to a separate `AppMessage` Independent of the `ChatCompletionMessage` format, to simplify maintenance and improve scalability. --- app/models/__init__.py | 95 +------- app/models/core.py | 48 ++++ app/models/models.py | 16 -- app/server/chat.py | 495 +++++++++++++++++++++++------------------ app/services/client.py | 37 ++- app/services/lmdb.py | 200 +++-------------- app/utils/helper.py | 14 +- 7 files changed, 395 insertions(+), 510 deletions(-) create mode 100644 app/models/core.py diff --git a/app/models/__init__.py b/app/models/__init__.py index 1e02dcb..6fa671f 100644 --- a/app/models/__init__.py +++ b/app/models/__init__.py @@ -1,93 +1,4 @@ -from .models import ( - ChatCompletionAssistantContentItem, - ChatCompletionChoice, - ChatCompletionContentItem, - ChatCompletionFunctionTool, - ChatCompletionMessage, - ChatCompletionMessageToolCall, - ChatCompletionNamedToolChoice, - ChatCompletionNamedToolChoiceFunction, - ChatCompletionRequest, - ChatCompletionRequestContentItem, - ChatCompletionResponse, - CompletionUsage, - ConversationInStore, - FunctionCall, - FunctionCallOutput, - FunctionDefinition, - FunctionTool, - HealthCheckResponse, - ImageGeneration, - ImageGenerationCall, - ModelData, - ModelListResponse, - ReasoningTextContent, - ResponseCreateRequest, - ResponseCreateResponse, - ResponseFormatText, - ResponseFormatTextJSONSchemaConfig, - ResponseFunctionToolCall, - ResponseInputFile, - ResponseInputImage, - ResponseInputMessage, - ResponseInputMessageContentList, - ResponseInputText, - ResponseOutputContent, - ResponseOutputMessage, - ResponseOutputRefusal, - ResponseOutputText, - ResponseReasoningItem, - ResponseTextConfig, - ResponseToolCall, - ResponseUsage, - SummaryTextContent, - ToolChoiceFunction, - ToolChoiceTypes, -) +# ruff: noqa: F403 -__all__ = [ - "ChatCompletionAssistantContentItem", - "ChatCompletionChoice", - "ChatCompletionContentItem", - "ChatCompletionFunctionTool", - "ChatCompletionMessage", - "ChatCompletionMessageToolCall", - "ChatCompletionNamedToolChoice", - "ChatCompletionNamedToolChoiceFunction", - "ChatCompletionRequest", - "ChatCompletionRequestContentItem", - "ChatCompletionResponse", - "CompletionUsage", - "ConversationInStore", - "FunctionCall", - "FunctionCallOutput", - "FunctionDefinition", - "FunctionTool", - "HealthCheckResponse", - "ImageGeneration", - "ImageGenerationCall", - "ModelData", - "ModelListResponse", - "ReasoningTextContent", - "ResponseCreateRequest", - "ResponseCreateResponse", - "ResponseFormatText", - "ResponseFormatTextJSONSchemaConfig", - "ResponseFunctionToolCall", - "ResponseInputFile", - "ResponseInputImage", - "ResponseInputMessage", - "ResponseInputMessageContentList", - "ResponseInputText", - "ResponseOutputContent", - "ResponseOutputMessage", - "ResponseOutputRefusal", - "ResponseOutputText", - "ResponseReasoningItem", - "ResponseTextConfig", - "ResponseToolCall", - "ResponseUsage", - "SummaryTextContent", - "ToolChoiceFunction", - "ToolChoiceTypes", -] +from .core import * +from .models import * diff --git a/app/models/core.py b/app/models/core.py new file mode 100644 index 0000000..d92dcc6 --- /dev/null +++ b/app/models/core.py @@ -0,0 +1,48 @@ +from datetime import datetime +from typing import Any, Literal + +from pydantic import BaseModel, Field + + +class AppToolCallFunction(BaseModel): + name: str + arguments: str + + +class AppToolCall(BaseModel): + id: str + type: Literal["function"] = "function" + function: AppToolCallFunction + + +class AppContentItem(BaseModel): + type: str + text: str | None = None + url: str | None = None + file_data: str | bytes | None = Field(default=None, exclude=True) + filename: str | None = None + raw_data: dict[str, Any] | None = None + + +class AppMessage(BaseModel): + role: Literal["system", "user", "assistant", "tool"] + name: str | None = None + content: str | list[AppContentItem] | None = None + tool_calls: list[AppToolCall] | None = None + tool_call_id: str | None = None + reasoning_content: str | None = None + + +class ConversationInStore(BaseModel): + """Persisted conversation record stored in LMDB.""" + + created_at: datetime | None = Field(default=None) + updated_at: datetime | None = Field(default=None) + model: str = Field(..., description="Model used for the conversation") + client_id: str = Field(..., description="Identifier of the Gemini client") + metadata: list[str | None] = Field( + ..., description="Metadata for Gemini API to locate the conversation" + ) + messages: list[AppMessage] = Field( + ..., description="Canonical message contents in the conversation" + ) diff --git a/app/models/models.py b/app/models/models.py index 9b3c0ff..71462b2 100644 --- a/app/models/models.py +++ b/app/models/models.py @@ -1,6 +1,5 @@ from __future__ import annotations -from datetime import datetime from typing import Any, Literal from pydantic import BaseModel, Field, model_validator @@ -454,21 +453,6 @@ class HealthCheckResponse(BaseModel): error: str | None = Field(default=None) -class ConversationInStore(BaseModel): - """Persisted conversation record stored in LMDB.""" - - created_at: datetime | None = Field(default=None) - updated_at: datetime | None = Field(default=None) - model: str = Field(..., description="Model used for the conversation") - client_id: str = Field(..., description="Identifier of the Gemini client") - metadata: list[str | None] = Field( - ..., description="Metadata for Gemini API to locate the conversation" - ) - messages: list[ChatCompletionMessage] = Field( - ..., description="Message contents in the conversation" - ) - - ChatCompletionMessage.model_rebuild() ChatCompletionMessageToolCall.model_rebuild() ChatCompletionRequest.model_rebuild() diff --git a/app/server/chat.py b/app/server/chat.py index 109abb2..86d25ae 100644 --- a/app/server/chat.py +++ b/app/server/chat.py @@ -7,7 +7,7 @@ from dataclasses import dataclass from datetime import UTC, datetime from pathlib import Path -from typing import Any, Literal +from typing import Any, Literal, cast import orjson from fastapi import APIRouter, Depends, HTTPException, Request, status @@ -19,18 +19,18 @@ from loguru import logger from app.models import ( + AppContentItem, + AppMessage, + AppToolCall, + AppToolCallFunction, ChatCompletionChoice, - ChatCompletionContentItem, ChatCompletionFunctionTool, ChatCompletionMessage, ChatCompletionMessageToolCall, ChatCompletionNamedToolChoice, ChatCompletionRequest, - ChatCompletionRequestContentItem, ChatCompletionResponse, CompletionUsage, - ConversationInStore, - FunctionCall, FunctionCallOutput, FunctionTool, ImageGeneration, @@ -130,7 +130,7 @@ async def _image_to_base64( def _calculate_usage( - messages: list[ChatCompletionMessage], + messages: list[AppMessage], assistant_text: str | None, tool_calls: list[Any] | None, thoughts: str | None = None, @@ -321,36 +321,109 @@ def _process_llm_output( return thoughts, visible_output, storage_output, tool_calls +def _convert_to_app_messages(messages: list[ChatCompletionMessage]) -> list[AppMessage]: + """Convert ChatCompletionMessage (OpenAI format) to generic internal AppMessage.""" + app_messages = [] + for msg in messages: + app_content = None + if isinstance(msg.content, str): + app_content = msg.content + elif isinstance(msg.content, list): + app_content = [] + for item in msg.content: + if item.type == "text": + app_content.append(AppContentItem(type="text", text=item.text)) + elif item.type == "image_url": + media_dict = getattr(item, "image_url", None) + url = media_dict.get("url") if media_dict else None + if url and url.startswith("data:"): + # image_url can be either a regular url or base64 data url + app_content.append(AppContentItem(type="image_url", url=url)) + else: + app_content.append(AppContentItem(type="image_url", url=url)) + elif item.type == "file": + file_dict = getattr(item, "file", None) + filename = file_dict.get("filename") if file_dict else None + file_data = file_dict.get("file_data") if file_dict else None + app_content.append( + AppContentItem(type="file", filename=filename, file_data=file_data) + ) + elif item.type == "input_audio": + audio_dict = getattr(item, "input_audio", None) + audio_data = audio_dict.get("data") if audio_dict else None + app_content.append( + AppContentItem( + type="input_audio", + file_data=audio_data, + raw_data=audio_dict, + ) + ) + elif item.type in ("refusal", "reasoning"): + text_val = getattr(item, "text", None) or getattr(item, item.type, None) + app_content.append(AppContentItem(type=item.type, text=text_val)) + + tool_calls = None + if msg.tool_calls: + tool_calls = [ + AppToolCall( + id=tc.id if hasattr(tc, "id") else tc.get("id", ""), + type="function", + function=AppToolCallFunction( + name=tc.function.name + if hasattr(tc, "function") + else tc.get("function", {}).get("name", ""), + arguments=tc.function.arguments + if hasattr(tc, "function") + else tc.get("function", {}).get("arguments", ""), + ), + ) + for tc in msg.tool_calls + ] + + role = {"developer": "system", "function": "tool"}.get(msg.role, msg.role) + if role not in ("system", "user", "assistant", "tool"): + role = "system" + + app_messages.append( + AppMessage( + role=role, # type: ignore + content=app_content, + tool_calls=tool_calls, + tool_call_id=msg.tool_call_id, + name=msg.name, + reasoning_content=getattr(msg, "reasoning_content", None), + ) + ) + return app_messages + + def _persist_conversation( db: LMDBConversationStore, model_name: str, client_id: str, metadata: list[str | None], - messages: list[ChatCompletionMessage], + messages: list[AppMessage], storage_output: str | None, tool_calls: list[Any] | None, - thoughts: str | None = None, ) -> str | None: """Unified logic to save conversation history to LMDB.""" try: - current_assistant_message = ChatCompletionMessage( + current_assistant_message = AppMessage( role="assistant", content=storage_output or None, tool_calls=tool_calls or None, - reasoning_content=thoughts or None, + reasoning_content=None, ) full_history = [*messages, current_assistant_message] - cleaned_history = db.sanitize_messages(full_history) - conv = ConversationInStore( - model=model_name, + db.store( client_id=client_id, + model=model_name, + messages=full_history, metadata=metadata, - messages=cleaned_history, ) - key = db.store(conv) - logger.debug(f"Conversation saved to LMDB with key: {key[:12]}") - return key + logger.debug("Conversation saved to LMDB.") + return "success" except Exception as e: logger.warning(f"Failed to save {len(messages) + 1} messages to LMDB: {e}") return None @@ -486,7 +559,7 @@ def _build_image_generation_instruction( return "\n\n".join(instructions) -def _append_tool_hint_to_last_user_message(messages: list[ChatCompletionMessage]) -> None: +def _append_tool_hint_to_last_user_message(messages: list[AppMessage]) -> None: """Ensure the last user message carries the tool wrap hint.""" for msg in reversed(messages): if msg.role != "user" or msg.content is None: @@ -508,12 +581,12 @@ def _append_tool_hint_to_last_user_message(messages: list[ChatCompletionMessage] return messages_text = TOOL_WRAP_HINT.strip() - msg.content.append(ChatCompletionRequestContentItem(type="text", text=messages_text)) + msg.content.append(AppContentItem(type="text", text=messages_text)) return def _prepare_messages_for_model( - source_messages: list[ChatCompletionMessage], + source_messages: list[AppMessage], tools: list[ChatCompletionFunctionTool] | None, tool_choice: Literal["none", "auto", "required"] | ChatCompletionNamedToolChoice @@ -522,7 +595,7 @@ def _prepare_messages_for_model( | None, extra_instructions: list[str] | None = None, inject_system_defaults: bool = True, -) -> list[ChatCompletionMessage]: +) -> list[AppMessage]: """Return a copy of messages enriched with tool instructions when needed.""" prepared = [msg.model_copy(deep=True) for msg in source_messages] @@ -564,7 +637,7 @@ def _prepare_messages_for_model( separator = "\n\n" if existing else "" prepared[0].content = f"{existing}{separator}{combined_instructions}" else: - prepared.insert(0, ChatCompletionMessage(role="system", content=combined_instructions)) + prepared.insert(0, AppMessage(role="system", content=combined_instructions)) if tools and tool_choice != "none" and not tool_prompt_injected: _append_tool_hint_to_last_user_message(prepared) @@ -572,33 +645,36 @@ def _prepare_messages_for_model( return prepared -def _response_items_to_messages( +def _convert_responses_to_app_messages( items: Any, -) -> list[ChatCompletionMessage]: - """Convert Responses API input items into internal Message objects.""" - messages: list[ChatCompletionMessage] = [] +) -> list[AppMessage]: + """Convert Responses API input items into internal AppMessage objects.""" + messages: list[AppMessage] = [] if isinstance(items, str): - messages.append(ChatCompletionMessage(role="user", content=items)) + messages.append(AppMessage(role="user", content=items)) logger.debug("Normalized Responses input: single string message.") return messages for item in items: if isinstance(item, (ResponseInputMessage, ResponseOutputMessage)): - role = item.role + raw_role = getattr(item, "role", "user") + normalized_role = {"developer": "system", "function": "tool"}.get(raw_role, raw_role) + if normalized_role not in ("system", "user", "assistant", "tool"): + normalized_role = "system" + role = cast(Literal["system", "user", "assistant", "tool"], normalized_role) + content = item.content if isinstance(content, str): - messages.append(ChatCompletionMessage(role=role, content=content)) + messages.append(AppMessage(role=role, content=content)) else: - converted: list[ChatCompletionContentItem] = [] + converted: list[AppContentItem] = [] reasoning_parts: list[str] = [] for part in content: if part.type in ("input_text", "output_text"): text_value = getattr(part, "text", "") or "" if text_value: - converted.append( - ChatCompletionRequestContentItem(type="text", text=text_value) - ) + converted.append(AppContentItem(type="text", text=text_value)) elif part.type == "reasoning_text": text_value = getattr(part, "text", "") or "" if text_value: @@ -606,31 +682,22 @@ def _response_items_to_messages( elif part.type == "input_image": image_url = getattr(part, "image_url", None) if image_url: - converted.append( - ChatCompletionRequestContentItem( - type="image_url", - image_url={ - "url": image_url, - "detail": getattr(part, "detail", "auto") or "auto", - }, - ) - ) + converted.append(AppContentItem(type="image_url", url=image_url)) elif part.type == "input_file": file_url = getattr(part, "file_url", None) file_data = getattr(part, "file_data", None) if file_url or file_data: - file_info = {} - if file_data: - file_info["file_data"] = file_data - file_info["filename"] = getattr(part, "filename", None) - if file_url: - file_info["url"] = file_url converted.append( - ChatCompletionRequestContentItem(type="file", file=file_info) + AppContentItem( + type="file", + url=file_url, + file_data=file_data, + filename=getattr(part, "filename", None), + ) ) reasoning_val = "\n\n".join(reasoning_parts) if reasoning_parts else None messages.append( - ChatCompletionMessage( + AppMessage( role=role, content=converted or None, reasoning_content=reasoning_val, @@ -639,13 +706,13 @@ def _response_items_to_messages( elif isinstance(item, ResponseFunctionToolCall): messages.append( - ChatCompletionMessage( + AppMessage( role="assistant", tool_calls=[ - ChatCompletionMessageToolCall( + AppToolCall( id=item.call_id, type="function", - function=FunctionCall(name=item.name, arguments=item.arguments), + function=AppToolCallFunction(name=item.name, arguments=item.arguments), ) ], ) @@ -653,7 +720,7 @@ def _response_items_to_messages( elif isinstance(item, FunctionCallOutput): output_content = str(item.output) if isinstance(item.output, list) else item.output messages.append( - ChatCompletionMessage( + AppMessage( role="tool", tool_call_id=item.call_id, content=output_content, @@ -664,14 +731,14 @@ def _response_items_to_messages( if item.content: reasoning_val = "\n\n".join(x.text for x in item.content if x.text) messages.append( - ChatCompletionMessage( + AppMessage( role="assistant", reasoning_content=reasoning_val, ) ) elif isinstance(item, ImageGenerationCall): messages.append( - ChatCompletionMessage( + AppMessage( role="assistant", content=item.result or None, ) @@ -679,82 +746,109 @@ def _response_items_to_messages( else: if hasattr(item, "role"): + raw_role = getattr(item, "role", "user") + normalized_role = {"developer": "system", "function": "tool"}.get( + raw_role, raw_role + ) + if normalized_role not in ("system", "user", "assistant", "tool"): + normalized_role = "system" + role = cast(Literal["system", "user", "assistant", "tool"], normalized_role) messages.append( - ChatCompletionMessage( - role=item.role, + AppMessage( + role=role, content=str(getattr(item, "content", "")), ) ) - logger.debug(f"Normalized Responses input: {len(messages)} message items.") - return messages + compacted_messages: list[AppMessage] = [] + for msg in messages: + if not compacted_messages: + compacted_messages.append(msg) + continue + + last_msg = compacted_messages[-1] + if last_msg.role == "assistant" and msg.role == "assistant": + reasoning_parts = [] + if last_msg.reasoning_content: + reasoning_parts.append(last_msg.reasoning_content) + if msg.reasoning_content: + reasoning_parts.append(msg.reasoning_content) + + merged_content = [] + if isinstance(last_msg.content, str): + merged_content.append(AppContentItem(type="text", text=last_msg.content)) + elif isinstance(last_msg.content, list): + merged_content.extend(last_msg.content) + + if isinstance(msg.content, str): + merged_content.append(AppContentItem(type="text", text=msg.content)) + elif isinstance(msg.content, list): + merged_content.extend(msg.content) + + merged_tools = [] + if last_msg.tool_calls: + merged_tools.extend(last_msg.tool_calls) + if msg.tool_calls: + merged_tools.extend(msg.tool_calls) + + last_msg.reasoning_content = "\n\n".join(reasoning_parts) if reasoning_parts else None + last_msg.content = merged_content if merged_content else None + last_msg.tool_calls = merged_tools if merged_tools else None + else: + compacted_messages.append(msg) + logger.debug(f"Normalized Responses input: {len(compacted_messages)} message items.") + return compacted_messages -def _instructions_to_messages( + +def _convert_instructions_to_app_messages( instructions: str | list[ResponseInputMessage] | None, -) -> list[ChatCompletionMessage]: - """Normalize instructions payload into Message objects.""" +) -> list[AppMessage]: + """Normalize instructions payload into AppMessage objects.""" if not instructions: return [] if isinstance(instructions, str): - return [ChatCompletionMessage(role="system", content=instructions)] + return [AppMessage(role="system", content=instructions)] - instruction_messages: list[ChatCompletionMessage] = [] - for item in instructions: - if item.type and item.type != "message": + instruction_messages: list[AppMessage] = [] + for instruction in instructions: + if instruction.type and instruction.type != "message": continue - role = item.role - content = item.content + raw_role = instruction.role + normalized_role = {"developer": "system", "function": "tool"}.get(raw_role, raw_role) + if normalized_role not in ("system", "user", "assistant", "tool"): + normalized_role = "system" + role = cast(Literal["system", "user", "assistant", "tool"], normalized_role) + + content = instruction.content if isinstance(content, str): - instruction_messages.append(ChatCompletionMessage(role=role, content=content)) + instruction_messages.append(AppMessage(role=role, content=content)) else: - converted: list[ChatCompletionContentItem] = [] - reasoning_parts: list[str] = [] + converted: list[AppContentItem] = [] for part in content: if part.type in ("input_text", "output_text"): text_value = getattr(part, "text", "") or "" if text_value: - converted.append( - ChatCompletionRequestContentItem(type="text", text=text_value) - ) - elif part.type == "reasoning_text": - text_value = getattr(part, "text", "") or "" - if text_value: - reasoning_parts.append(text_value) + converted.append(AppContentItem(type="text", text=text_value)) elif part.type == "input_image": image_url = getattr(part, "image_url", None) if image_url: - converted.append( - ChatCompletionRequestContentItem( - type="image_url", - image_url={ - "url": image_url, - "detail": getattr(part, "detail", "auto") or "auto", - }, - ) - ) + converted.append(AppContentItem(type="image_url", url=image_url)) elif part.type == "input_file": - file_data = getattr(part, "file_data", None) file_url = getattr(part, "file_url", None) - if file_data or file_url: - file_info = {} - if file_data: - file_info["file_data"] = file_data - file_info["filename"] = getattr(part, "filename", None) - if file_url: - file_info["url"] = file_url + file_data = getattr(part, "file_data", None) + if file_url or file_data: converted.append( - ChatCompletionRequestContentItem(type="file", file=file_info) + AppContentItem( + type="file", + url=file_url, + file_data=file_data, + filename=getattr(part, "filename", None), + ) ) - instruction_messages.append( - ChatCompletionMessage( - role=role, - content=converted or None, - reasoning_content="\n".join(reasoning_parts) if reasoning_parts else None, - ) - ) + instruction_messages.append(AppMessage(role=role, content=converted or None)) return instruction_messages @@ -813,8 +907,8 @@ async def _find_reusable_session( db: LMDBConversationStore, pool: GeminiClientPool, model: Model, - messages: list[ChatCompletionMessage], -) -> tuple[ChatSession | None, GeminiClientWrapper | None, list[ChatCompletionMessage]]: + messages: list[AppMessage], +) -> tuple[ChatSession | None, GeminiClientWrapper | None, list[AppMessage]]: """Find an existing chat session matching the longest suitable history prefix.""" if len(messages) < 2: return None, None, messages @@ -982,12 +1076,12 @@ def flush(self) -> str: # --- Response Builders & Streaming --- -def _create_real_streaming_response( - generator: AsyncGenerator[ModelOutput], +async def _create_real_streaming_response( + resp_or_stream: AsyncGenerator[ModelOutput] | ModelOutput, completion_id: str, created_time: int, model_name: str, - messages: list[ChatCompletionMessage], + messages: list[AppMessage], db: LMDBConversationStore, model: Model, client_wrapper: GeminiClientWrapper, @@ -1005,56 +1099,44 @@ async def generate_stream(): has_started = False all_outputs: list[ModelOutput] = [] suppressor = StreamingOutputFilter() + + async def _make_async_gen(item: ModelOutput) -> AsyncGenerator[ModelOutput]: + yield item + + def make_chunk(delta_content: dict) -> str: + data = { + "id": completion_id, + "object": "chat.completion.chunk", + "created": created_time, + "model": model_name, + "choices": [{"index": 0, **delta_content}], + } + return f"data: {orjson.dumps(data).decode('utf-8')}\n\n" + try: + if hasattr(resp_or_stream, "__aiter__"): + generator = cast(AsyncGenerator[ModelOutput], resp_or_stream) + else: + generator = _make_async_gen(cast(ModelOutput, resp_or_stream)) + async for chunk in generator: all_outputs.append(chunk) if not has_started: - data = { - "id": completion_id, - "object": "chat.completion.chunk", - "created": created_time, - "model": model_name, - "choices": [ - {"index": 0, "delta": {"role": "assistant"}, "finish_reason": None} - ], - } - yield f"data: {orjson.dumps(data).decode('utf-8')}\n\n" + yield make_chunk({"delta": {"role": "assistant"}, "finish_reason": None}) has_started = True if t_delta := chunk.thoughts_delta: full_thoughts += t_delta - data = { - "id": completion_id, - "object": "chat.completion.chunk", - "created": created_time, - "model": model_name, - "choices": [ - { - "index": 0, - "delta": {"reasoning_content": t_delta}, - "finish_reason": None, - } - ], - } - yield f"data: {orjson.dumps(data).decode('utf-8')}\n\n" + yield make_chunk( + {"delta": {"reasoning_content": t_delta}, "finish_reason": None} + ) if text_delta := chunk.text_delta: full_text += text_delta if visible_delta := suppressor.process(text_delta): - data = { - "id": completion_id, - "object": "chat.completion.chunk", - "created": created_time, - "model": model_name, - "choices": [ - { - "index": 0, - "delta": {"content": visible_delta}, - "finish_reason": None, - } - ], - } - yield f"data: {orjson.dumps(data).decode('utf-8')}\n\n" + yield make_chunk( + {"delta": {"content": visible_delta}, "finish_reason": None} + ) except Exception as e: logger.exception(f"Error during OpenAI streaming: {e}") yield f"data: {orjson.dumps({'error': {'message': 'Streaming error occurred.', 'type': 'server_error', 'param': None, 'code': None}}).decode('utf-8')}\n\n" @@ -1068,18 +1150,9 @@ async def generate_stream(): full_thoughts = final_chunk.thoughts if remaining_text := suppressor.flush(): - data = { - "id": completion_id, - "object": "chat.completion.chunk", - "created": created_time, - "model": model_name, - "choices": [ - {"index": 0, "delta": {"content": remaining_text}, "finish_reason": None} - ], - } - yield f"data: {orjson.dumps(data).decode('utf-8')}\n\n" + yield make_chunk({"delta": {"content": remaining_text}, "finish_reason": None}) - _thoughts, assistant_text, storage_output, tool_calls = _process_llm_output( + _thoughts, assistant_text, storage_output, detected_tool_calls = _process_llm_output( full_thoughts, full_text, structured_requirement ) @@ -1092,7 +1165,7 @@ async def generate_stream(): images.append(img) seen_urls.add(img.url) - image_markdown = "" + image_results = [] seen_hashes = set() for image in images: try: @@ -1103,43 +1176,35 @@ async def generate_stream(): continue seen_hashes.add(fhash) - img_url = f"![{fname}]({base_url}images/{fname}?token={get_image_token(fname)})" - image_markdown += f"\n\n{img_url}" + img_url = f"{base_url}images/{fname}?token={get_image_token(fname)}" + image_results.append(img_url) except Exception as exc: logger.warning(f"Failed to process image in OpenAI stream: {exc}") - if image_markdown: - assistant_text += image_markdown - storage_output += image_markdown - data = { - "id": completion_id, - "object": "chat.completion.chunk", - "created": created_time, - "model": model_name, - "choices": [ - {"index": 0, "delta": {"content": image_markdown}, "finish_reason": None} - ], - } - yield f"data: {orjson.dumps(data).decode('utf-8')}\n\n" + if detected_tool_calls: + for call in detected_tool_calls: + tc_dict = { + "index": call.id, + "id": call.id, + "type": "function", + "function": {"name": call.function.name, "arguments": call.function.arguments}, + } + + yield make_chunk( + { + "delta": { + "tool_calls": [tc_dict], + } + } + ) - tool_calls_payload = [call.model_dump(mode="json") for call in tool_calls] - if tool_calls_payload: - tool_calls_delta = [ - {**call, "index": idx} for idx, call in enumerate(tool_calls_payload) - ] - data = { - "id": completion_id, - "object": "chat.completion.chunk", - "created": created_time, - "model": model_name, - "choices": [ - {"index": 0, "delta": {"tool_calls": tool_calls_delta}, "finish_reason": None} - ], - } - yield f"data: {orjson.dumps(data).decode('utf-8')}\n\n" + for image_url in image_results: + yield make_chunk({"delta": {"content": f"\n\n![Generated Image]({image_url})"}}) + + yield make_chunk({"delta": {}, "finish_reason": "stop"}) p_tok, c_tok, t_tok, r_tok = _calculate_usage( - messages, assistant_text, tool_calls, full_thoughts + messages, assistant_text, detected_tool_calls, full_thoughts ) usage = CompletionUsage( prompt_tokens=p_tok, @@ -1147,16 +1212,6 @@ async def generate_stream(): total_tokens=t_tok, completion_tokens_details={"reasoning_tokens": r_tok}, ) - data = { - "id": completion_id, - "object": "chat.completion.chunk", - "created": created_time, - "model": model_name, - "choices": [ - {"index": 0, "delta": {}, "finish_reason": "tool_calls" if tool_calls else "stop"} - ], - "usage": usage.model_dump(mode="json"), - } _persist_conversation( db, model.model_name, @@ -1164,21 +1219,26 @@ async def generate_stream(): session.metadata, messages, storage_output, - tool_calls, - full_thoughts, + detected_tool_calls, + ) + yield make_chunk( + { + "delta": {}, + "finish_reason": "tool_calls" if detected_tool_calls else "stop", + "usage": usage.model_dump(mode="json"), + } ) - yield f"data: {orjson.dumps(data).decode('utf-8')}\n\n" yield "data: [DONE]\n\n" return StreamingResponse(generate_stream(), media_type="text/event-stream") -def _create_responses_real_streaming_response( - generator: AsyncGenerator[ModelOutput], +async def _create_responses_real_streaming_response( + resp_or_stream: AsyncGenerator[ModelOutput] | ModelOutput, response_id: str, created_time: int, model_name: str, - messages: list[ChatCompletionMessage], + messages: list[AppMessage], db: LMDBConversationStore, model: Model, client_wrapper: GeminiClientWrapper, @@ -1257,6 +1317,15 @@ def make_event(etype: str, data: dict) -> str: suppressor = StreamingOutputFilter() try: + if hasattr(resp_or_stream, "__aiter__"): + generator = cast(AsyncGenerator[ModelOutput], resp_or_stream) + else: + + async def _make_async_gen(item: ModelOutput) -> AsyncGenerator[ModelOutput]: + yield item + + generator = _make_async_gen(cast(ModelOutput, resp_or_stream)) + async for chunk in generator: all_outputs.append(chunk) @@ -1624,7 +1693,6 @@ def make_event(etype: str, data: dict) -> str: messages, storage_output, detected_tool_calls, - full_thoughts, ) yield make_event( @@ -1670,9 +1738,10 @@ async def create_chat_completion( structured_requirement = _build_structured_requirement(request.response_format) extra_instr = [structured_requirement.instruction] if structured_requirement else None - # This ensures that server-injected system instructions are part of the history + app_messages = _convert_to_app_messages(request.messages) + msgs = _prepare_messages_for_model( - request.messages, + app_messages, request.tools, request.tool_choice, extra_instr, @@ -1784,7 +1853,7 @@ async def create_chat_completion( logger.debug(f"Detected tool calls: {reprlib.repr(tool_calls_payload)}") p_tok, c_tok, t_tok, r_tok = _calculate_usage( - request.messages, visible_output, tool_calls, thoughts + app_messages, visible_output, tool_calls, thoughts ) usage = { "prompt_tokens": p_tok, @@ -1810,7 +1879,6 @@ async def create_chat_completion( msgs, # Use prepared messages 'msgs' storage_output, tool_calls, - thoughts, ) return payload @@ -1824,7 +1892,7 @@ async def create_response( image_store: Path = Depends(get_image_store_dir), ): base_url = str(raw_request.base_url) - base_messages = _response_items_to_messages(request.input) + base_messages = _convert_responses_to_app_messages(request.input) struct_req = _build_structured_requirement(request.response_format) extra_instr = [struct_req.instruction] if struct_req else [] @@ -1847,7 +1915,7 @@ async def create_response( ) if img_instr: extra_instr.append(img_instr) - preface = _instructions_to_messages(request.instructions) + preface = _convert_instructions_to_app_messages(request.instructions) conv_messages = [*preface, *base_messages] if preface else base_messages model_tool_choice = ( request.tool_choice @@ -2026,6 +2094,5 @@ async def create_response( messages, storage_output, tool_calls, - thoughts, ) return payload diff --git a/app/services/client.py b/app/services/client.py index 77dfdf6..d2e5270 100644 --- a/app/services/client.py +++ b/app/services/client.py @@ -6,7 +6,7 @@ from gemini_webapi import GeminiClient, ModelOutput from loguru import logger -from app.models import ChatCompletionMessage +from app.models import AppMessage from app.utils import g_config from app.utils.helper import ( add_tag, @@ -69,7 +69,7 @@ def running(self) -> bool: @staticmethod async def process_message( - message: ChatCompletionMessage, + message: AppMessage, tempdir: Path | None = None, tagged: bool = True, wrap_tool: bool = True, @@ -91,28 +91,27 @@ async def process_message( if item_text or message.role == "tool": text_fragments.append(item_text) elif item.type == "image_url": - item_image_url = getattr(item, "image_url", None) - if not item_image_url: - raise ValueError("Image URL cannot be empty") - if url := item_image_url.get("url", None): - files.append(await save_url_to_tempfile(url, tempdir)) - else: - raise ValueError("Image URL must contain 'url' key") + item_media_url = getattr(item, "url", None) + if not item_media_url: + raise ValueError(f"{item.type} cannot be empty") + files.append(await save_url_to_tempfile(item_media_url, tempdir)) elif item.type == "file": - item_file = getattr(item, "file", None) - if not item_file: - raise ValueError("File cannot be empty") - if file_data := item_file.get("file_data", None): - filename = item_file.get("filename", "") + file_data = getattr(item, "file_data", None) + if file_data: + filename = getattr(item, "filename", "") or "" files.append(await save_file_to_tempfile(file_data, filename, tempdir)) - elif url := item_file.get("url", None): - files.append(await save_url_to_tempfile(url, tempdir)) else: - raise ValueError("File must contain 'file_data' or 'url' key") + raise ValueError("File must contain 'file_data'") + elif item.type == "input_audio": + file_data = getattr(item, "file_data", None) + if file_data: + files.append(await save_file_to_tempfile(file_data, "audio.wav", tempdir)) + else: + raise ValueError("input_audio must contain 'file_data' key") elif message.content is None and message.role == "tool": text_fragments.append("") elif message.content is not None: - raise ValueError("Unsupported message content type.") + raise ValueError(f"Unsupported message content type: {type(message.content)}") if message.role == "tool": tool_name = message.name or "unknown" @@ -161,7 +160,7 @@ async def process_message( @staticmethod async def process_conversation( - messages: list[ChatCompletionMessage], tempdir: Path | None = None + messages: list[AppMessage], tempdir: Path | None = None ) -> tuple[str, list[str | Path | bytes | io.BytesIO]]: conversation: list[str] = [] files: list[str | Path | bytes | io.BytesIO] = [] diff --git a/app/services/lmdb.py b/app/services/lmdb.py index 7a915d4..5a32089 100644 --- a/app/services/lmdb.py +++ b/app/services/lmdb.py @@ -12,17 +12,13 @@ from loguru import logger from app.models import ( - ChatCompletionAssistantContentItem, - ChatCompletionMessage, - ChatCompletionRequestContentItem, + AppMessage, ConversationInStore, ) from app.utils import g_config from app.utils.helper import ( - extract_tool_calls, normalize_llm_text, remove_tool_call_blocks, - strip_system_hints, unescape_text, ) from app.utils.singleton import Singleton @@ -57,17 +53,14 @@ def _normalize_text(text: str | None, fuzzy: bool = False) -> str | None: return text.strip() if text.strip() else None -def _hash_message(message: ChatCompletionMessage, fuzzy: bool = False) -> str: +def _hash_message(message: AppMessage, fuzzy: bool = False) -> str: """ Generate a stable, canonical hash for a single message. """ core_data: dict[str, Any] = { "role": message.role, - "name": message.name or None, - "tool_call_id": message.tool_call_id or None, - "reasoning_content": _normalize_text(message.reasoning_content) - if message.reasoning_content - else None, + "name": message.name, + "tool_call_id": message.tool_call_id, } content = message.content @@ -76,44 +69,22 @@ def _hash_message(message: ChatCompletionMessage, fuzzy: bool = False) -> str: elif isinstance(content, str): core_data["content"] = _normalize_text(content, fuzzy=fuzzy) elif isinstance(content, list): - _ContentItem = (ChatCompletionRequestContentItem, ChatCompletionAssistantContentItem) text_parts = [] for item in content: - text_val = "" - if isinstance(item, _ContentItem) and item.type == "text": - text_val = item.text or "" - elif isinstance(item, dict) and item.get("type") == "text": - text_val = item.get("text") or "" - - if text_val: - normalized_part = _normalize_text(text_val, fuzzy=fuzzy) + if item.type == "text" and item.text: + normalized_part = _normalize_text(item.text, fuzzy=fuzzy) if normalized_part: text_parts.append(normalized_part) - elif isinstance(item, _ContentItem): - item_type = item.type - if item_type == "image_url": - item_image_url = getattr(item, "image_url", None) - url = item_image_url.get("url") if item_image_url else None - text_parts.append(f"[image_url:{url}]") - elif item_type == "file": - item_file = getattr(item, "file", None) - url = (item_file.get("url") or item_file.get("filename")) if item_file else None - text_parts.append(f"[file:{url}]") - elif isinstance(item, dict): - item_type = item.get("type") - if item_type == "image_url": - url = item.get("image_url", {}).get("url") - text_parts.append(f"[image_url:{url}]") - elif item_type == "file": - url = item.get("file", {}).get("url") or item.get("file", {}).get("filename") - text_parts.append(f"[file:{url}]") + elif item.type != "text" and item.url: + text_parts.append(f"[{item.type}:{item.url}]") core_data["content"] = "\n".join(text_parts) if text_parts else None if message.tool_calls: calls_data = [] for tc in message.tool_calls: - args = tc.function.arguments or "{}" + args = tc.function.arguments + name = tc.function.name try: parsed = orjson.loads(args) canon_args = orjson.dumps(parsed, option=orjson.OPT_SORT_KEYS).decode("utf-8") @@ -122,7 +93,7 @@ def _hash_message(message: ChatCompletionMessage, fuzzy: bool = False) -> str: calls_data.append( { - "name": tc.function.name, + "name": name, "arguments": canon_args, } ) @@ -136,7 +107,7 @@ def _hash_message(message: ChatCompletionMessage, fuzzy: bool = False) -> str: def _hash_conversation( - client_id: str, model: str, messages: list[ChatCompletionMessage], fuzzy: bool = False + client_id: str, model: str, messages: list[AppMessage], fuzzy: bool = False ) -> str: """Generate a hash for a list of messages and model name, tied to a specific client_id.""" combined_hash = hashlib.sha256() @@ -271,29 +242,35 @@ def _remove_from_index(self, txn: Transaction, prefix: str, hash_val: str, stora def store( self, - conv: ConversationInStore, - custom_key: str | None = None, - ) -> str: + client_id: str, + model: str, + messages: list[AppMessage], + metadata: list[str | None], + ) -> None: """ Store a conversation model in LMDB. Args: - conv: Conversation model to store - custom_key: Optional custom key, if not provided, hash will be used - - Returns: - str: The key used to store the messages (hash or custom key) + client_id: The client identifier + model: The model name + messages: Unsanitized API messages + metadata: Session metadata """ - if not conv: + if not messages: raise ValueError("Messages list cannot be empty") - # Ensure consistent sanitization before hashing and storage - sanitized_messages = self.sanitize_messages(conv.messages) - conv.messages = sanitized_messages - + now = datetime.now() + conv = ConversationInStore( + model=model, + client_id=client_id, + metadata=metadata, + messages=messages, + created_at=now, + updated_at=now, + ) message_hash = _hash_conversation(conv.client_id, conv.model, conv.messages) fuzzy_hash = _hash_conversation(conv.client_id, conv.model, conv.messages, fuzzy=True) - storage_key = custom_key or message_hash + storage_key = message_hash now = datetime.now() if conv.created_at is None: @@ -310,7 +287,6 @@ def store( self._update_index(txn, self.FUZZY_LOOKUP_PREFIX, fuzzy_hash, storage_key) logger.debug(f"Stored {len(conv.messages)} messages with key: {storage_key[:12]}") - return storage_key except Error as e: logger.error(f"LMDB error while storing messages with key {storage_key[:12]}: {e}") @@ -349,10 +325,10 @@ def get(self, key: str) -> ConversationInStore | None: logger.error(f"Unexpected error retrieving messages with key {key[:12]}: {e}") return None - def find(self, model: str, messages: list[ChatCompletionMessage]) -> ConversationInStore | None: + def find(self, model: str, messages: list[AppMessage]) -> ConversationInStore | None: """ Search conversation data by message list. - Tries raw matching, then sanitized matching, and finally fuzzy matching. + Tries sanitized matching, and finally fuzzy matching. Args: model: Model name @@ -365,16 +341,7 @@ def find(self, model: str, messages: list[ChatCompletionMessage]) -> Conversatio return None if conv := self._find_by_message_list(model, messages): - logger.debug(f"Session found for '{model}' with {len(messages)} raw messages.") - return conv - - cleaned_messages = self.sanitize_messages(messages) - if cleaned_messages != messages and ( - conv := self._find_by_message_list(model, cleaned_messages) - ): - logger.debug( - f"Session found for '{model}' with {len(cleaned_messages)} cleaned messages." - ) + logger.debug(f"Session found for '{model}' with {len(messages)} cleaned messages.") return conv if conv := self._find_by_message_list(model, messages, fuzzy=True): @@ -389,7 +356,7 @@ def find(self, model: str, messages: list[ChatCompletionMessage]) -> Conversatio def _find_by_message_list( self, model: str, - messages: list[ChatCompletionMessage], + messages: list[AppMessage], fuzzy: bool = False, ) -> ConversationInStore | None: """ @@ -592,98 +559,3 @@ def close(self) -> None: def __del__(self): """Cleanup on destruction.""" self.close() - - @staticmethod - def sanitize_messages(messages: list[ChatCompletionMessage]) -> list[ChatCompletionMessage]: - """Clean all messages of internal markers, hints and normalize tool calls.""" - cleaned_messages = [] - for msg in messages: - update_data = {} - content_changed = False - - # Normalize reasoning_content - if msg.reasoning_content: - norm_reasoning = _normalize_text(msg.reasoning_content) - if norm_reasoning != msg.reasoning_content: - update_data["reasoning_content"] = norm_reasoning - content_changed = True - - if isinstance(msg.content, str): - text = msg.content - tool_calls = msg.tool_calls - - if msg.role == "assistant" and not tool_calls: - text, tool_calls = extract_tool_calls(text) - else: - text = strip_system_hints(text) - - normalized_content = text.strip() or None - - if normalized_content != msg.content: - update_data["content"] = normalized_content - content_changed = True - if tool_calls != msg.tool_calls: - update_data["tool_calls"] = tool_calls or None - content_changed = True - - elif isinstance(msg.content, list): - new_content = [] - all_extracted_calls = list(msg.tool_calls or []) - list_changed = False - reasoning_parts = [] - - for item in msg.content: - # Extract reasoning items and move them to reasoning_content - if ( - isinstance(item, ChatCompletionAssistantContentItem) - and item.type == "reasoning" - ): - val = item.text - if val: - norm_val = _normalize_text(val) - if norm_val: - reasoning_parts.append(norm_val) - list_changed = True - continue - - if ( - isinstance( - item, - (ChatCompletionRequestContentItem, ChatCompletionAssistantContentItem), - ) - and item.type == "text" - and item.text - ): - text = item.text - if msg.role == "assistant" and not msg.tool_calls: - text, extracted = extract_tool_calls(text) - if extracted: - all_extracted_calls.extend(extracted) - list_changed = True - else: - text = strip_system_hints(text) - - if text != item.text: - list_changed = True - item = item.model_copy(update={"text": text.strip() or None}) - new_content.append(item) - - if reasoning_parts: - existing_reason = update_data.get("reasoning_content") or msg.reasoning_content - all_reasoning = "\n\n".join( - r for r in ([existing_reason, *reasoning_parts]) if r - ) - if all_reasoning: - update_data["reasoning_content"] = all_reasoning - content_changed = True - - if list_changed: - update_data["content"] = new_content if new_content else None - update_data["tool_calls"] = all_extracted_calls or None - content_changed = True - - if content_changed: - cleaned_messages.append(msg.model_copy(update=update_data)) - else: - cleaned_messages.append(msg) - return cleaned_messages diff --git a/app/utils/helper.py b/app/utils/helper.py index 8bc4940..abab15d 100644 --- a/app/utils/helper.py +++ b/app/utils/helper.py @@ -14,7 +14,7 @@ from curl_cffi.requests import AsyncSession from loguru import logger -from app.models import ChatCompletionMessage, ChatCompletionMessageToolCall, FunctionCall +from app.models import AppMessage, ChatCompletionMessageToolCall, FunctionCall VALID_TAG_ROLES = {"user", "assistant", "system", "tool"} TOOL_WRAP_HINT = ( @@ -180,7 +180,7 @@ def estimate_tokens(text: str | None) -> int: async def save_file_to_tempfile( - file_in_base64: str, file_name: str = "", tempdir: Path | None = None + file_in_base64: str | bytes, file_name: str = "", tempdir: Path | None = None ) -> Path: """Decode base64 file data and save to a temporary file.""" with tempfile.NamedTemporaryFile( @@ -195,11 +195,15 @@ async def save_url_to_tempfile(url: str, tempdir: Path | None = None) -> Path: """Download content from a URL and save to a temporary file.""" data: bytes | None = None suffix: str | None = None - if url.startswith("data:image/"): + if url.startswith("data:"): metadata_part = url.split(",")[0] mime_type = metadata_part.split(":")[1].split(";")[0] data = base64.b64decode(url.split(",")[1]) - suffix = mimetypes.guess_extension(mime_type) or f".{mime_type.split('/')[1]}" + suffix = mimetypes.guess_extension(mime_type) + if not suffix and "/" in mime_type: + suffix = f".{mime_type.split('/')[1]}" + elif not suffix: + suffix = ".bin" else: async with AsyncSession(impersonate="chrome", allow_redirects=True) as client: resp = await client.get(url) @@ -348,7 +352,7 @@ def extract_tool_calls(text: str) -> tuple[str, list[ChatCompletionMessageToolCa return _process_tools_internal(text, extract=True) -def text_from_message(message: ChatCompletionMessage) -> str: +def text_from_message(message: AppMessage) -> str: """Concatenate text and tool arguments from a message for token estimation.""" base_text = "" if isinstance(message.content, str): From 88c6d4df19e8b068d88ba36f2067dc14cda79f42 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C6=B0u=20Quang=20V=C5=A9?= Date: Wed, 4 Mar 2026 16:14:36 +0700 Subject: [PATCH 180/291] Move the content storage in LMDB to a separate `AppMessage` Independent of the `ChatCompletionMessage` format, to simplify maintenance and improve scalability. --- app/server/chat.py | 16 ++++------------ 1 file changed, 4 insertions(+), 12 deletions(-) diff --git a/app/server/chat.py b/app/server/chat.py index 86d25ae..3d17293 100644 --- a/app/server/chat.py +++ b/app/server/chat.py @@ -1076,7 +1076,7 @@ def flush(self) -> str: # --- Response Builders & Streaming --- -async def _create_real_streaming_response( +def _create_real_streaming_response( resp_or_stream: AsyncGenerator[ModelOutput] | ModelOutput, completion_id: str, created_time: int, @@ -1233,7 +1233,7 @@ def make_chunk(delta_content: dict) -> str: return StreamingResponse(generate_stream(), media_type="text/event-stream") -async def _create_responses_real_streaming_response( +def _create_responses_real_streaming_response( resp_or_stream: AsyncGenerator[ModelOutput] | ModelOutput, response_id: str, created_time: int, @@ -1753,8 +1753,6 @@ async def create_chat_completion( if not remain: raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="No new messages.") - # For reused sessions, we only need to process the remaining messages. - # We don't re-inject system defaults to avoid duplicating instructions already in history. input_msgs = _prepare_messages_for_model( remain, request.tools, @@ -1771,7 +1769,6 @@ async def create_chat_completion( try: client = await pool.acquire() session = client.start_chat(model=model) - # Use the already prepared 'msgs' for a fresh session m_input, files = await GeminiClientWrapper.process_conversation(msgs, tmp_dir) except Exception as e: logger.exception("Error in preparing conversation") @@ -1795,14 +1792,13 @@ async def create_chat_completion( raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail=str(e)) from e if request.stream: - # Narrow for type checker assert not isinstance(resp_or_stream, ModelOutput) return _create_real_streaming_response( resp_or_stream, completion_id, created_time, request.model, - msgs, # Use prepared 'msgs' + msgs, db, model, client, @@ -1811,7 +1807,6 @@ async def create_chat_completion( structured_requirement, ) - # Narrow for type checker assert isinstance(resp_or_stream, ModelOutput) try: @@ -1876,7 +1871,7 @@ async def create_chat_completion( model.model_name, client.id, session.metadata, - msgs, # Use prepared messages 'msgs' + msgs, storage_output, tool_calls, ) @@ -1977,7 +1972,6 @@ async def create_response( raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail=str(e)) from e if request.stream: - # Narrow for type checker assert not isinstance(resp_or_stream, ModelOutput) return _create_responses_real_streaming_response( resp_or_stream, @@ -1995,7 +1989,6 @@ async def create_response( struct_req, ) - # Narrow for type checker assert isinstance(resp_or_stream, ModelOutput) try: @@ -2058,7 +2051,6 @@ async def create_response( if not contents: contents.append(ResponseOutputText(type="output_text", text="")) - # Aggregate images for storage image_markdown = "" for img_call in img_calls: fname = f"{img_call.id}.{img_call.output_format}" From dec4b3cfec13fe5b303e1e6d894a7d907ce7613c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C6=B0u=20Quang=20V=C5=A9?= Date: Wed, 4 Mar 2026 16:59:07 +0700 Subject: [PATCH 181/291] Move the content storage in LMDB to a separate `AppMessage` Independent of the `ChatCompletionMessage` format, to simplify maintenance and improve scalability. --- app/server/chat.py | 67 ++++++++++++++++++++------------------------- app/utils/helper.py | 14 ++++------ 2 files changed, 36 insertions(+), 45 deletions(-) diff --git a/app/server/chat.py b/app/server/chat.py index 3d17293..362e791 100644 --- a/app/server/chat.py +++ b/app/server/chat.py @@ -31,6 +31,7 @@ ChatCompletionRequest, ChatCompletionResponse, CompletionUsage, + FunctionCall, FunctionCallOutput, FunctionTool, ImageGeneration, @@ -132,7 +133,7 @@ async def _image_to_base64( def _calculate_usage( messages: list[AppMessage], assistant_text: str | None, - tool_calls: list[Any] | None, + tool_calls: list[AppToolCall] | None, thoughts: str | None = None, ) -> tuple[int, int, int, int]: """Calculate prompt, completion, total and reasoning tokens consistently.""" @@ -140,10 +141,7 @@ def _calculate_usage( tool_args_text = "" if tool_calls: for call in tool_calls: - if hasattr(call, "function"): - tool_args_text += call.function.arguments or "" - elif isinstance(call, dict): - tool_args_text += call.get("function", {}).get("arguments", "") + tool_args_text += call.function.arguments or "" completion_basis = assistant_text or "" if tool_args_text: @@ -167,7 +165,7 @@ def _create_responses_standard_payload( response_id: str, created_time: int, model_name: str, - detected_tool_calls: list[Any] | None, + detected_tool_calls: list[AppToolCall] | None, image_call_items: list[ImageGenerationCall], response_contents: list[ResponseOutputContent], usage: ResponseUsage, @@ -204,18 +202,10 @@ def _create_responses_standard_payload( output_items.extend( [ ResponseFunctionToolCall( - id=call.id if hasattr(call, "id") else call["id"], - call_id=call.id if hasattr(call, "id") else call["id"], - name=( - call.function.name - if hasattr(call, "function") - else call["function"]["name"] - ), - arguments=( - call.function.arguments - if hasattr(call, "function") - else call["function"]["arguments"] - ), + id=call.id, + call_id=call.id, + name=call.function.name, + arguments=call.function.arguments, status="completed", ) for call in detected_tool_calls @@ -249,21 +239,27 @@ def _create_chat_completion_standard_payload( created_time: int, model_name: str, visible_output: str | None, - tool_calls_payload: list[dict] | None, + tool_calls: list[AppToolCall] | None, finish_reason: Literal["stop", "length", "tool_calls", "content_filter"], usage: dict, reasoning_content: str | None = None, ) -> ChatCompletionResponse: """Unified factory for building Chat Completion response objects.""" - # Convert tool calls to Model objects if they are dicts - tool_calls = None - if tool_calls_payload: - tool_calls = [ChatCompletionMessageToolCall.model_validate(tc) for tc in tool_calls_payload] + tc_converted = None + if tool_calls: + tc_converted = [ + ChatCompletionMessageToolCall( + id=tc.id, + type="function", + function=FunctionCall(name=tc.function.name, arguments=tc.function.arguments), + ) + for tc in tool_calls + ] message = ChatCompletionMessage( role="assistant", content=visible_output or None, - tool_calls=tool_calls, + tool_calls=tc_converted, reasoning_content=reasoning_content or None, ) @@ -287,7 +283,7 @@ def _process_llm_output( thoughts: str | None, raw_text: str, structured_requirement: StructuredOutputRequirement | None, -) -> tuple[str | None, str, str, list[Any]]: +) -> tuple[str | None, str, str, list[AppToolCall]]: """ Post-process Gemini output to extract tool calls and prepare clean text for display and storage. Returns: (thoughts, visible_text, storage_output, tool_calls) @@ -366,15 +362,11 @@ def _convert_to_app_messages(messages: list[ChatCompletionMessage]) -> list[AppM if msg.tool_calls: tool_calls = [ AppToolCall( - id=tc.id if hasattr(tc, "id") else tc.get("id", ""), + id=tc.id, type="function", function=AppToolCallFunction( - name=tc.function.name - if hasattr(tc, "function") - else tc.get("function", {}).get("name", ""), - arguments=tc.function.arguments - if hasattr(tc, "function") - else tc.get("function", {}).get("arguments", ""), + name=tc.function.name, + arguments=tc.function.arguments, ), ) for tc in msg.tool_calls @@ -404,7 +396,7 @@ def _persist_conversation( metadata: list[str | None], messages: list[AppMessage], storage_output: str | None, - tool_calls: list[Any] | None, + tool_calls: list[AppToolCall] | None, ) -> str | None: """Unified logic to save conversation history to LMDB.""" try: @@ -1843,9 +1835,10 @@ async def create_chat_completion( visible_output += image_markdown storage_output += image_markdown - tool_calls_payload = [call.model_dump(mode="json") for call in tool_calls] - if tool_calls_payload: - logger.debug(f"Detected tool calls: {reprlib.repr(tool_calls_payload)}") + if tool_calls: + logger.debug( + f"Detected tool calls: {reprlib.repr([tc.model_dump(mode='json') for tc in tool_calls])}" + ) p_tok, c_tok, t_tok, r_tok = _calculate_usage( app_messages, visible_output, tool_calls, thoughts @@ -1861,7 +1854,7 @@ async def create_chat_completion( created_time, request.model, visible_output, - tool_calls_payload, + tool_calls or None, "tool_calls" if tool_calls else "stop", usage, thoughts, diff --git a/app/utils/helper.py b/app/utils/helper.py index abab15d..3b1d90f 100644 --- a/app/utils/helper.py +++ b/app/utils/helper.py @@ -14,7 +14,7 @@ from curl_cffi.requests import AsyncSession from loguru import logger -from app.models import AppMessage, ChatCompletionMessageToolCall, FunctionCall +from app.models import AppMessage, AppToolCall, AppToolCallFunction VALID_TAG_ROLES = {"user", "assistant", "system", "tool"} TOOL_WRAP_HINT = ( @@ -282,9 +282,7 @@ def strip_system_hints(text: str) -> str: return cleaned -def _process_tools_internal( - text: str, extract: bool = True -) -> tuple[str, list[ChatCompletionMessageToolCall]]: +def _process_tools_internal(text: str, extract: bool = True) -> tuple[str, list[AppToolCall]]: """ Extract tool metadata and return text stripped of technical markers. Arguments are parsed into JSON and assigned deterministic call IDs. @@ -292,7 +290,7 @@ def _process_tools_internal( if not text: return text, [] - tool_calls: list[ChatCompletionMessageToolCall] = [] + tool_calls: list[AppToolCall] = [] def _create_tool_call(name: str, raw_args: str) -> None: if not extract: @@ -327,10 +325,10 @@ def _create_tool_call(name: str, raw_args: str) -> None: call_id = f"call_{hashlib.sha256(seed).hexdigest()[:24]}" tool_calls.append( - ChatCompletionMessageToolCall( + AppToolCall( id=call_id, type="function", - function=FunctionCall(name=name, arguments=arguments), + function=AppToolCallFunction(name=name, arguments=arguments), ) ) @@ -347,7 +345,7 @@ def remove_tool_call_blocks(text: str) -> str: return cleaned -def extract_tool_calls(text: str) -> tuple[str, list[ChatCompletionMessageToolCall]]: +def extract_tool_calls(text: str) -> tuple[str, list[AppToolCall]]: """Extract tool calls and return cleaned text.""" return _process_tools_internal(text, extract=True) From ece7e7ad186f8749b06c7d1f8763fed0a6e82521 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C6=B0u=20Quang=20V=C5=A9?= Date: Wed, 4 Mar 2026 19:04:18 +0700 Subject: [PATCH 182/291] Move the content storage in LMDB to a separate `AppMessage` Independent of the `ChatCompletionMessage` format, to simplify maintenance and improve scalability. --- app/server/chat.py | 21 +++++++++++++-------- 1 file changed, 13 insertions(+), 8 deletions(-) diff --git a/app/server/chat.py b/app/server/chat.py index 362e791..029a8ea 100644 --- a/app/server/chat.py +++ b/app/server/chat.py @@ -1114,7 +1114,9 @@ def make_chunk(delta_content: dict) -> str: async for chunk in generator: all_outputs.append(chunk) if not has_started: - yield make_chunk({"delta": {"role": "assistant"}, "finish_reason": None}) + yield make_chunk( + {"delta": {"role": "assistant", "content": ""}, "finish_reason": None} + ) has_started = True if t_delta := chunk.thoughts_delta: @@ -1174,9 +1176,9 @@ def make_chunk(delta_content: dict) -> str: logger.warning(f"Failed to process image in OpenAI stream: {exc}") if detected_tool_calls: - for call in detected_tool_calls: + for idx, call in enumerate(detected_tool_calls): tc_dict = { - "index": call.id, + "index": idx, "id": call.id, "type": "function", "function": {"name": call.function.name, "arguments": call.function.arguments}, @@ -1186,14 +1188,18 @@ def make_chunk(delta_content: dict) -> str: { "delta": { "tool_calls": [tc_dict], - } + }, + "finish_reason": None, } ) for image_url in image_results: - yield make_chunk({"delta": {"content": f"\n\n![Generated Image]({image_url})"}}) - - yield make_chunk({"delta": {}, "finish_reason": "stop"}) + yield make_chunk( + { + "delta": {"content": f"\n\n![Generated Image]({image_url})"}, + "finish_reason": None, + } + ) p_tok, c_tok, t_tok, r_tok = _calculate_usage( messages, assistant_text, detected_tool_calls, full_thoughts @@ -1280,7 +1286,6 @@ def make_event(etype: str, data: dict) -> str: }, }, ) - yield make_event( "response.in_progress", { From 67632bf0388ae78030d4b2d0fc6140eb9375edad Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C6=B0u=20Quang=20V=C5=A9?= Date: Thu, 5 Mar 2026 20:01:20 +0700 Subject: [PATCH 183/291] Add support for retrieving dynamic models based on client accounts. --- app/server/chat.py | 30 +++++++++++++++++++++++++----- 1 file changed, 25 insertions(+), 5 deletions(-) diff --git a/app/server/chat.py b/app/server/chat.py index 029a8ea..78dc7d6 100644 --- a/app/server/chat.py +++ b/app/server/chat.py @@ -859,8 +859,8 @@ def _get_model_by_name(name: str) -> Model: return Model.from_name(name) -def _get_available_models() -> list[ModelData]: - """Return a list of available models based on configuration strategy.""" +async def _get_available_models(pool: GeminiClientPool) -> list[ModelData]: + """Return a list of available models based on the configuration strategy and per-client accounts.""" now = int(datetime.now(tz=UTC).timestamp()) strategy = g_config.gemini.model_strategy models_data = [] @@ -876,12 +876,31 @@ def _get_available_models() -> list[ModelData]: ) if strategy == "append": - custom_ids = {m.model_name for m in custom_models} + custom_ids = {m.id for m in models_data} + seen_model_ids = set() + + for client in pool.clients: + if not client.running(): + continue + + client_models = client.list_models() + if client_models: + for am in client_models: + if am.id not in custom_ids and am.id not in seen_model_ids: + models_data.append( + ModelData( + id=am.id, + created=now, + owned_by="gemini-web", + ) + ) + seen_model_ids.add(am.id) + for model in Model: m_name = model.model_name if not m_name or m_name == "unspecified": continue - if m_name in custom_ids: + if m_name in custom_ids or m_name in seen_model_ids: continue models_data.append( @@ -1711,7 +1730,8 @@ async def _make_async_gen(item: ModelOutput) -> AsyncGenerator[ModelOutput]: @router.get("/v1/models", response_model=ModelListResponse) async def list_models(api_key: str = Depends(verify_api_key)): - models = _get_available_models() + pool = GeminiClientPool() + models = await _get_available_models(pool) return ModelListResponse(data=models) From 9b7d3eb5e7512762cfd24c0c7d743d96cf93d6af Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C6=B0u=20Quang=20V=C5=A9?= Date: Fri, 6 Mar 2026 11:14:45 +0700 Subject: [PATCH 184/291] Update dependencies to latest versions --- pyproject.toml | 4 +-- uv.lock | 86 +++++++++++++++++++++++++------------------------- 2 files changed, 45 insertions(+), 45 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 9a8f666..459b6f4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -23,8 +23,8 @@ Repository = "https://github.com/Nativu5/Gemini-FastAPI" [project.optional-dependencies] dev = [ "pytest>=9.0.2", - "ruff>=0.15.4", - "ty>=0.0.20", + "ruff>=0.15.5", + "ty>=0.0.21", ] [dependency-groups] diff --git a/uv.lock b/uv.lock index b262688..6f688b1 100644 --- a/uv.lock +++ b/uv.lock @@ -176,8 +176,8 @@ dev = [{ name = "gemini-fastapi", extras = ["dev"] }] [[package]] name = "gemini-webapi" -version = "0.0.post243" -source = { git = "https://github.com/luuquangvu/Gemini-API.git?rev=move-httpx-to-curl_cffi#79d86aff4785dd45bd951910b8be75cf8122e9cd" } +version = "0.0.post251" +source = { git = "https://github.com/luuquangvu/Gemini-API.git?rev=move-httpx-to-curl_cffi#891b9fd643d66eef2970897b06f32d395a091ad4" } dependencies = [ { name = "curl-cffi" }, { name = "loguru" }, @@ -418,27 +418,27 @@ wheels = [ [[package]] name = "ruff" -version = "0.15.4" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/da/31/d6e536cdebb6568ae75a7f00e4b4819ae0ad2640c3604c305a0428680b0c/ruff-0.15.4.tar.gz", hash = "sha256:3412195319e42d634470cc97aa9803d07e9d5c9223b99bcb1518f0c725f26ae1", size = 4569550, upload-time = "2026-02-26T20:04:14.959Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/f2/82/c11a03cfec3a4d26a0ea1e571f0f44be5993b923f905eeddfc397c13d360/ruff-0.15.4-py3-none-linux_armv6l.whl", hash = "sha256:a1810931c41606c686bae8b5b9a8072adac2f611bb433c0ba476acba17a332e0", size = 10453333, upload-time = "2026-02-26T20:04:20.093Z" }, - { url = "https://files.pythonhosted.org/packages/ce/5d/6a1f271f6e31dffb31855996493641edc3eef8077b883eaf007a2f1c2976/ruff-0.15.4-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:5a1632c66672b8b4d3e1d1782859e98d6e0b4e70829530666644286600a33992", size = 10853356, upload-time = "2026-02-26T20:04:05.808Z" }, - { url = "https://files.pythonhosted.org/packages/b1/d8/0fab9f8842b83b1a9c2bf81b85063f65e93fb512e60effa95b0be49bfc54/ruff-0.15.4-py3-none-macosx_11_0_arm64.whl", hash = "sha256:a4386ba2cd6c0f4ff75252845906acc7c7c8e1ac567b7bc3d373686ac8c222ba", size = 10187434, upload-time = "2026-02-26T20:03:54.656Z" }, - { url = "https://files.pythonhosted.org/packages/85/cc/cc220fd9394eff5db8d94dec199eec56dd6c9f3651d8869d024867a91030/ruff-0.15.4-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b2496488bdfd3732747558b6f95ae427ff066d1fcd054daf75f5a50674411e75", size = 10535456, upload-time = "2026-02-26T20:03:52.738Z" }, - { url = "https://files.pythonhosted.org/packages/fa/0f/bced38fa5cf24373ec767713c8e4cadc90247f3863605fb030e597878661/ruff-0.15.4-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3f1c4893841ff2d54cbda1b2860fa3260173df5ddd7b95d370186f8a5e66a4ac", size = 10287772, upload-time = "2026-02-26T20:04:08.138Z" }, - { url = "https://files.pythonhosted.org/packages/2b/90/58a1802d84fed15f8f281925b21ab3cecd813bde52a8ca033a4de8ab0e7a/ruff-0.15.4-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:820b8766bd65503b6c30aaa6331e8ef3a6e564f7999c844e9a547c40179e440a", size = 11049051, upload-time = "2026-02-26T20:04:03.53Z" }, - { url = "https://files.pythonhosted.org/packages/d2/ac/b7ad36703c35f3866584564dc15f12f91cb1a26a897dc2fd13d7cb3ae1af/ruff-0.15.4-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c9fb74bab47139c1751f900f857fa503987253c3ef89129b24ed375e72873e85", size = 11890494, upload-time = "2026-02-26T20:04:10.497Z" }, - { url = "https://files.pythonhosted.org/packages/93/3d/3eb2f47a39a8b0da99faf9c54d3eb24720add1e886a5309d4d1be73a6380/ruff-0.15.4-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f80c98765949c518142b3a50a5db89343aa90f2c2bf7799de9986498ae6176db", size = 11326221, upload-time = "2026-02-26T20:04:12.84Z" }, - { url = "https://files.pythonhosted.org/packages/ff/90/bf134f4c1e5243e62690e09d63c55df948a74084c8ac3e48a88468314da6/ruff-0.15.4-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:451a2e224151729b3b6c9ffb36aed9091b2996fe4bdbd11f47e27d8f2e8888ec", size = 11168459, upload-time = "2026-02-26T20:04:00.969Z" }, - { url = "https://files.pythonhosted.org/packages/b5/e5/a64d27688789b06b5d55162aafc32059bb8c989c61a5139a36e1368285eb/ruff-0.15.4-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:a8f157f2e583c513c4f5f896163a93198297371f34c04220daf40d133fdd4f7f", size = 11104366, upload-time = "2026-02-26T20:03:48.099Z" }, - { url = "https://files.pythonhosted.org/packages/f1/f6/32d1dcb66a2559763fc3027bdd65836cad9eb09d90f2ed6a63d8e9252b02/ruff-0.15.4-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:917cc68503357021f541e69b35361c99387cdbbf99bd0ea4aa6f28ca99ff5338", size = 10510887, upload-time = "2026-02-26T20:03:45.771Z" }, - { url = "https://files.pythonhosted.org/packages/ff/92/22d1ced50971c5b6433aed166fcef8c9343f567a94cf2b9d9089f6aa80fe/ruff-0.15.4-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:e9737c8161da79fd7cfec19f1e35620375bd8b2a50c3e77fa3d2c16f574105cc", size = 10285939, upload-time = "2026-02-26T20:04:22.42Z" }, - { url = "https://files.pythonhosted.org/packages/e6/f4/7c20aec3143837641a02509a4668fb146a642fd1211846634edc17eb5563/ruff-0.15.4-py3-none-musllinux_1_2_i686.whl", hash = "sha256:291258c917539e18f6ba40482fe31d6f5ac023994ee11d7bdafd716f2aab8a68", size = 10765471, upload-time = "2026-02-26T20:03:58.924Z" }, - { url = "https://files.pythonhosted.org/packages/d0/09/6d2f7586f09a16120aebdff8f64d962d7c4348313c77ebb29c566cefc357/ruff-0.15.4-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:3f83c45911da6f2cd5936c436cf86b9f09f09165f033a99dcf7477e34041cbc3", size = 11263382, upload-time = "2026-02-26T20:04:24.424Z" }, - { url = "https://files.pythonhosted.org/packages/1b/fa/2ef715a1cd329ef47c1a050e10dee91a9054b7ce2fcfdd6a06d139afb7ec/ruff-0.15.4-py3-none-win32.whl", hash = "sha256:65594a2d557d4ee9f02834fcdf0a28daa8b3b9f6cb2cb93846025a36db47ef22", size = 10506664, upload-time = "2026-02-26T20:03:50.56Z" }, - { url = "https://files.pythonhosted.org/packages/d0/a8/c688ef7e29983976820d18710f955751d9f4d4eb69df658af3d006e2ba3e/ruff-0.15.4-py3-none-win_amd64.whl", hash = "sha256:04196ad44f0df220c2ece5b0e959c2f37c777375ec744397d21d15b50a75264f", size = 11651048, upload-time = "2026-02-26T20:04:17.191Z" }, - { url = "https://files.pythonhosted.org/packages/3e/0a/9e1be9035b37448ce2e68c978f0591da94389ade5a5abafa4cf99985d1b2/ruff-0.15.4-py3-none-win_arm64.whl", hash = "sha256:60d5177e8cfc70e51b9c5fad936c634872a74209f934c1e79107d11787ad5453", size = 10966776, upload-time = "2026-02-26T20:03:56.908Z" }, +version = "0.15.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/77/9b/840e0039e65fcf12758adf684d2289024d6140cde9268cc59887dc55189c/ruff-0.15.5.tar.gz", hash = "sha256:7c3601d3b6d76dce18c5c824fc8d06f4eef33d6df0c21ec7799510cde0f159a2", size = 4574214, upload-time = "2026-03-05T20:06:34.946Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/47/20/5369c3ce21588c708bcbe517a8fbe1a8dfdb5dfd5137e14790b1da71612c/ruff-0.15.5-py3-none-linux_armv6l.whl", hash = "sha256:4ae44c42281f42e3b06b988e442d344a5b9b72450ff3c892e30d11b29a96a57c", size = 10478185, upload-time = "2026-03-05T20:06:29.093Z" }, + { url = "https://files.pythonhosted.org/packages/44/ed/e81dd668547da281e5dce710cf0bc60193f8d3d43833e8241d006720e42b/ruff-0.15.5-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:6edd3792d408ebcf61adabc01822da687579a1a023f297618ac27a5b51ef0080", size = 10859201, upload-time = "2026-03-05T20:06:32.632Z" }, + { url = "https://files.pythonhosted.org/packages/c4/8f/533075f00aaf19b07c5cd6aa6e5d89424b06b3b3f4583bfa9c640a079059/ruff-0.15.5-py3-none-macosx_11_0_arm64.whl", hash = "sha256:89f463f7c8205a9f8dea9d658d59eff49db05f88f89cc3047fb1a02d9f344010", size = 10184752, upload-time = "2026-03-05T20:06:40.312Z" }, + { url = "https://files.pythonhosted.org/packages/66/0e/ba49e2c3fa0395b3152bad634c7432f7edfc509c133b8f4529053ff024fb/ruff-0.15.5-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ba786a8295c6574c1116704cf0b9e6563de3432ac888d8f83685654fe528fd65", size = 10534857, upload-time = "2026-03-05T20:06:19.581Z" }, + { url = "https://files.pythonhosted.org/packages/59/71/39234440f27a226475a0659561adb0d784b4d247dfe7f43ffc12dd02e288/ruff-0.15.5-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:fd4b801e57955fe9f02b31d20375ab3a5c4415f2e5105b79fb94cf2642c91440", size = 10309120, upload-time = "2026-03-05T20:06:00.435Z" }, + { url = "https://files.pythonhosted.org/packages/f5/87/4140aa86a93df032156982b726f4952aaec4a883bb98cb6ef73c347da253/ruff-0.15.5-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:391f7c73388f3d8c11b794dbbc2959a5b5afe66642c142a6effa90b45f6f5204", size = 11047428, upload-time = "2026-03-05T20:05:51.867Z" }, + { url = "https://files.pythonhosted.org/packages/5a/f7/4953e7e3287676f78fbe85e3a0ca414c5ca81237b7575bdadc00229ac240/ruff-0.15.5-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8dc18f30302e379fe1e998548b0f5e9f4dff907f52f73ad6da419ea9c19d66c8", size = 11914251, upload-time = "2026-03-05T20:06:22.887Z" }, + { url = "https://files.pythonhosted.org/packages/77/46/0f7c865c10cf896ccf5a939c3e84e1cfaeed608ff5249584799a74d33835/ruff-0.15.5-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1cc6e7f90087e2d27f98dc34ed1b3ab7c8f0d273cc5431415454e22c0bd2a681", size = 11333801, upload-time = "2026-03-05T20:05:57.168Z" }, + { url = "https://files.pythonhosted.org/packages/d3/01/a10fe54b653061585e655f5286c2662ebddb68831ed3eaebfb0eb08c0a16/ruff-0.15.5-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c1cb7169f53c1ddb06e71a9aebd7e98fc0fea936b39afb36d8e86d36ecc2636a", size = 11206821, upload-time = "2026-03-05T20:06:03.441Z" }, + { url = "https://files.pythonhosted.org/packages/7a/0d/2132ceaf20c5e8699aa83da2706ecb5c5dcdf78b453f77edca7fb70f8a93/ruff-0.15.5-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:9b037924500a31ee17389b5c8c4d88874cc6ea8e42f12e9c61a3d754ff72f1ca", size = 11133326, upload-time = "2026-03-05T20:06:25.655Z" }, + { url = "https://files.pythonhosted.org/packages/72/cb/2e5259a7eb2a0f87c08c0fe5bf5825a1e4b90883a52685524596bfc93072/ruff-0.15.5-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:65bb414e5b4eadd95a8c1e4804f6772bbe8995889f203a01f77ddf2d790929dd", size = 10510820, upload-time = "2026-03-05T20:06:37.79Z" }, + { url = "https://files.pythonhosted.org/packages/ff/20/b67ce78f9e6c59ffbdb5b4503d0090e749b5f2d31b599b554698a80d861c/ruff-0.15.5-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:d20aa469ae3b57033519c559e9bc9cd9e782842e39be05b50e852c7c981fa01d", size = 10302395, upload-time = "2026-03-05T20:05:54.504Z" }, + { url = "https://files.pythonhosted.org/packages/5f/e5/719f1acccd31b720d477751558ed74e9c88134adcc377e5e886af89d3072/ruff-0.15.5-py3-none-musllinux_1_2_i686.whl", hash = "sha256:15388dd28c9161cdb8eda68993533acc870aa4e646a0a277aa166de9ad5a8752", size = 10754069, upload-time = "2026-03-05T20:06:06.422Z" }, + { url = "https://files.pythonhosted.org/packages/c3/9c/d1db14469e32d98f3ca27079dbd30b7b44dbb5317d06ab36718dee3baf03/ruff-0.15.5-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:b30da330cbd03bed0c21420b6b953158f60c74c54c5f4c1dabbdf3a57bf355d2", size = 11304315, upload-time = "2026-03-05T20:06:10.867Z" }, + { url = "https://files.pythonhosted.org/packages/28/3a/950367aee7c69027f4f422059227b290ed780366b6aecee5de5039d50fa8/ruff-0.15.5-py3-none-win32.whl", hash = "sha256:732e5ee1f98ba5b3679029989a06ca39a950cced52143a0ea82a2102cb592b74", size = 10551676, upload-time = "2026-03-05T20:06:13.705Z" }, + { url = "https://files.pythonhosted.org/packages/b8/00/bf077a505b4e649bdd3c47ff8ec967735ce2544c8e4a43aba42ee9bf935d/ruff-0.15.5-py3-none-win_amd64.whl", hash = "sha256:821d41c5fa9e19117616c35eaa3f4b75046ec76c65e7ae20a333e9a8696bc7fe", size = 11678972, upload-time = "2026-03-05T20:06:45.379Z" }, + { url = "https://files.pythonhosted.org/packages/fe/4e/cd76eca6db6115604b7626668e891c9dd03330384082e33662fb0f113614/ruff-0.15.5-py3-none-win_arm64.whl", hash = "sha256:b498d1c60d2fe5c10c45ec3f698901065772730b411f164ae270bb6bfcc4740b", size = 10965572, upload-time = "2026-03-05T20:06:16.984Z" }, ] [[package]] @@ -455,26 +455,26 @@ wheels = [ [[package]] name = "ty" -version = "0.0.20" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/56/95/8de69bb98417227b01f1b1d743c819d6456c9fd140255b6124b05b17dfd6/ty-0.0.20.tar.gz", hash = "sha256:ebba6be7974c14efbb2a9adda6ac59848f880d7259f089dfa72a093039f1dcc6", size = 5262529, upload-time = "2026-03-02T15:51:36.587Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/0b/2c/718abe48393e521bf852cd6b0f984766869b09c258d6e38a118768a91731/ty-0.0.20-py3-none-linux_armv6l.whl", hash = "sha256:7cc12769c169c9709a829c2248ee2826b7aae82e92caeac813d856f07c021eae", size = 10333656, upload-time = "2026-03-02T15:51:56.461Z" }, - { url = "https://files.pythonhosted.org/packages/41/0e/eb1c4cc4a12862e2327b72657bcebb10b7d9f17046f1bdcd6457a0211615/ty-0.0.20-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:3b777c1bf13bc0a95985ebb8a324b8668a4a9b2e514dde5ccf09e4d55d2ff232", size = 10168505, upload-time = "2026-03-02T15:51:51.895Z" }, - { url = "https://files.pythonhosted.org/packages/89/7f/10230798e673f0dd3094dfd16e43bfd90e9494e7af6e8e7db516fb431ddf/ty-0.0.20-py3-none-macosx_11_0_arm64.whl", hash = "sha256:b2a4a7db48bf8cba30365001bc2cad7fd13c1a5aacdd704cc4b7925de8ca5eb3", size = 9678510, upload-time = "2026-03-02T15:51:48.451Z" }, - { url = "https://files.pythonhosted.org/packages/7a/3d/59d9159577494edd1728f7db77b51bb07884bd21384f517963114e3ab5f6/ty-0.0.20-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6846427b8b353a43483e9c19936dc6a25612573b44c8f7d983dfa317e7f00d4c", size = 10162926, upload-time = "2026-03-02T15:51:40.558Z" }, - { url = "https://files.pythonhosted.org/packages/9c/a8/b7273eec3e802f78eb913fbe0ce0c16ef263723173e06a5776a8359b2c66/ty-0.0.20-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:245ceef5bd88df366869385cf96411cb14696334f8daa75597cf7e41c3012eb8", size = 10171702, upload-time = "2026-03-02T15:51:44.069Z" }, - { url = "https://files.pythonhosted.org/packages/9f/32/5f1144f2f04a275109db06e3498450c4721554215b80ae73652ef412eeab/ty-0.0.20-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c4d21d1cdf67a444d3c37583c17291ddba9382a9871021f3f5d5735e09e85efe", size = 10682552, upload-time = "2026-03-02T15:51:33.102Z" }, - { url = "https://files.pythonhosted.org/packages/6a/db/9f1f637310792f12bd6ed37d5fc8ab39ba1a9b0c6c55a33865e9f1cad840/ty-0.0.20-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:bd4ffd907d1bd70e46af9e9a2f88622f215e1bf44658ea43b32c2c0b357299e4", size = 11242605, upload-time = "2026-03-02T15:51:34.895Z" }, - { url = "https://files.pythonhosted.org/packages/1a/68/cc9cae2e732fcfd20ccdffc508407905a023fc8493b8771c392d915528dc/ty-0.0.20-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b6594b58d8b0e9d16a22b3045fc1305db4b132c8d70c17784ab8c7a7cc986807", size = 10974655, upload-time = "2026-03-02T15:51:46.011Z" }, - { url = "https://files.pythonhosted.org/packages/1c/c1/b9e3e3f28fe63486331e653f6aeb4184af8b1fe80542fcf74d2dda40a93d/ty-0.0.20-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3662f890518ce6cf4d7568f57d03906912d2afbf948a01089a28e325b1ef198c", size = 10761325, upload-time = "2026-03-02T15:51:26.818Z" }, - { url = "https://files.pythonhosted.org/packages/39/9e/67db935bdedf219a00fb69ec5437ba24dab66e0f2e706dd54a4eca234b84/ty-0.0.20-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:0e3ffbae58f9f0d17cdc4ac6d175ceae560b7ed7d54f9ddfb1c9f31054bcdc2c", size = 10145793, upload-time = "2026-03-02T15:51:38.562Z" }, - { url = "https://files.pythonhosted.org/packages/c7/de/b0eb815d4dc5a819c7e4faddc2a79058611169f7eef07ccc006531ce228c/ty-0.0.20-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:176e52bc8bb00b0e84efd34583962878a447a3a0e34ecc45fd7097a37554261b", size = 10189640, upload-time = "2026-03-02T15:51:50.202Z" }, - { url = "https://files.pythonhosted.org/packages/b8/71/63734923965cbb70df1da3e93e4b8875434e326b89e9f850611122f279bf/ty-0.0.20-py3-none-musllinux_1_2_i686.whl", hash = "sha256:b2bc73025418e976ca4143dde71fb9025a90754a08ac03e6aa9b80d4bed1294b", size = 10370568, upload-time = "2026-03-02T15:51:42.295Z" }, - { url = "https://files.pythonhosted.org/packages/32/a0/a532c2048533347dff48e9ca98bd86d2c224356e101688a8edaf8d6973fb/ty-0.0.20-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:d52f7c9ec6e363e094b3c389c344d5a140401f14a77f0625e3f28c21918552f5", size = 10853999, upload-time = "2026-03-02T15:51:58.963Z" }, - { url = "https://files.pythonhosted.org/packages/48/88/36c652c658fe96658043e4abc8ea97801de6fb6e63ab50aaa82807bff1d8/ty-0.0.20-py3-none-win32.whl", hash = "sha256:c7d32bfe93f8fcaa52b6eef3f1b930fd7da410c2c94e96f7412c30cfbabf1d17", size = 9744206, upload-time = "2026-03-02T15:51:54.183Z" }, - { url = "https://files.pythonhosted.org/packages/ff/a7/a4a13bed1d7fd9d97aaa3c5bb5e6d3e9a689e6984806cbca2ab4c9233cac/ty-0.0.20-py3-none-win_amd64.whl", hash = "sha256:a5e10f40fc4a0a1cbcb740a4aad5c7ce35d79f030836ea3183b7a28f43170248", size = 10711999, upload-time = "2026-03-02T15:51:29.212Z" }, - { url = "https://files.pythonhosted.org/packages/8d/7e/6bfd748a9f4ff9267ed3329b86a0f02cdf6ab49f87bc36c8a164852f99fc/ty-0.0.20-py3-none-win_arm64.whl", hash = "sha256:53f7a5c12c960e71f160b734f328eff9a35d578af4b67a36b0bb5990ac5cdc27", size = 10150143, upload-time = "2026-03-02T15:51:31.283Z" }, +version = "0.0.21" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ee/20/2ba8fd9493c89c41dfe9dbb73bc70a28b28028463bc0d2897ba8be36230a/ty-0.0.21.tar.gz", hash = "sha256:a4c2ba5d67d64df8fcdefd8b280ac1149d24a73dbda82fa953a0dff9d21400ed", size = 5297967, upload-time = "2026-03-06T01:57:13.809Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/36/70/edf38bb37517531681d1c37f5df64744e5ad02673c02eb48447eae4bea08/ty-0.0.21-py3-none-linux_armv6l.whl", hash = "sha256:7bdf2f572378de78e1f388d24691c89db51b7caf07cf90f2bfcc1d6b18b70a76", size = 10299222, upload-time = "2026-03-06T01:57:16.64Z" }, + { url = "https://files.pythonhosted.org/packages/72/62/0047b0bd19afeefbc7286f20a5f78a2aa39f92b4d89853f0d7185ab89edc/ty-0.0.21-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:7e9613994610431ab8625025bd2880dbcb77c5c9fabdd21134cda12d840a529d", size = 10130513, upload-time = "2026-03-06T01:57:29.93Z" }, + { url = "https://files.pythonhosted.org/packages/a2/20/0b93a9e91aaed23155780258cdfdb4726ef68b6985378ac069bc427291a0/ty-0.0.21-py3-none-macosx_11_0_arm64.whl", hash = "sha256:56d3b198b64dd0a19b2b66e257deaed2ecea568e722ae5352f3c6fb62027f89d", size = 9605425, upload-time = "2026-03-06T01:57:27.115Z" }, + { url = "https://files.pythonhosted.org/packages/ea/fd/9945e2fa2996a1287b1e1d7ce050e97e1f420233b271e770934bfa0880a0/ty-0.0.21-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d23d2c34f7a77d974bb08f0860ef700addc8a683d81a0319f71c08f87506cfd0", size = 10108298, upload-time = "2026-03-06T01:57:35.429Z" }, + { url = "https://files.pythonhosted.org/packages/52/e7/4ec52fcb15f3200826c9f048472c062549a05b0d1ef0b51f32d527b513c4/ty-0.0.21-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:56b01fd2519637a4ca88344f61c96225f540c98ff18bca321d4eaa7bb0f7aa2f", size = 10121556, upload-time = "2026-03-06T01:57:03.242Z" }, + { url = "https://files.pythonhosted.org/packages/ee/c0/ad457be2a8abea0f25549598bd098554540ced66229488daa0d558dad3c8/ty-0.0.21-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e9de7e11c63c6afc40f3e9ba716374add171aee7fabc70b5146a510705c6d41b", size = 10603264, upload-time = "2026-03-06T01:56:52.134Z" }, + { url = "https://files.pythonhosted.org/packages/f8/5b/2ecc7a2175243a4bcb72f5298ae41feabbb93b764bb0dc45722f3752c2c2/ty-0.0.21-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:62f7f5b235c4f7876db305c36997aea07b7af29b1a068f373d0e2547e25f32ff", size = 11196428, upload-time = "2026-03-06T01:57:32.94Z" }, + { url = "https://files.pythonhosted.org/packages/37/f5/aff507d6a901f328ef96a298032b0c11aaaf950a146ed7dd3b5bf2cd3acf/ty-0.0.21-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ee8399f7c453a425291e6688efe430cfae7ab0ac4ffd50eba9f872bf878b54f6", size = 10866355, upload-time = "2026-03-06T01:56:57.831Z" }, + { url = "https://files.pythonhosted.org/packages/be/30/822bbcb92d55b65989aa7ed06d9585f28ade9c9447369194ed4b0fb3b5b9/ty-0.0.21-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:210e7568c9f886c4d01308d751949ee714ad7ad9d7d928d2ba90d329dd880367", size = 10738177, upload-time = "2026-03-06T01:57:11.256Z" }, + { url = "https://files.pythonhosted.org/packages/57/cc/46e7991b6469e93ac2c7e533a028983e402485580150ac864c56352a3a82/ty-0.0.21-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:53508e345b11569f78b21ba8e2b4e61df38a9754947fb3cd9f2ef574367338fb", size = 10079158, upload-time = "2026-03-06T01:57:00.516Z" }, + { url = "https://files.pythonhosted.org/packages/15/c2/0bbdadfbd008240f8f1a87dc877433cb3884436097926107ccf06e618199/ty-0.0.21-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:553e43571f4a35604c36cfd07d8b61a5eb7a714e3c67f8c4ff2cf674fefbaef9", size = 10150535, upload-time = "2026-03-06T01:57:08.815Z" }, + { url = "https://files.pythonhosted.org/packages/c5/b5/2dbdb7b57b5362200ef0a39738ebd31331726328336def0143ac097ee59d/ty-0.0.21-py3-none-musllinux_1_2_i686.whl", hash = "sha256:666f6822e3b9200abfa7e95eb0ddd576460adb8d66b550c0ad2c70abc84a2048", size = 10319803, upload-time = "2026-03-06T01:57:19.106Z" }, + { url = "https://files.pythonhosted.org/packages/72/84/70e52c0b7abc7c2086f9876ef454a73b161d3125315536d8d7e911c94ca4/ty-0.0.21-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:a0854d008347ce4a5fb351af132f660a390ab2a1163444d075251d43e6f74b9b", size = 10826239, upload-time = "2026-03-06T01:57:21.727Z" }, + { url = "https://files.pythonhosted.org/packages/a1/8a/1f72480fd013bbc6cd1929002abbbcde9a0b08ead6a15154de9d7f7fa37e/ty-0.0.21-py3-none-win32.whl", hash = "sha256:bef3ab4c7b966bcc276a8ac6c11b63ba222d21355b48d471ea782c4104eee4e0", size = 9693196, upload-time = "2026-03-06T01:57:24.126Z" }, + { url = "https://files.pythonhosted.org/packages/8d/f8/1104808b875c26c640e536945753a78562d606bef4e241d9dbf3d92477f6/ty-0.0.21-py3-none-win_amd64.whl", hash = "sha256:a709d576e5bea84b745d43058d8b9cd4f27f74a0b24acb4b0cbb7d3d41e0d050", size = 10668660, upload-time = "2026-03-06T01:56:55.06Z" }, + { url = "https://files.pythonhosted.org/packages/1b/b8/25e0adc404bbf986977657b25318991f93097b49f8aea640d93c0b0db68e/ty-0.0.21-py3-none-win_arm64.whl", hash = "sha256:f72047996598ac20553fb7e21ba5741e3c82dee4e9eadf10d954551a5fe09391", size = 10104161, upload-time = "2026-03-06T01:57:06.072Z" }, ] [[package]] From 11a709abe5a73d8b31f53bf9124b1430bfb1c351 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C6=B0u=20Quang=20V=C5=A9?= Date: Fri, 6 Mar 2026 13:43:07 +0700 Subject: [PATCH 185/291] Add support for music and video generation, and update the persistent path from `images` to `media` to accommodate multiple media types. --- app/main.py | 8 +- app/server/chat.py | 364 ++++++++++++++++++++++++++++++--------- app/server/images.py | 18 -- app/server/media.py | 18 ++ app/server/middleware.py | 28 +-- app/utils/config.py | 6 +- config/config.yaml | 2 +- uv.lock | 4 +- 8 files changed, 325 insertions(+), 123 deletions(-) delete mode 100644 app/server/images.py create mode 100644 app/server/media.py diff --git a/app/main.py b/app/main.py index 20d15b0..9782726 100644 --- a/app/main.py +++ b/app/main.py @@ -6,11 +6,11 @@ from .server.chat import router as chat_router from .server.health import router as health_router -from .server.images import router as images_router +from .server.media import router as media_router from .server.middleware import ( add_cors_middleware, add_exception_handler, - cleanup_expired_images, + cleanup_expired_media, ) from .services import GeminiClientPool, LMDBConversationStore @@ -33,7 +33,7 @@ async def _run_retention_cleanup(stop_event: asyncio.Event) -> None: while not stop_event.is_set(): try: store.cleanup_expired() - cleanup_expired_images(store.retention_days) + cleanup_expired_media(store.retention_days) except Exception: logger.exception("LMDB retention cleanup task failed.") @@ -99,6 +99,6 @@ def create_app() -> FastAPI: app.include_router(health_router, tags=["Health"]) app.include_router(chat_router, tags=["Chat"]) - app.include_router(images_router, tags=["Images"]) + app.include_router(media_router, tags=["Media"]) return app diff --git a/app/server/chat.py b/app/server/chat.py index 78dc7d6..ff62840 100644 --- a/app/server/chat.py +++ b/app/server/chat.py @@ -16,6 +16,7 @@ from gemini_webapi.client import ChatSession from gemini_webapi.constants import Model from gemini_webapi.types.image import GeneratedImage, Image +from gemini_webapi.types.video import GeneratedMedia, GeneratedVideo from loguru import logger from app.models import ( @@ -54,8 +55,8 @@ ToolChoiceTypes, ) from app.server.middleware import ( - get_image_store_dir, - get_image_token, + get_media_store_dir, + get_media_token, get_temp_dir, verify_api_key, ) @@ -130,6 +131,38 @@ async def _image_to_base64( return base64.b64encode(data).decode("ascii"), width, height, filename, file_hash +async def _media_to_local_file( + media: GeneratedVideo | GeneratedMedia, temp_dir: Path +) -> dict[str, tuple[str, str]]: + """Persist media and return dict mapping type to (filename, hash)""" + try: + saved_paths = await media.save(path=str(temp_dir)) + except Exception as e: + logger.warning(f"Failed to save media: {e}") + return {} + + results = {} + for mtype, spath in saved_paths.items(): + if not spath: + continue + + original_path = Path(spath) + data = original_path.read_bytes() + suffix = original_path.suffix + + if not suffix: + suffix = ".mp4" if "video" in mtype else ".mp3" + + random_name = f"media_{uuid.uuid4().hex}{suffix}" + new_path = temp_dir / random_name + original_path.rename(new_path) + + fhash = hashlib.sha256(data).hexdigest() + results[mtype] = (random_name, fhash) + + return results + + def _calculate_usage( messages: list[AppMessage], assistant_text: str | None, @@ -1170,30 +1203,86 @@ def make_chunk(delta_content: dict) -> str: ) images = [] - seen_urls = set() + seen_image_urls = set() + media_items: list[GeneratedVideo | GeneratedMedia] = [] + seen_media_urls = set() + for out in all_outputs: if out.images: for img in out.images: - if img.url not in seen_urls: + if img.url not in seen_image_urls: images.append(img) - seen_urls.add(img.url) + seen_image_urls.add(img.url) + + m_list = (out.videos or []) + (out.media or []) + for m in m_list: + m_url = getattr(m, "url", None) or getattr(m, "mp3_url", None) + if m_url and m_url not in seen_media_urls: + media_items.append(m) + seen_media_urls.add(m_url) image_results = [] seen_hashes = set() for image in images: try: - image_store = get_image_store_dir() - _, _, _, fname, fhash = await _image_to_base64(image, image_store) + media_store = get_media_store_dir() + _, _, _, fname, fhash = await _image_to_base64(image, media_store) if fhash in seen_hashes: - (image_store / fname).unlink(missing_ok=True) + (media_store / fname).unlink(missing_ok=True) continue seen_hashes.add(fhash) - - img_url = f"{base_url}images/{fname}?token={get_image_token(fname)}" - image_results.append(img_url) + img_url = f"{base_url}media/{fname}?token={get_media_token(fname)}" + title = getattr(image, "title", "Image") + image_results.append(f"![{title}]({img_url})") except Exception as exc: logger.warning(f"Failed to process image in OpenAI stream: {exc}") + media_results = [] + seen_media_hashes = set() + for media_item in media_items: + try: + media_store = get_media_store_dir() + m_dict = await _media_to_local_file(media_item, media_store) + + m_urls = {} + for mtype, (random_name, fhash) in m_dict.items(): + if fhash in seen_media_hashes: + (media_store / random_name).unlink(missing_ok=True) + continue + seen_media_hashes.add(fhash) + m_urls[mtype] = ( + f"{base_url}media/{random_name}?token={get_media_token(random_name)}" + ) + + media_url = m_urls.get("video") or m_urls.get("audio") + thumb_url = m_urls.get("video_thumbnail") or m_urls.get("audio_thumbnail") + + title = getattr(media_item, "title", "Media") + if thumb_url and media_url: + media_results.append(f"[![{title}]({thumb_url})]({media_url})") + elif media_url: + media_results.append(f"[{title}]({media_url})") + elif thumb_url: + media_results.append(f"![{title}]({thumb_url})") + except Exception as exc: + logger.warning(f"Failed to process media in OpenAI stream: {exc}") + + for image_url in image_results: + yield make_chunk( + { + "delta": {"content": f"\n\n{image_url}"}, + "finish_reason": None, + } + ) + + for media_md in media_results: + yield make_chunk( + { + "delta": {"content": f"\n\n{media_md}"}, + "finish_reason": None, + } + ) + if detected_tool_calls: for idx, call in enumerate(detected_tool_calls): tc_dict = { @@ -1212,14 +1301,6 @@ def make_chunk(delta_content: dict) -> str: } ) - for image_url in image_results: - yield make_chunk( - { - "delta": {"content": f"\n\n![Generated Image]({image_url})"}, - "finish_reason": None, - } - ) - p_tok, c_tok, t_tok, r_tok = _calculate_usage( messages, assistant_text, detected_tool_calls, full_thoughts ) @@ -1261,7 +1342,7 @@ def _create_responses_real_streaming_response( client_wrapper: GeminiClientWrapper, session: ChatSession, request: ResponseCreateRequest, - image_store: Path, + media_store: Path, base_url: str, structured_requirement: StructuredOutputRequirement | None = None, ) -> StreamingResponse: @@ -1596,57 +1677,107 @@ async def _make_async_gen(item: ModelOutput) -> AsyncGenerator[ModelOutput]: final_response_contents: list[ResponseOutputContent] = [] seen_hashes = set() + images = [] + seen_image_urls = set() + media_items: list[GeneratedVideo | GeneratedMedia] = [] + seen_media_urls = set() + for out in all_outputs: if out.images: - for image in out.images: - try: - b64, w, h, fname, fhash = await _image_to_base64(image, image_store) - if fhash in seen_hashes: - continue - seen_hashes.add(fhash) - - parts = fname.rsplit(".", 1) - img_id = parts[0] - fmt = parts[1] if len(parts) > 1 else "png" - - img_item = ImageGenerationCall( - id=img_id, - result=b64, - output_format=fmt, - size=f"{w}x{h}" if w and h else None, - ) + for img in out.images: + if img.url not in seen_image_urls: + images.append(img) + seen_image_urls.add(img.url) - image_url = ( - f"![{fname}]({base_url}images/{fname}?token={get_image_token(fname)})" - ) - final_response_contents.append( - ResponseOutputText(type="output_text", text=image_url) - ) + m_list = (out.videos or []) + (out.media or []) + for m in m_list: + m_url = getattr(m, "url", None) or getattr(m, "mp3_url", None) + if m_url and m_url not in seen_media_urls: + media_items.append(m) + seen_media_urls.add(m_url) - yield make_event( - "response.output_item.added", - { - **base_event, - "type": "response.output_item.added", - "output_index": current_index, - "item": img_item.model_dump(mode="json"), - }, - ) + for image in images: + try: + b64, w, h, fname, fhash = await _image_to_base64(image, media_store) + if fhash in seen_hashes: + continue + seen_hashes.add(fhash) - yield make_event( - "response.output_item.done", - { - **base_event, - "type": "response.output_item.done", - "output_index": current_index, - "item": img_item.model_dump(mode="json"), - }, - ) - current_index += 1 - image_items.append(img_item) - storage_output += f"\n\n{image_url}" - except Exception: - logger.warning("Image processing failed in stream") + parts = fname.rsplit(".", 1) + img_id = parts[0] + fmt = parts[1] if len(parts) > 1 else "png" + + img_item = ImageGenerationCall( + id=img_id, + result=b64, + output_format=fmt, + size=f"{w}x{h}" if w and h else None, + ) + + image_url = f"![{fname}]({base_url}media/{fname}?token={get_media_token(fname)})" + final_response_contents.append( + ResponseOutputText(type="output_text", text=image_url) + ) + + yield make_event( + "response.output_item.added", + { + **base_event, + "type": "response.output_item.added", + "output_index": current_index, + "item": img_item.model_dump(mode="json"), + }, + ) + + yield make_event( + "response.output_item.done", + { + **base_event, + "type": "response.output_item.done", + "output_index": current_index, + "item": img_item.model_dump(mode="json"), + }, + ) + current_index += 1 + image_items.append(img_item) + storage_output += f"\n\n{image_url}" + except Exception: + logger.warning("Image processing failed in stream") + + seen_media_hashes = set() + for media_item in media_items: + try: + m_dict = await _media_to_local_file(media_item, media_store) + + m_urls = {} + for mtype, (random_name, fhash) in m_dict.items(): + if fhash in seen_media_hashes: + (media_store / random_name).unlink(missing_ok=True) + continue + seen_media_hashes.add(fhash) + m_urls[mtype] = ( + f"{base_url}media/{random_name}?token={get_media_token(random_name)}" + ) + + media_url = m_urls.get("video") or m_urls.get("audio") + thumb_url = m_urls.get("video_thumbnail") or m_urls.get("audio_thumbnail") + + title = getattr(media_item, "title", "Media") + media_md = "" + if thumb_url and media_url: + media_md = f"[![{title}]({thumb_url})]({media_url})" + elif media_url: + media_md = f"[{title}]({media_url})" + elif thumb_url: + media_md = f"![{title}]({thumb_url})" + + if media_md: + final_response_contents.append( + ResponseOutputText(type="output_text", text=media_md) + ) + storage_output += f"\n\n{media_md}" + except Exception: + logger.warning("Media processing failed in stream") for call in detected_tool_calls: tc_item = ResponseFunctionToolCall( @@ -1741,7 +1872,7 @@ async def create_chat_completion( raw_request: Request, api_key: str = Depends(verify_api_key), tmp_dir: Path = Depends(get_temp_dir), - image_store: Path = Depends(get_image_store_dir), + media_store: Path = Depends(get_media_store_dir), ): base_url = str(raw_request.base_url) pool, db = GeminiClientPool(), LMDBConversationStore() @@ -1845,13 +1976,13 @@ async def create_chat_completion( seen_hashes = set() for image in images: try: - _, _, _, fname, fhash = await _image_to_base64(image, image_store) + _, _, _, fname, fhash = await _image_to_base64(image, media_store) if fhash in seen_hashes: - (image_store / fname).unlink(missing_ok=True) + (media_store / fname).unlink(missing_ok=True) continue seen_hashes.add(fhash) - img_url = f"![{fname}]({base_url}images/{fname}?token={get_image_token(fname)})" + img_url = f"![{fname}]({base_url}media/{fname}?token={get_media_token(fname)})" image_markdown += f"\n\n{img_url}" except Exception as exc: logger.warning(f"Failed to process image in OpenAI response: {exc}") @@ -1860,6 +1991,42 @@ async def create_chat_completion( visible_output += image_markdown storage_output += image_markdown + media_items: list[GeneratedVideo | GeneratedMedia] = (resp_or_stream.videos or []) + ( + resp_or_stream.media or [] + ) + media_markdown = "" + seen_media_hashes = set() + for m_item in media_items: + try: + m_dict = await _media_to_local_file(m_item, media_store) + + m_urls = {} + for mtype, (random_name, fhash) in m_dict.items(): + if fhash in seen_media_hashes: + (media_store / random_name).unlink(missing_ok=True) + continue + seen_media_hashes.add(fhash) + m_urls[mtype] = ( + f"{base_url}media/{random_name}?token={get_media_token(random_name)}" + ) + + media_url = m_urls.get("video") or m_urls.get("audio") + thumb_url = m_urls.get("video_thumbnail") or m_urls.get("audio_thumbnail") + + title = getattr(m_item, "title", "Media") + if thumb_url and media_url: + media_markdown += f"\n\n[![{title}]({thumb_url})]({media_url})" + elif media_url: + media_markdown += f"\n\n[{title}]({media_url})" + elif thumb_url: + media_markdown += f"\n\n![{title}]({thumb_url})" + except Exception as exc: + logger.warning(f"Failed to process media in OpenAI response: {exc}") + + if media_markdown: + visible_output += media_markdown + storage_output += media_markdown + if tool_calls: logger.debug( f"Detected tool calls: {reprlib.repr([tc.model_dump(mode='json') for tc in tool_calls])}" @@ -1902,7 +2069,7 @@ async def create_response( raw_request: Request, api_key: str = Depends(verify_api_key), tmp_dir: Path = Depends(get_temp_dir), - image_store: Path = Depends(get_image_store_dir), + media_store: Path = Depends(get_media_store_dir), ): base_url = str(raw_request.base_url) base_messages = _convert_responses_to_app_messages(request.input) @@ -2002,7 +2169,7 @@ async def create_response( client, session, request, - image_store, + media_store, base_url, struct_req, ) @@ -2033,9 +2200,9 @@ async def create_response( seen_hashes = set() for img in images: try: - b64, w, h, fname, fhash = await _image_to_base64(img, image_store) + b64, w, h, fname, fhash = await _image_to_base64(img, media_store) if fhash in seen_hashes: - (image_store / fname).unlink(missing_ok=True) + (media_store / fname).unlink(missing_ok=True) continue seen_hashes.add(fhash) @@ -2047,12 +2214,6 @@ async def create_response( else ("png" if isinstance(img, GeneratedImage) else "jpeg") ) - contents.append( - ResponseOutputText( - type="output_text", - text=f"![{fname}]({base_url}images/{fname}?token={get_image_token(fname)})", - ) - ) img_calls.append( ImageGenerationCall( id=img_id, @@ -2072,11 +2233,52 @@ async def create_response( image_markdown = "" for img_call in img_calls: fname = f"{img_call.id}.{img_call.output_format}" - img_url = f"![{fname}]({base_url}images/{fname}?token={get_image_token(fname)})" + img_url = f"![{fname}]({base_url}media/{fname}?token={get_media_token(fname)})" image_markdown += f"\n\n{img_url}" if image_markdown: storage_output += image_markdown + contents.append(ResponseOutputText(type="output_text", text=image_markdown.strip())) + + media_items: list[GeneratedVideo | GeneratedMedia] = (resp_or_stream.videos or []) + ( + resp_or_stream.media or [] + ) + seen_media_hashes = set() + media_markdown = "" + for m_item in media_items: + try: + m_dict = await _media_to_local_file(m_item, media_store) + + m_urls = {} + for mtype, (random_name, fhash) in m_dict.items(): + if fhash in seen_media_hashes: + (media_store / random_name).unlink(missing_ok=True) + continue + seen_media_hashes.add(fhash) + m_urls[mtype] = ( + f"{base_url}media/{random_name}?token={get_media_token(random_name)}" + ) + + media_url = m_urls.get("video") or m_urls.get("audio") + thumb_url = m_urls.get("video_thumbnail") or m_urls.get("audio_thumbnail") + + title = getattr(m_item, "title", "Media") + m_md = "" + if thumb_url and media_url: + m_md = f"[![{title}]({thumb_url})]({media_url})" + elif media_url: + m_md = f"[{title}]({media_url})" + elif thumb_url: + m_md = f"![{title}]({thumb_url})" + + if m_md: + media_markdown += f"\n\n{m_md}" + except Exception as exc: + logger.warning(f"Failed to process media in OpenAI response: {exc}") + + if media_markdown: + storage_output += media_markdown + contents.append(ResponseOutputText(type="output_text", text=media_markdown.strip())) p_tok, c_tok, t_tok, r_tok = _calculate_usage(messages, assistant_text, tool_calls, thoughts) usage = ResponseUsage( diff --git a/app/server/images.py b/app/server/images.py deleted file mode 100644 index e1c161c..0000000 --- a/app/server/images.py +++ /dev/null @@ -1,18 +0,0 @@ -from fastapi import APIRouter, HTTPException, Query -from fastapi.responses import FileResponse - -from app.server.middleware import get_image_store_dir, verify_image_token - -router = APIRouter() - - -@router.get("/images/{filename}", tags=["Images"]) -async def get_image(filename: str, token: str | None = Query(default=None)): - if not verify_image_token(filename, token): - raise HTTPException(status_code=403, detail="Invalid token") - - image_store = get_image_store_dir() - file_path = image_store / filename - if not file_path.exists(): - raise HTTPException(status_code=404, detail="Image not found") - return FileResponse(file_path) diff --git a/app/server/media.py b/app/server/media.py new file mode 100644 index 0000000..86b5b70 --- /dev/null +++ b/app/server/media.py @@ -0,0 +1,18 @@ +from fastapi import APIRouter, HTTPException, Query +from fastapi.responses import FileResponse + +from app.server.middleware import get_media_store_dir, verify_media_token + +router = APIRouter() + + +@router.get("/media/{filename}", tags=["Media"]) +async def get_media(filename: str, token: str | None = Query(default=None)): + if not verify_media_token(filename, token): + raise HTTPException(status_code=403, detail="Invalid token") + + media_store = get_media_store_dir() + file_path = media_store / filename + if not file_path.exists(): + raise HTTPException(status_code=404, detail="Media not found") + return FileResponse(file_path) diff --git a/app/server/middleware.py b/app/server/middleware.py index 457ac0f..07840be 100644 --- a/app/server/middleware.py +++ b/app/server/middleware.py @@ -12,17 +12,17 @@ from app.utils import g_config -# Persistent directory for storing generated images -IMAGE_STORE_DIR = Path(g_config.storage.images_path) -IMAGE_STORE_DIR.mkdir(parents=True, exist_ok=True) +# Persistent directory for storing generated media +MEDIA_STORE_DIR = Path(g_config.storage.media_path) +MEDIA_STORE_DIR.mkdir(parents=True, exist_ok=True) -def get_image_store_dir() -> Path: - """Returns a persistent directory for storing images.""" - return IMAGE_STORE_DIR +def get_media_store_dir() -> Path: + """Returns a persistent directory for storing media.""" + return MEDIA_STORE_DIR -def get_image_token(filename: str) -> str: +def get_media_token(filename: str) -> str: """Generate a HMAC-SHA256 token for a filename using the API key.""" secret = g_config.server.api_key if not secret: @@ -33,9 +33,9 @@ def get_image_token(filename: str) -> str: return hmac.new(secret_bytes, msg, hashlib.sha256).hexdigest() -def verify_image_token(filename: str, token: str | None) -> bool: +def verify_media_token(filename: str, token: str | None) -> bool: """Verify the provided token against the filename.""" - expected = get_image_token(filename) + expected = get_media_token(filename) if not expected: return True # No auth required if not token: @@ -43,8 +43,8 @@ def verify_image_token(filename: str, token: str | None) -> bool: return hmac.compare_digest(token, expected) -def cleanup_expired_images(retention_days: int) -> int: - """Delete images in IMAGE_STORE_DIR older than retention_days.""" +def cleanup_expired_media(retention_days: int) -> int: + """Delete media files in MEDIA_STORE_DIR older than retention_days.""" if retention_days <= 0: return 0 @@ -53,7 +53,7 @@ def cleanup_expired_images(retention_days: int) -> int: cutoff = now - retention_seconds count = 0 - for file_path in IMAGE_STORE_DIR.iterdir(): + for file_path in MEDIA_STORE_DIR.iterdir(): if not file_path.is_file(): continue try: @@ -61,10 +61,10 @@ def cleanup_expired_images(retention_days: int) -> int: file_path.unlink() count += 1 except Exception as e: - logger.warning(f"Failed to delete expired image {file_path}: {e}") + logger.warning(f"Failed to delete expired media {file_path}: {e}") if count > 0: - logger.info(f"Cleaned up {count} expired images.") + logger.info(f"Cleaned up {count} expired media files.") return count diff --git a/app/utils/config.py b/app/utils/config.py index 9b4f1a3..a52bab5 100644 --- a/app/utils/config.py +++ b/app/utils/config.py @@ -151,9 +151,9 @@ class StorageConfig(BaseModel): default="data/lmdb", description="Path to the storage directory where data will be saved", ) - images_path: str = Field( - default="data/images", - description="Path to the directory where generated images will be stored", + media_path: str = Field( + default="data/media", + description="Path to the directory where generated media will be stored", ) max_size: int = Field( default=1024**2 * 256, # 256 MB diff --git a/config/config.yaml b/config/config.yaml index 71301d0..746be67 100644 --- a/config/config.yaml +++ b/config/config.yaml @@ -33,7 +33,7 @@ gemini: storage: path: "data/lmdb" # Database storage path - images_path: "data/images" # Image storage path + media_path: "data/media" # Media storage path max_size: 268435456 # Maximum database size (256 MB) retention_days: 14 # Number of days to retain conversations before cleanup diff --git a/uv.lock b/uv.lock index 6f688b1..e5fa9f4 100644 --- a/uv.lock +++ b/uv.lock @@ -164,8 +164,8 @@ requires-dist = [ { name = "orjson", specifier = ">=3.11.7" }, { name = "pydantic-settings", extras = ["yaml"], specifier = ">=2.13.1" }, { name = "pytest", marker = "extra == 'dev'", specifier = ">=9.0.2" }, - { name = "ruff", marker = "extra == 'dev'", specifier = ">=0.15.4" }, - { name = "ty", marker = "extra == 'dev'", specifier = ">=0.0.20" }, + { name = "ruff", marker = "extra == 'dev'", specifier = ">=0.15.5" }, + { name = "ty", marker = "extra == 'dev'", specifier = ">=0.0.21" }, { name = "uvicorn", specifier = ">=0.41.0" }, { name = "uvloop", marker = "sys_platform != 'win32'", specifier = ">=0.22.1" }, ] From 9a962019151f7061620a0589e8c40685948ec491 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C6=B0u=20Quang=20V=C5=A9?= Date: Fri, 6 Mar 2026 14:02:34 +0700 Subject: [PATCH 186/291] Refactor model retrieval logic to avoid duplicates and streamline processing --- app/server/chat.py | 38 +++++++++++--------------------------- 1 file changed, 11 insertions(+), 27 deletions(-) diff --git a/app/server/chat.py b/app/server/chat.py index ff62840..018a20f 100644 --- a/app/server/chat.py +++ b/app/server/chat.py @@ -897,21 +897,20 @@ async def _get_available_models(pool: GeminiClientPool) -> list[ModelData]: now = int(datetime.now(tz=UTC).timestamp()) strategy = g_config.gemini.model_strategy models_data = [] + seen_model_ids = set() - custom_models = [m for m in g_config.gemini.models if m.model_name] - for m in custom_models: - models_data.append( - ModelData( - id=m.model_name or "", - created=now, - owned_by="custom", + for m in g_config.gemini.models: + if m.model_name and m.model_name not in seen_model_ids: + models_data.append( + ModelData( + id=m.model_name, + created=now, + owned_by="custom", + ) ) - ) + seen_model_ids.add(m.model_name) if strategy == "append": - custom_ids = {m.id for m in models_data} - seen_model_ids = set() - for client in pool.clients: if not client.running(): continue @@ -919,7 +918,7 @@ async def _get_available_models(pool: GeminiClientPool) -> list[ModelData]: client_models = client.list_models() if client_models: for am in client_models: - if am.id not in custom_ids and am.id not in seen_model_ids: + if am.id and am.id not in seen_model_ids: models_data.append( ModelData( id=am.id, @@ -929,21 +928,6 @@ async def _get_available_models(pool: GeminiClientPool) -> list[ModelData]: ) seen_model_ids.add(am.id) - for model in Model: - m_name = model.model_name - if not m_name or m_name == "unspecified": - continue - if m_name in custom_ids or m_name in seen_model_ids: - continue - - models_data.append( - ModelData( - id=m_name, - created=now, - owned_by="gemini-web", - ) - ) - return models_data From 391c31ecbd90d8963a9d4b5686be235a1744f1bc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C6=B0u=20Quang=20V=C5=A9?= Date: Fri, 6 Mar 2026 14:57:26 +0700 Subject: [PATCH 187/291] Update dependencies to latest versions --- uv.lock | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/uv.lock b/uv.lock index e5fa9f4..f11b4a1 100644 --- a/uv.lock +++ b/uv.lock @@ -176,8 +176,8 @@ dev = [{ name = "gemini-fastapi", extras = ["dev"] }] [[package]] name = "gemini-webapi" -version = "0.0.post251" -source = { git = "https://github.com/luuquangvu/Gemini-API.git?rev=move-httpx-to-curl_cffi#891b9fd643d66eef2970897b06f32d395a091ad4" } +version = "0.0.post252" +source = { git = "https://github.com/luuquangvu/Gemini-API.git?rev=move-httpx-to-curl_cffi#3526b6597183fc7247f2304f7e4c2c72c70a9180" } dependencies = [ { name = "curl-cffi" }, { name = "loguru" }, From 13d79833876d62fd5d641c1b688d7d5dd384971c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C6=B0u=20Quang=20V=C5=A9?= Date: Fri, 6 Mar 2026 15:37:50 +0700 Subject: [PATCH 188/291] Update dependencies to latest versions --- uv.lock | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/uv.lock b/uv.lock index f11b4a1..f38ac9c 100644 --- a/uv.lock +++ b/uv.lock @@ -176,8 +176,8 @@ dev = [{ name = "gemini-fastapi", extras = ["dev"] }] [[package]] name = "gemini-webapi" -version = "0.0.post252" -source = { git = "https://github.com/luuquangvu/Gemini-API.git?rev=move-httpx-to-curl_cffi#3526b6597183fc7247f2304f7e4c2c72c70a9180" } +version = "0.0.post253" +source = { git = "https://github.com/luuquangvu/Gemini-API.git?rev=move-httpx-to-curl_cffi#2c55ed1e9be545e5fea11d7a9ab04d68a2058596" } dependencies = [ { name = "curl-cffi" }, { name = "loguru" }, From eef2cbb35b2e1af45907b14acd63733968896f1b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C6=B0u=20Quang=20V=C5=A9?= Date: Fri, 6 Mar 2026 16:12:07 +0700 Subject: [PATCH 189/291] Update the Markdown link handling to include video and audio support for music generation. --- app/server/chat.py | 131 +++++++++++++++++++++++++++++++-------------- 1 file changed, 91 insertions(+), 40 deletions(-) diff --git a/app/server/chat.py b/app/server/chat.py index 018a20f..950deed 100644 --- a/app/server/chat.py +++ b/app/server/chat.py @@ -1238,16 +1238,27 @@ def make_chunk(delta_content: dict) -> str: f"{base_url}media/{random_name}?token={get_media_token(random_name)}" ) - media_url = m_urls.get("video") or m_urls.get("audio") - thumb_url = m_urls.get("video_thumbnail") or m_urls.get("audio_thumbnail") - title = getattr(media_item, "title", "Media") - if thumb_url and media_url: - media_results.append(f"[![{title}]({thumb_url})]({media_url})") - elif media_url: - media_results.append(f"[{title}]({media_url})") - elif thumb_url: - media_results.append(f"![{title}]({thumb_url})") + video_url, video_thumb = m_urls.get("video"), m_urls.get("video_thumbnail") + audio_url, audio_thumb = m_urls.get("audio"), m_urls.get("audio_thumbnail") + + if video_url: + media_results.append( + f"[![{title}]({video_thumb})]({video_url})" + if video_thumb + else f"[{title}]({video_url})" + ) + elif video_thumb: + media_results.append(f"![{title}]({video_thumb})") + + if audio_url: + media_results.append( + f"[![{title} (Audio)]({audio_thumb})]({audio_url})" + if audio_thumb + else f"[{title} (Audio)]({audio_url})" + ) + elif audio_thumb: + media_results.append(f"![{title} (Audio)]({audio_thumb})") except Exception as exc: logger.warning(f"Failed to process media in OpenAI stream: {exc}") @@ -1743,17 +1754,30 @@ async def _make_async_gen(item: ModelOutput) -> AsyncGenerator[ModelOutput]: f"{base_url}media/{random_name}?token={get_media_token(random_name)}" ) - media_url = m_urls.get("video") or m_urls.get("audio") - thumb_url = m_urls.get("video_thumbnail") or m_urls.get("audio_thumbnail") - title = getattr(media_item, "title", "Media") - media_md = "" - if thumb_url and media_url: - media_md = f"[![{title}]({thumb_url})]({media_url})" - elif media_url: - media_md = f"[{title}]({media_url})" - elif thumb_url: - media_md = f"![{title}]({thumb_url})" + video_url, video_thumb = m_urls.get("video"), m_urls.get("video_thumbnail") + audio_url, audio_thumb = m_urls.get("audio"), m_urls.get("audio_thumbnail") + + md_parts = [] + if video_url: + md_parts.append( + f"[![{title}]({video_thumb})]({video_url})" + if video_thumb + else f"[{title}]({video_url})" + ) + elif video_thumb: + md_parts.append(f"![{title}]({video_thumb})") + + if audio_url: + md_parts.append( + f"[![{title} (Audio)]({audio_thumb})]({audio_url})" + if audio_thumb + else f"[{title} (Audio)]({audio_url})" + ) + elif audio_thumb: + md_parts.append(f"![{title} (Audio)]({audio_thumb})") + + media_md = "\n\n".join(md_parts) if media_md: final_response_contents.append( @@ -1994,16 +2018,31 @@ async def create_chat_completion( f"{base_url}media/{random_name}?token={get_media_token(random_name)}" ) - media_url = m_urls.get("video") or m_urls.get("audio") - thumb_url = m_urls.get("video_thumbnail") or m_urls.get("audio_thumbnail") - title = getattr(m_item, "title", "Media") - if thumb_url and media_url: - media_markdown += f"\n\n[![{title}]({thumb_url})]({media_url})" - elif media_url: - media_markdown += f"\n\n[{title}]({media_url})" - elif thumb_url: - media_markdown += f"\n\n![{title}]({thumb_url})" + video_url, video_thumb = m_urls.get("video"), m_urls.get("video_thumbnail") + audio_url, audio_thumb = m_urls.get("audio"), m_urls.get("audio_thumbnail") + + md_parts = [] + if video_url: + md_parts.append( + f"[![{title}]({video_thumb})]({video_url})" + if video_thumb + else f"[{title}]({video_url})" + ) + elif video_thumb: + md_parts.append(f"![{title}]({video_thumb})") + + if audio_url: + md_parts.append( + f"[![{title} (Audio)]({audio_thumb})]({audio_url})" + if audio_thumb + else f"[{title} (Audio)]({audio_url})" + ) + elif audio_thumb: + md_parts.append(f"![{title} (Audio)]({audio_thumb})") + + if md_parts: + media_markdown += "\n\n" + "\n\n".join(md_parts) except Exception as exc: logger.warning(f"Failed to process media in OpenAI response: {exc}") @@ -2243,19 +2282,31 @@ async def create_response( f"{base_url}media/{random_name}?token={get_media_token(random_name)}" ) - media_url = m_urls.get("video") or m_urls.get("audio") - thumb_url = m_urls.get("video_thumbnail") or m_urls.get("audio_thumbnail") - title = getattr(m_item, "title", "Media") - m_md = "" - if thumb_url and media_url: - m_md = f"[![{title}]({thumb_url})]({media_url})" - elif media_url: - m_md = f"[{title}]({media_url})" - elif thumb_url: - m_md = f"![{title}]({thumb_url})" - - if m_md: + video_url, video_thumb = m_urls.get("video"), m_urls.get("video_thumbnail") + audio_url, audio_thumb = m_urls.get("audio"), m_urls.get("audio_thumbnail") + + md_parts = [] + if video_url: + md_parts.append( + f"[![{title}]({video_thumb})]({video_url})" + if video_thumb + else f"[{title}]({video_url})" + ) + elif video_thumb: + md_parts.append(f"![{title}]({video_thumb})") + + if audio_url: + md_parts.append( + f"[![{title} (Audio)]({audio_thumb})]({audio_url})" + if audio_thumb + else f"[{title} (Audio)]({audio_url})" + ) + elif audio_thumb: + md_parts.append(f"![{title} (Audio)]({audio_thumb})") + + if md_parts: + m_md = "\n\n".join(md_parts) media_markdown += f"\n\n{m_md}" except Exception as exc: logger.warning(f"Failed to process media in OpenAI response: {exc}") From d28b5a81b872c02a6c7bc78a4f369c82b36f6f31 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C6=B0u=20Quang=20V=C5=A9?= Date: Fri, 6 Mar 2026 17:01:09 +0700 Subject: [PATCH 190/291] Update the Markdown link handling to include video and audio support for music generation. --- app/server/chat.py | 78 +++++++++++++++++++++++++++++++--------------- 1 file changed, 53 insertions(+), 25 deletions(-) diff --git a/app/server/chat.py b/app/server/chat.py index 950deed..e429208 100644 --- a/app/server/chat.py +++ b/app/server/chat.py @@ -1206,15 +1206,16 @@ def make_chunk(delta_content: dict) -> str: seen_media_urls.add(m_url) image_results = [] - seen_hashes = set() + seen_hashes = {} for image in images: try: media_store = get_media_store_dir() _, _, _, fname, fhash = await _image_to_base64(image, media_store) if fhash in seen_hashes: (media_store / fname).unlink(missing_ok=True) - continue - seen_hashes.add(fhash) + fname = seen_hashes[fhash] + else: + seen_hashes[fhash] = fname img_url = f"{base_url}media/{fname}?token={get_media_token(fname)}" title = getattr(image, "title", "Image") image_results.append(f"![{title}]({img_url})") @@ -1222,7 +1223,7 @@ def make_chunk(delta_content: dict) -> str: logger.warning(f"Failed to process image in OpenAI stream: {exc}") media_results = [] - seen_media_hashes = set() + seen_media_hashes = {} for media_item in media_items: try: media_store = get_media_store_dir() @@ -1232,15 +1233,21 @@ def make_chunk(delta_content: dict) -> str: for mtype, (random_name, fhash) in m_dict.items(): if fhash in seen_media_hashes: (media_store / random_name).unlink(missing_ok=True) + existing_name = seen_media_hashes[fhash] + m_urls[mtype] = ( + f"{base_url}media/{existing_name}?token={get_media_token(existing_name)}" + ) continue - seen_media_hashes.add(fhash) + seen_media_hashes[fhash] = random_name m_urls[mtype] = ( f"{base_url}media/{random_name}?token={get_media_token(random_name)}" ) title = getattr(media_item, "title", "Media") - video_url, video_thumb = m_urls.get("video"), m_urls.get("video_thumbnail") - audio_url, audio_thumb = m_urls.get("audio"), m_urls.get("audio_thumbnail") + video_url = m_urls.get("video") + audio_url = m_urls.get("audio") + video_thumb = m_urls.get("video_thumbnail") or m_urls.get("audio_thumbnail") + audio_thumb = m_urls.get("audio_thumbnail") or m_urls.get("video_thumbnail") if video_url: media_results.append( @@ -1670,7 +1677,7 @@ async def _make_async_gen(item: ModelOutput) -> AsyncGenerator[ModelOutput]: image_items: list[ImageGenerationCall] = [] final_response_contents: list[ResponseOutputContent] = [] - seen_hashes = set() + seen_hashes = {} images = [] seen_image_urls = set() @@ -1695,8 +1702,10 @@ async def _make_async_gen(item: ModelOutput) -> AsyncGenerator[ModelOutput]: try: b64, w, h, fname, fhash = await _image_to_base64(image, media_store) if fhash in seen_hashes: - continue - seen_hashes.add(fhash) + (media_store / fname).unlink(missing_ok=True) + b64, w, h, fname = seen_hashes[fhash] + else: + seen_hashes[fhash] = (b64, w, h, fname) parts = fname.rsplit(".", 1) img_id = parts[0] @@ -1739,7 +1748,7 @@ async def _make_async_gen(item: ModelOutput) -> AsyncGenerator[ModelOutput]: except Exception: logger.warning("Image processing failed in stream") - seen_media_hashes = set() + seen_media_hashes = {} for media_item in media_items: try: m_dict = await _media_to_local_file(media_item, media_store) @@ -1748,15 +1757,21 @@ async def _make_async_gen(item: ModelOutput) -> AsyncGenerator[ModelOutput]: for mtype, (random_name, fhash) in m_dict.items(): if fhash in seen_media_hashes: (media_store / random_name).unlink(missing_ok=True) + existing_name = seen_media_hashes[fhash] + m_urls[mtype] = ( + f"{base_url}media/{existing_name}?token={get_media_token(existing_name)}" + ) continue - seen_media_hashes.add(fhash) + seen_media_hashes[fhash] = random_name m_urls[mtype] = ( f"{base_url}media/{random_name}?token={get_media_token(random_name)}" ) title = getattr(media_item, "title", "Media") - video_url, video_thumb = m_urls.get("video"), m_urls.get("video_thumbnail") - audio_url, audio_thumb = m_urls.get("audio"), m_urls.get("audio_thumbnail") + video_url = m_urls.get("video") + audio_url = m_urls.get("audio") + video_thumb = m_urls.get("video_thumbnail") or m_urls.get("audio_thumbnail") + audio_thumb = m_urls.get("audio_thumbnail") or m_urls.get("video_thumbnail") md_parts = [] if video_url: @@ -2003,7 +2018,7 @@ async def create_chat_completion( resp_or_stream.media or [] ) media_markdown = "" - seen_media_hashes = set() + seen_media_hashes = {} for m_item in media_items: try: m_dict = await _media_to_local_file(m_item, media_store) @@ -2012,15 +2027,21 @@ async def create_chat_completion( for mtype, (random_name, fhash) in m_dict.items(): if fhash in seen_media_hashes: (media_store / random_name).unlink(missing_ok=True) + existing_name = seen_media_hashes[fhash] + m_urls[mtype] = ( + f"{base_url}media/{existing_name}?token={get_media_token(existing_name)}" + ) continue - seen_media_hashes.add(fhash) + seen_media_hashes[fhash] = random_name m_urls[mtype] = ( f"{base_url}media/{random_name}?token={get_media_token(random_name)}" ) title = getattr(m_item, "title", "Media") - video_url, video_thumb = m_urls.get("video"), m_urls.get("video_thumbnail") - audio_url, audio_thumb = m_urls.get("audio"), m_urls.get("audio_thumbnail") + video_url = m_urls.get("video") + audio_url = m_urls.get("audio") + video_thumb = m_urls.get("video_thumbnail") or m_urls.get("audio_thumbnail") + audio_thumb = m_urls.get("audio_thumbnail") or m_urls.get("video_thumbnail") md_parts = [] if video_url: @@ -2220,14 +2241,15 @@ async def create_response( raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail="No images returned.") contents, img_calls = [], [] - seen_hashes = set() + seen_hashes = {} for img in images: try: b64, w, h, fname, fhash = await _image_to_base64(img, media_store) if fhash in seen_hashes: (media_store / fname).unlink(missing_ok=True) - continue - seen_hashes.add(fhash) + b64, w, h, fname = seen_hashes[fhash] + else: + seen_hashes[fhash] = (b64, w, h, fname) parts = fname.rsplit(".", 1) img_id = parts[0] @@ -2266,7 +2288,7 @@ async def create_response( media_items: list[GeneratedVideo | GeneratedMedia] = (resp_or_stream.videos or []) + ( resp_or_stream.media or [] ) - seen_media_hashes = set() + seen_media_hashes = {} media_markdown = "" for m_item in media_items: try: @@ -2276,15 +2298,21 @@ async def create_response( for mtype, (random_name, fhash) in m_dict.items(): if fhash in seen_media_hashes: (media_store / random_name).unlink(missing_ok=True) + existing_name = seen_media_hashes[fhash] + m_urls[mtype] = ( + f"{base_url}media/{existing_name}?token={get_media_token(existing_name)}" + ) continue - seen_media_hashes.add(fhash) + seen_media_hashes[fhash] = random_name m_urls[mtype] = ( f"{base_url}media/{random_name}?token={get_media_token(random_name)}" ) title = getattr(m_item, "title", "Media") - video_url, video_thumb = m_urls.get("video"), m_urls.get("video_thumbnail") - audio_url, audio_thumb = m_urls.get("audio"), m_urls.get("audio_thumbnail") + video_url = m_urls.get("video") + audio_url = m_urls.get("audio") + video_thumb = m_urls.get("video_thumbnail") or m_urls.get("audio_thumbnail") + audio_thumb = m_urls.get("audio_thumbnail") or m_urls.get("video_thumbnail") md_parts = [] if video_url: From af1b508bb4de3f48613d9a7e2f42fb821e356fa2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C6=B0u=20Quang=20V=C5=A9?= Date: Fri, 6 Mar 2026 17:44:05 +0700 Subject: [PATCH 191/291] Update dependencies to latest versions --- uv.lock | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/uv.lock b/uv.lock index f38ac9c..648394a 100644 --- a/uv.lock +++ b/uv.lock @@ -176,8 +176,8 @@ dev = [{ name = "gemini-fastapi", extras = ["dev"] }] [[package]] name = "gemini-webapi" -version = "0.0.post253" -source = { git = "https://github.com/luuquangvu/Gemini-API.git?rev=move-httpx-to-curl_cffi#2c55ed1e9be545e5fea11d7a9ab04d68a2058596" } +version = "0.0.post254" +source = { git = "https://github.com/luuquangvu/Gemini-API.git?rev=move-httpx-to-curl_cffi#8e390c25f75d156a6c0916057f0f5d92acb00239" } dependencies = [ { name = "curl-cffi" }, { name = "loguru" }, From 0d4fa4c3c168dcae95bd920cd3873c367e692b33 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C6=B0u=20Quang=20V=C5=A9?= Date: Fri, 6 Mar 2026 20:23:26 +0700 Subject: [PATCH 192/291] Update dependencies to latest versions --- uv.lock | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/uv.lock b/uv.lock index 648394a..5fdc3f0 100644 --- a/uv.lock +++ b/uv.lock @@ -176,8 +176,8 @@ dev = [{ name = "gemini-fastapi", extras = ["dev"] }] [[package]] name = "gemini-webapi" -version = "0.0.post254" -source = { git = "https://github.com/luuquangvu/Gemini-API.git?rev=move-httpx-to-curl_cffi#8e390c25f75d156a6c0916057f0f5d92acb00239" } +version = "0.0.post255" +source = { git = "https://github.com/luuquangvu/Gemini-API.git?rev=move-httpx-to-curl_cffi#29907757372446a2ac9fa58090c035149332f7d7" } dependencies = [ { name = "curl-cffi" }, { name = "loguru" }, From 95889afeff997de481dd3705d861af6342f7a21a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C6=B0u=20Quang=20V=C5=A9?= Date: Fri, 6 Mar 2026 21:00:48 +0700 Subject: [PATCH 193/291] Update dependencies to latest versions --- uv.lock | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/uv.lock b/uv.lock index 5fdc3f0..7e4c47e 100644 --- a/uv.lock +++ b/uv.lock @@ -176,8 +176,8 @@ dev = [{ name = "gemini-fastapi", extras = ["dev"] }] [[package]] name = "gemini-webapi" -version = "0.0.post255" -source = { git = "https://github.com/luuquangvu/Gemini-API.git?rev=move-httpx-to-curl_cffi#29907757372446a2ac9fa58090c035149332f7d7" } +version = "0.0.post256" +source = { git = "https://github.com/luuquangvu/Gemini-API.git?rev=move-httpx-to-curl_cffi#880acd7aaee4a78c6a2a3c19c96f04551c566f39" } dependencies = [ { name = "curl-cffi" }, { name = "loguru" }, From 3d10fd53f5f2d278878502ce8c34f6ba911d0498 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C6=B0u=20Quang=20V=C5=A9?= Date: Fri, 6 Mar 2026 21:52:24 +0700 Subject: [PATCH 194/291] Update dependencies to latest versions --- uv.lock | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/uv.lock b/uv.lock index 7e4c47e..cc223d2 100644 --- a/uv.lock +++ b/uv.lock @@ -176,8 +176,8 @@ dev = [{ name = "gemini-fastapi", extras = ["dev"] }] [[package]] name = "gemini-webapi" -version = "0.0.post256" -source = { git = "https://github.com/luuquangvu/Gemini-API.git?rev=move-httpx-to-curl_cffi#880acd7aaee4a78c6a2a3c19c96f04551c566f39" } +version = "0.0.post257" +source = { git = "https://github.com/luuquangvu/Gemini-API.git?rev=move-httpx-to-curl_cffi#59d729862fa0a936ec7bbe879cfc82c51ae912ca" } dependencies = [ { name = "curl-cffi" }, { name = "loguru" }, From d92fa8fb4b9f1015c6c93107efa455f1de8c07c1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C6=B0u=20Quang=20V=C5=A9?= Date: Fri, 6 Mar 2026 22:33:18 +0700 Subject: [PATCH 195/291] Update the Markdown link handling to correctly include thumbnails. --- app/server/chat.py | 42 +++++++++++++++--------------------------- 1 file changed, 15 insertions(+), 27 deletions(-) diff --git a/app/server/chat.py b/app/server/chat.py index e429208..5db703c 100644 --- a/app/server/chat.py +++ b/app/server/chat.py @@ -1246,26 +1246,25 @@ def make_chunk(delta_content: dict) -> str: title = getattr(media_item, "title", "Media") video_url = m_urls.get("video") audio_url = m_urls.get("audio") - video_thumb = m_urls.get("video_thumbnail") or m_urls.get("audio_thumbnail") - audio_thumb = m_urls.get("audio_thumbnail") or m_urls.get("video_thumbnail") + video_thumb = m_urls.get("video_thumbnail") + audio_thumb = m_urls.get("audio_thumbnail") + md_parts = [] if video_url: - media_results.append( + md_parts.append( f"[![{title}]({video_thumb})]({video_url})" if video_thumb else f"[{title}]({video_url})" ) - elif video_thumb: - media_results.append(f"![{title}]({video_thumb})") if audio_url: - media_results.append( + md_parts.append( f"[![{title} (Audio)]({audio_thumb})]({audio_url})" if audio_thumb else f"[{title} (Audio)]({audio_url})" ) - elif audio_thumb: - media_results.append(f"![{title} (Audio)]({audio_thumb})") + if md_parts: + media_results.append("\n\n".join(md_parts)) except Exception as exc: logger.warning(f"Failed to process media in OpenAI stream: {exc}") @@ -1770,8 +1769,8 @@ async def _make_async_gen(item: ModelOutput) -> AsyncGenerator[ModelOutput]: title = getattr(media_item, "title", "Media") video_url = m_urls.get("video") audio_url = m_urls.get("audio") - video_thumb = m_urls.get("video_thumbnail") or m_urls.get("audio_thumbnail") - audio_thumb = m_urls.get("audio_thumbnail") or m_urls.get("video_thumbnail") + video_thumb = m_urls.get("video_thumbnail") + audio_thumb = m_urls.get("audio_thumbnail") md_parts = [] if video_url: @@ -1780,8 +1779,6 @@ async def _make_async_gen(item: ModelOutput) -> AsyncGenerator[ModelOutput]: if video_thumb else f"[{title}]({video_url})" ) - elif video_thumb: - md_parts.append(f"![{title}]({video_thumb})") if audio_url: md_parts.append( @@ -1789,8 +1786,6 @@ async def _make_async_gen(item: ModelOutput) -> AsyncGenerator[ModelOutput]: if audio_thumb else f"[{title} (Audio)]({audio_url})" ) - elif audio_thumb: - md_parts.append(f"![{title} (Audio)]({audio_thumb})") media_md = "\n\n".join(md_parts) @@ -2040,8 +2035,8 @@ async def create_chat_completion( title = getattr(m_item, "title", "Media") video_url = m_urls.get("video") audio_url = m_urls.get("audio") - video_thumb = m_urls.get("video_thumbnail") or m_urls.get("audio_thumbnail") - audio_thumb = m_urls.get("audio_thumbnail") or m_urls.get("video_thumbnail") + video_thumb = m_urls.get("video_thumbnail") + audio_thumb = m_urls.get("audio_thumbnail") md_parts = [] if video_url: @@ -2050,8 +2045,6 @@ async def create_chat_completion( if video_thumb else f"[{title}]({video_url})" ) - elif video_thumb: - md_parts.append(f"![{title}]({video_thumb})") if audio_url: md_parts.append( @@ -2059,11 +2052,10 @@ async def create_chat_completion( if audio_thumb else f"[{title} (Audio)]({audio_url})" ) - elif audio_thumb: - md_parts.append(f"![{title} (Audio)]({audio_thumb})") if md_parts: - media_markdown += "\n\n" + "\n\n".join(md_parts) + m_md = "\n\n".join(md_parts) + media_markdown += f"\n\n{m_md}" except Exception as exc: logger.warning(f"Failed to process media in OpenAI response: {exc}") @@ -2311,8 +2303,8 @@ async def create_response( title = getattr(m_item, "title", "Media") video_url = m_urls.get("video") audio_url = m_urls.get("audio") - video_thumb = m_urls.get("video_thumbnail") or m_urls.get("audio_thumbnail") - audio_thumb = m_urls.get("audio_thumbnail") or m_urls.get("video_thumbnail") + video_thumb = m_urls.get("video_thumbnail") + audio_thumb = m_urls.get("audio_thumbnail") md_parts = [] if video_url: @@ -2321,8 +2313,6 @@ async def create_response( if video_thumb else f"[{title}]({video_url})" ) - elif video_thumb: - md_parts.append(f"![{title}]({video_thumb})") if audio_url: md_parts.append( @@ -2330,8 +2320,6 @@ async def create_response( if audio_thumb else f"[{title} (Audio)]({audio_url})" ) - elif audio_thumb: - md_parts.append(f"![{title} (Audio)]({audio_thumb})") if md_parts: m_md = "\n\n".join(md_parts) From d6920ff177dc07b8e7807e12b6ff229424945e2f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C6=B0u=20Quang=20V=C5=A9?= Date: Sat, 7 Mar 2026 08:35:27 +0700 Subject: [PATCH 196/291] Update the Markdown link handling to correctly include thumbnails. --- app/server/chat.py | 63 ++++++++++++++++++++++++++++++---------------- 1 file changed, 41 insertions(+), 22 deletions(-) diff --git a/app/server/chat.py b/app/server/chat.py index 5db703c..15c8efd 100644 --- a/app/server/chat.py +++ b/app/server/chat.py @@ -138,27 +138,47 @@ async def _media_to_local_file( try: saved_paths = await media.save(path=str(temp_dir)) except Exception as e: - logger.warning(f"Failed to save media: {e}") + logger.warning(f"Failed to save media: {e}", exc_info=True) return {} + default_extensions = { + "video": ".mp4", + "audio": ".mp3", + "video_thumbnail": ".jpg", + "audio_thumbnail": ".jpg", + } + results = {} + path_map = {} + for mtype, spath in saved_paths.items(): if not spath: continue + try: + original_path = Path(spath) + if not original_path.exists(): + if spath in path_map: + results[mtype] = path_map[spath] + continue - original_path = Path(spath) - data = original_path.read_bytes() - suffix = original_path.suffix + if spath in path_map: + results[mtype] = path_map[spath] + continue - if not suffix: - suffix = ".mp4" if "video" in mtype else ".mp3" + data = original_path.read_bytes() + suffix = original_path.suffix + if not suffix: + suffix = default_extensions.get(mtype) or (".mp4" if "video" in mtype else ".mp3") - random_name = f"media_{uuid.uuid4().hex}{suffix}" - new_path = temp_dir / random_name - original_path.rename(new_path) + random_name = f"media_{uuid.uuid4().hex}{suffix}" + new_path = temp_dir / random_name + original_path.rename(new_path) - fhash = hashlib.sha256(data).hexdigest() - results[mtype] = (random_name, fhash) + fhash = hashlib.sha256(data).hexdigest() + results[mtype] = (random_name, fhash) + path_map[spath] = (random_name, fhash) + except Exception as e: + logger.warning(f"Error processing {mtype} at {spath}: {e}") return results @@ -1674,13 +1694,13 @@ async def _make_async_gen(item: ModelOutput) -> AsyncGenerator[ModelOutput]: ) current_index += 1 - image_items: list[ImageGenerationCall] = [] - final_response_contents: list[ResponseOutputContent] = [] + image_items = [] + final_response_contents = [] seen_hashes = {} images = [] seen_image_urls = set() - media_items: list[GeneratedVideo | GeneratedMedia] = [] + media_items = [] seen_media_urls = set() for out in all_outputs: @@ -1731,7 +1751,6 @@ async def _make_async_gen(item: ModelOutput) -> AsyncGenerator[ModelOutput]: "item": img_item.model_dump(mode="json"), }, ) - yield make_event( "response.output_item.done", { @@ -1744,8 +1763,8 @@ async def _make_async_gen(item: ModelOutput) -> AsyncGenerator[ModelOutput]: current_index += 1 image_items.append(img_item) storage_output += f"\n\n{image_url}" - except Exception: - logger.warning("Image processing failed in stream") + except Exception as e: + logger.warning(f"Image processing failed in stream: {e}") seen_media_hashes = {} for media_item in media_items: @@ -1988,17 +2007,17 @@ async def create_chat_completion( thoughts, raw_clean, structured_requirement ) - # Process images for OpenAI non-streaming flow images = resp_or_stream.images or [] image_markdown = "" - seen_hashes = set() + seen_hashes = {} for image in images: try: - _, _, _, fname, fhash = await _image_to_base64(image, media_store) + b64, w, h, fname, fhash = await _image_to_base64(image, media_store) if fhash in seen_hashes: (media_store / fname).unlink(missing_ok=True) - continue - seen_hashes.add(fhash) + b64, w, h, fname = seen_hashes[fhash] + else: + seen_hashes[fhash] = (b64, w, h, fname) img_url = f"![{fname}]({base_url}media/{fname}?token={get_media_token(fname)})" image_markdown += f"\n\n{img_url}" From d85552d88839ab233875a7a7facb17069d6bccb6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C6=B0u=20Quang=20V=C5=A9?= Date: Sat, 7 Mar 2026 09:34:35 +0700 Subject: [PATCH 197/291] Update the Markdown link handling to correctly include thumbnails. --- app/server/chat.py | 84 ++++++++++++++++++++++++++++------------------ 1 file changed, 52 insertions(+), 32 deletions(-) diff --git a/app/server/chat.py b/app/server/chat.py index 15c8efd..e01aa76 100644 --- a/app/server/chat.py +++ b/app/server/chat.py @@ -1244,16 +1244,19 @@ def make_chunk(delta_content: dict) -> str: media_results = [] seen_media_hashes = {} + last_thumb_url = None for media_item in media_items: try: media_store = get_media_store_dir() m_dict = await _media_to_local_file(media_item, media_store) + logger.debug(f"Media processing keys: {list(m_dict.keys())}") m_urls = {} for mtype, (random_name, fhash) in m_dict.items(): if fhash in seen_media_hashes: - (media_store / random_name).unlink(missing_ok=True) existing_name = seen_media_hashes[fhash] + if random_name != existing_name: + (media_store / random_name).unlink(missing_ok=True) m_urls[mtype] = ( f"{base_url}media/{existing_name}?token={get_media_token(existing_name)}" ) @@ -1263,25 +1266,27 @@ def make_chunk(delta_content: dict) -> str: f"{base_url}media/{random_name}?token={get_media_token(random_name)}" ) + logger.debug(f"Media processing m_urls: {m_urls}") title = getattr(media_item, "title", "Media") video_url = m_urls.get("video") audio_url = m_urls.get("audio") - video_thumb = m_urls.get("video_thumbnail") - audio_thumb = m_urls.get("audio_thumbnail") + item_thumb = m_urls.get("video_thumbnail") or m_urls.get("audio_thumbnail") + if item_thumb: + last_thumb_url = item_thumb md_parts = [] if video_url: md_parts.append( - f"[![{title}]({video_thumb})]({video_url})" - if video_thumb + f"[![{title}]({last_thumb_url})]({video_url})" + if last_thumb_url else f"[{title}]({video_url})" ) if audio_url: md_parts.append( - f"[![{title} (Audio)]({audio_thumb})]({audio_url})" - if audio_thumb - else f"[{title} (Audio)]({audio_url})" + f"[![{title} - Audio]({last_thumb_url})]({audio_url})" + if last_thumb_url + else f"[{title} - Audio]({audio_url})" ) if md_parts: media_results.append("\n\n".join(md_parts)) @@ -1767,15 +1772,18 @@ async def _make_async_gen(item: ModelOutput) -> AsyncGenerator[ModelOutput]: logger.warning(f"Image processing failed in stream: {e}") seen_media_hashes = {} + last_thumb_url = None for media_item in media_items: try: m_dict = await _media_to_local_file(media_item, media_store) + logger.debug(f"Media processing m_dict: {m_dict}") m_urls = {} for mtype, (random_name, fhash) in m_dict.items(): if fhash in seen_media_hashes: - (media_store / random_name).unlink(missing_ok=True) existing_name = seen_media_hashes[fhash] + if random_name != existing_name: + (media_store / random_name).unlink(missing_ok=True) m_urls[mtype] = ( f"{base_url}media/{existing_name}?token={get_media_token(existing_name)}" ) @@ -1785,25 +1793,27 @@ async def _make_async_gen(item: ModelOutput) -> AsyncGenerator[ModelOutput]: f"{base_url}media/{random_name}?token={get_media_token(random_name)}" ) + logger.debug(f"Media processing m_urls: {m_urls}") title = getattr(media_item, "title", "Media") video_url = m_urls.get("video") audio_url = m_urls.get("audio") - video_thumb = m_urls.get("video_thumbnail") - audio_thumb = m_urls.get("audio_thumbnail") + item_thumb = m_urls.get("video_thumbnail") or m_urls.get("audio_thumbnail") + if item_thumb: + last_thumb_url = item_thumb md_parts = [] if video_url: md_parts.append( - f"[![{title}]({video_thumb})]({video_url})" - if video_thumb + f"[![{title}]({last_thumb_url})]({video_url})" + if last_thumb_url else f"[{title}]({video_url})" ) if audio_url: md_parts.append( - f"[![{title} (Audio)]({audio_thumb})]({audio_url})" - if audio_thumb - else f"[{title} (Audio)]({audio_url})" + f"[![{title} - Audio]({last_thumb_url})]({audio_url})" + if last_thumb_url + else f"[{title} - Audio]({audio_url})" ) media_md = "\n\n".join(md_parts) @@ -2033,15 +2043,18 @@ async def create_chat_completion( ) media_markdown = "" seen_media_hashes = {} + last_thumb_url = None for m_item in media_items: try: m_dict = await _media_to_local_file(m_item, media_store) + logger.debug(f"Media processing m_dict: {m_dict}") m_urls = {} for mtype, (random_name, fhash) in m_dict.items(): if fhash in seen_media_hashes: - (media_store / random_name).unlink(missing_ok=True) existing_name = seen_media_hashes[fhash] + if random_name != existing_name: + (media_store / random_name).unlink(missing_ok=True) m_urls[mtype] = ( f"{base_url}media/{existing_name}?token={get_media_token(existing_name)}" ) @@ -2051,25 +2064,27 @@ async def create_chat_completion( f"{base_url}media/{random_name}?token={get_media_token(random_name)}" ) + logger.debug(f"Media processing m_urls: {m_urls}") title = getattr(m_item, "title", "Media") video_url = m_urls.get("video") audio_url = m_urls.get("audio") - video_thumb = m_urls.get("video_thumbnail") - audio_thumb = m_urls.get("audio_thumbnail") + item_thumb = m_urls.get("video_thumbnail") or m_urls.get("audio_thumbnail") + if item_thumb: + last_thumb_url = item_thumb md_parts = [] if video_url: md_parts.append( - f"[![{title}]({video_thumb})]({video_url})" - if video_thumb + f"[![{title}]({last_thumb_url})]({video_url})" + if last_thumb_url else f"[{title}]({video_url})" ) if audio_url: md_parts.append( - f"[![{title} (Audio)]({audio_thumb})]({audio_url})" - if audio_thumb - else f"[{title} (Audio)]({audio_url})" + f"[![{title} - Audio]({last_thumb_url})]({audio_url})" + if last_thumb_url + else f"[{title} - Audio]({audio_url})" ) if md_parts: @@ -2301,15 +2316,18 @@ async def create_response( ) seen_media_hashes = {} media_markdown = "" + last_thumb_url = None for m_item in media_items: try: m_dict = await _media_to_local_file(m_item, media_store) + logger.debug(f"Media processing m_dict: {m_dict}") m_urls = {} for mtype, (random_name, fhash) in m_dict.items(): if fhash in seen_media_hashes: - (media_store / random_name).unlink(missing_ok=True) existing_name = seen_media_hashes[fhash] + if random_name != existing_name: + (media_store / random_name).unlink(missing_ok=True) m_urls[mtype] = ( f"{base_url}media/{existing_name}?token={get_media_token(existing_name)}" ) @@ -2319,25 +2337,27 @@ async def create_response( f"{base_url}media/{random_name}?token={get_media_token(random_name)}" ) + logger.debug(f"Media processing m_urls: {m_urls}") title = getattr(m_item, "title", "Media") video_url = m_urls.get("video") audio_url = m_urls.get("audio") - video_thumb = m_urls.get("video_thumbnail") - audio_thumb = m_urls.get("audio_thumbnail") + item_thumb = m_urls.get("video_thumbnail") or m_urls.get("audio_thumbnail") + if item_thumb: + last_thumb_url = item_thumb md_parts = [] if video_url: md_parts.append( - f"[![{title}]({video_thumb})]({video_url})" - if video_thumb + f"[![{title}]({last_thumb_url})]({video_url})" + if last_thumb_url else f"[{title}]({video_url})" ) if audio_url: md_parts.append( - f"[![{title} (Audio)]({audio_thumb})]({audio_url})" - if audio_thumb - else f"[{title} (Audio)]({audio_url})" + f"[![{title} - Audio]({last_thumb_url})]({audio_url})" + if last_thumb_url + else f"[{title} - Audio]({audio_url})" ) if md_parts: From 9dfc866a7251c578c57993bac7b9e0efc44e9615 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C6=B0u=20Quang=20V=C5=A9?= Date: Sat, 7 Mar 2026 10:55:25 +0700 Subject: [PATCH 198/291] Update the Markdown link handling to correctly include thumbnails. --- app/server/chat.py | 80 ++++++++++++++++++++++++++++------------------ 1 file changed, 49 insertions(+), 31 deletions(-) diff --git a/app/server/chat.py b/app/server/chat.py index e01aa76..4d344bf 100644 --- a/app/server/chat.py +++ b/app/server/chat.py @@ -1206,25 +1206,38 @@ def make_chunk(delta_content: dict) -> str: full_thoughts, full_text, structured_requirement ) - images = [] - seen_image_urls = set() - media_items: list[GeneratedVideo | GeneratedMedia] = [] - seen_media_urls = set() + image_map = {} + media_items = [] + media_url_to_idx = {} for out in all_outputs: - if out.images: - for img in out.images: - if img.url not in seen_image_urls: - images.append(img) - seen_image_urls.add(img.url) + for img in out.images or []: + if img.url: + image_map[img.url] = img m_list = (out.videos or []) + (out.media or []) for m in m_list: - m_url = getattr(m, "url", None) or getattr(m, "mp3_url", None) - if m_url and m_url not in seen_media_urls: + v_url = getattr(m, "url", None) + a_url = getattr(m, "mp3_url", None) + if not v_url and not a_url: + continue + + idx = (media_url_to_idx.get(v_url) if v_url else None) or ( + media_url_to_idx.get(a_url) if a_url else None + ) + + if idx is not None: + media_items[idx] = m + else: + idx = len(media_items) media_items.append(m) - seen_media_urls.add(m_url) + if v_url: + media_url_to_idx[v_url] = idx + if a_url: + media_url_to_idx[a_url] = idx + + images = list(image_map.values()) image_results = [] seen_hashes = {} for image in images: @@ -1249,7 +1262,6 @@ def make_chunk(delta_content: dict) -> str: try: media_store = get_media_store_dir() m_dict = await _media_to_local_file(media_item, media_store) - logger.debug(f"Media processing keys: {list(m_dict.keys())}") m_urls = {} for mtype, (random_name, fhash) in m_dict.items(): @@ -1266,7 +1278,6 @@ def make_chunk(delta_content: dict) -> str: f"{base_url}media/{random_name}?token={get_media_token(random_name)}" ) - logger.debug(f"Media processing m_urls: {m_urls}") title = getattr(media_item, "title", "Media") video_url = m_urls.get("video") audio_url = m_urls.get("audio") @@ -1703,25 +1714,38 @@ async def _make_async_gen(item: ModelOutput) -> AsyncGenerator[ModelOutput]: final_response_contents = [] seen_hashes = {} - images = [] - seen_image_urls = set() + image_map = {} media_items = [] - seen_media_urls = set() + media_url_to_idx = {} for out in all_outputs: - if out.images: - for img in out.images: - if img.url not in seen_image_urls: - images.append(img) - seen_image_urls.add(img.url) + for img in out.images or []: + if img.url: + image_map[img.url] = img m_list = (out.videos or []) + (out.media or []) for m in m_list: - m_url = getattr(m, "url", None) or getattr(m, "mp3_url", None) - if m_url and m_url not in seen_media_urls: + v_url = getattr(m, "url", None) + a_url = getattr(m, "mp3_url", None) + if not v_url and not a_url: + continue + + idx = (media_url_to_idx.get(v_url) if v_url else None) or ( + media_url_to_idx.get(a_url) if a_url else None + ) + + if idx is not None: + media_items[idx] = m + else: + idx = len(media_items) media_items.append(m) - seen_media_urls.add(m_url) + if v_url: + media_url_to_idx[v_url] = idx + if a_url: + media_url_to_idx[a_url] = idx + + images = list(image_map.values()) for image in images: try: b64, w, h, fname, fhash = await _image_to_base64(image, media_store) @@ -1776,7 +1800,6 @@ async def _make_async_gen(item: ModelOutput) -> AsyncGenerator[ModelOutput]: for media_item in media_items: try: m_dict = await _media_to_local_file(media_item, media_store) - logger.debug(f"Media processing m_dict: {m_dict}") m_urls = {} for mtype, (random_name, fhash) in m_dict.items(): @@ -1793,7 +1816,6 @@ async def _make_async_gen(item: ModelOutput) -> AsyncGenerator[ModelOutput]: f"{base_url}media/{random_name}?token={get_media_token(random_name)}" ) - logger.debug(f"Media processing m_urls: {m_urls}") title = getattr(media_item, "title", "Media") video_url = m_urls.get("video") audio_url = m_urls.get("audio") @@ -2047,7 +2069,6 @@ async def create_chat_completion( for m_item in media_items: try: m_dict = await _media_to_local_file(m_item, media_store) - logger.debug(f"Media processing m_dict: {m_dict}") m_urls = {} for mtype, (random_name, fhash) in m_dict.items(): @@ -2064,7 +2085,6 @@ async def create_chat_completion( f"{base_url}media/{random_name}?token={get_media_token(random_name)}" ) - logger.debug(f"Media processing m_urls: {m_urls}") title = getattr(m_item, "title", "Media") video_url = m_urls.get("video") audio_url = m_urls.get("audio") @@ -2320,7 +2340,6 @@ async def create_response( for m_item in media_items: try: m_dict = await _media_to_local_file(m_item, media_store) - logger.debug(f"Media processing m_dict: {m_dict}") m_urls = {} for mtype, (random_name, fhash) in m_dict.items(): @@ -2337,7 +2356,6 @@ async def create_response( f"{base_url}media/{random_name}?token={get_media_token(random_name)}" ) - logger.debug(f"Media processing m_urls: {m_urls}") title = getattr(m_item, "title", "Media") video_url = m_urls.get("video") audio_url = m_urls.get("audio") From 1f11c9547c1f93e63fc3dade685ee923d1c06533 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C6=B0u=20Quang=20V=C5=A9?= Date: Sun, 8 Mar 2026 22:13:52 +0700 Subject: [PATCH 199/291] Resolve mismatch of reusable sessions --- app/server/chat.py | 34 +++++++++++++++++++++------------- app/services/lmdb.py | 1 - app/utils/config.py | 2 +- config/config.yaml | 2 +- 4 files changed, 23 insertions(+), 16 deletions(-) diff --git a/app/server/chat.py b/app/server/chat.py index 4d344bf..25a1d6a 100644 --- a/app/server/chat.py +++ b/app/server/chat.py @@ -77,7 +77,7 @@ ) MAX_CHARS_PER_REQUEST = int(g_config.gemini.max_chars_per_request * 0.9) -METADATA_TTL_MINUTES = 15 +METADATA_TTL_MINUTES = 60 router = APIRouter() @@ -1188,8 +1188,8 @@ def make_chunk(delta_content: dict) -> str: {"delta": {"content": visible_delta}, "finish_reason": None} ) except Exception as e: - logger.exception(f"Error during OpenAI streaming: {e}") - yield f"data: {orjson.dumps({'error': {'message': 'Streaming error occurred.', 'type': 'server_error', 'param': None, 'code': None}}).decode('utf-8')}\n\n" + logger.exception(f"Error during streaming: {e}") + yield f"data: {orjson.dumps({'error': {'message': f'Streaming error occurred: {e}', 'type': 'server_error', 'param': None, 'code': None}}).decode('utf-8')}\n\n" return if all_outputs: @@ -1305,6 +1305,7 @@ def make_chunk(delta_content: dict) -> str: logger.warning(f"Failed to process media in OpenAI stream: {exc}") for image_url in image_results: + storage_output += f"\n\n{image_url}" yield make_chunk( { "delta": {"content": f"\n\n{image_url}"}, @@ -1313,6 +1314,7 @@ def make_chunk(delta_content: dict) -> str: ) for media_md in media_results: + storage_output += f"\n\n{media_md}" yield make_chunk( { "delta": {"content": f"\n\n{media_md}"}, @@ -1596,11 +1598,15 @@ async def _make_async_gen(item: ModelOutput) -> AsyncGenerator[ModelOutput]: }, ) - except Exception: - logger.exception("Responses streaming error") + except Exception as e: + logger.exception(f"Error during streaming: {e}") yield make_event( "error", - {**base_event, "type": "error", "error": {"message": "Streaming error."}}, + { + **base_event, + "type": "error", + "error": {"message": f"Streaming error occurred: {e}"}, + }, ) return @@ -1766,9 +1772,10 @@ async def _make_async_gen(item: ModelOutput) -> AsyncGenerator[ModelOutput]: size=f"{w}x{h}" if w and h else None, ) - image_url = f"![{fname}]({base_url}media/{fname}?token={get_media_token(fname)})" + img_link = f"![{fname}]({base_url}media/{fname}?token={get_media_token(fname)})" + image_url_with_newline = f"\n\n{img_link}" final_response_contents.append( - ResponseOutputText(type="output_text", text=image_url) + ResponseOutputText(type="output_text", text=image_url_with_newline) ) yield make_event( @@ -1791,7 +1798,7 @@ async def _make_async_gen(item: ModelOutput) -> AsyncGenerator[ModelOutput]: ) current_index += 1 image_items.append(img_item) - storage_output += f"\n\n{image_url}" + storage_output += image_url_with_newline except Exception as e: logger.warning(f"Image processing failed in stream: {e}") @@ -1841,10 +1848,11 @@ async def _make_async_gen(item: ModelOutput) -> AsyncGenerator[ModelOutput]: media_md = "\n\n".join(md_parts) if media_md: + media_md_with_newline = f"\n\n{media_md}" final_response_contents.append( - ResponseOutputText(type="output_text", text=media_md) + ResponseOutputText(type="output_text", text=media_md_with_newline) ) - storage_output += f"\n\n{media_md}" + storage_output += media_md_with_newline except Exception: logger.warning("Media processing failed in stream") @@ -2329,7 +2337,7 @@ async def create_response( if image_markdown: storage_output += image_markdown - contents.append(ResponseOutputText(type="output_text", text=image_markdown.strip())) + contents.append(ResponseOutputText(type="output_text", text=image_markdown)) media_items: list[GeneratedVideo | GeneratedMedia] = (resp_or_stream.videos or []) + ( resp_or_stream.media or [] @@ -2386,7 +2394,7 @@ async def create_response( if media_markdown: storage_output += media_markdown - contents.append(ResponseOutputText(type="output_text", text=media_markdown.strip())) + contents.append(ResponseOutputText(type="output_text", text=media_markdown)) p_tok, c_tok, t_tok, r_tok = _calculate_usage(messages, assistant_text, tool_calls, thoughts) usage = ResponseUsage( diff --git a/app/services/lmdb.py b/app/services/lmdb.py index 5a32089..2f45193 100644 --- a/app/services/lmdb.py +++ b/app/services/lmdb.py @@ -43,7 +43,6 @@ def _normalize_text(text: str | None, fuzzy: bool = False) -> str | None: text = normalize_llm_text(text) text = unescape_text(text) - text = remove_tool_call_blocks(text) if fuzzy: diff --git a/app/utils/config.py b/app/utils/config.py index a52bab5..03e6f6d 100644 --- a/app/utils/config.py +++ b/app/utils/config.py @@ -82,7 +82,7 @@ class GeminiConfig(BaseModel): default="append", description="Strategy for loading models: 'append' merges custom with default, 'overwrite' uses only custom", ) - timeout: int = Field(default=600, ge=30, description="Init timeout in seconds") + timeout: int = Field(default=450, ge=30, description="Init timeout in seconds") watchdog_timeout: int = Field(default=90, ge=30, description="Watchdog timeout in seconds") auto_refresh: bool = Field(True, description="Enable auto-refresh for Gemini cookies") refresh_interval: int = Field( diff --git a/config/config.yaml b/config/config.yaml index 746be67..bfc9306 100644 --- a/config/config.yaml +++ b/config/config.yaml @@ -22,7 +22,7 @@ gemini: secure_1psid: "YOUR_SECURE_1PSID_HERE" secure_1psidts: "YOUR_SECURE_1PSIDTS_HERE" proxy: null # Optional proxy URL (null/empty means direct connection) - timeout: 600 # Init timeout in seconds (Not less than 30s) + timeout: 450 # Init timeout in seconds (Not less than 30s) watchdog_timeout: 90 # Watchdog timeout in seconds (Not less than 30s) auto_refresh: true # Auto-refresh session cookies refresh_interval: 600 # Refresh interval in seconds (Not less than 60s) From bd24c53d7f44820703e6b5e852a3859d54a455f9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C6=B0u=20Quang=20V=C5=A9?= Date: Sun, 8 Mar 2026 22:14:22 +0700 Subject: [PATCH 200/291] Update dependencies to latest versions --- uv.lock | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/uv.lock b/uv.lock index cc223d2..aba96fe 100644 --- a/uv.lock +++ b/uv.lock @@ -176,8 +176,8 @@ dev = [{ name = "gemini-fastapi", extras = ["dev"] }] [[package]] name = "gemini-webapi" -version = "0.0.post257" -source = { git = "https://github.com/luuquangvu/Gemini-API.git?rev=move-httpx-to-curl_cffi#59d729862fa0a936ec7bbe879cfc82c51ae912ca" } +version = "0.0.post258" +source = { git = "https://github.com/luuquangvu/Gemini-API.git?rev=move-httpx-to-curl_cffi#4fd2ee6c4e880f693768d32c73220e89661fcc12" } dependencies = [ { name = "curl-cffi" }, { name = "loguru" }, From cfd96d5d5d73aefa8bdb7e4add7ab576c91cbc73 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C6=B0u=20Quang=20V=C5=A9?= Date: Mon, 9 Mar 2026 13:22:42 +0700 Subject: [PATCH 201/291] Resolve the issue where downloading media takes too long and results in a timeout. --- app/server/chat.py | 934 +++++++++++++++++++++++++-------------------- 1 file changed, 529 insertions(+), 405 deletions(-) diff --git a/app/server/chat.py b/app/server/chat.py index 25a1d6a..e0df9c6 100644 --- a/app/server/chat.py +++ b/app/server/chat.py @@ -1,3 +1,4 @@ +import asyncio import base64 import hashlib import io @@ -137,8 +138,11 @@ async def _media_to_local_file( """Persist media and return dict mapping type to (filename, hash)""" try: saved_paths = await media.save(path=str(temp_dir)) + if not saved_paths: + logger.warning("No files saved from media object.") + return {} except Exception as e: - logger.warning(f"Failed to save media: {e}", exc_info=True) + logger.error(f"Failed to save media: {e}") return {} default_extensions = { @@ -224,10 +228,12 @@ def _create_responses_standard_payload( usage: ResponseUsage, request: ResponseCreateRequest, full_thoughts: str | None = None, + message_id: str | None = None, + reason_id: str | None = None, ) -> ResponseCreateResponse: """Unified factory for building ResponseCreateResponse objects.""" - message_id = f"msg_{uuid.uuid4().hex[:24]}" - reason_id = f"rs_{uuid.uuid4().hex[:24]}" + message_id = message_id or f"msg_{uuid.uuid4().hex[:24]}" + reason_id = reason_id or f"rs_{uuid.uuid4().hex[:24]}" now_ts = int(datetime.now(tz=UTC).timestamp()) output_items: list[Any] = [] @@ -241,15 +247,16 @@ def _create_responses_standard_payload( ) ) - output_items.append( - ResponseOutputMessage( - id=message_id, - type="message", - status="completed", - role="assistant", - content=response_contents, + if response_contents or not (detected_tool_calls or image_call_items): + output_items.append( + ResponseOutputMessage( + id=message_id, + type="message", + status="completed", + role="assistant", + content=response_contents, + ) ) - ) if detected_tool_calls: output_items.extend( @@ -1009,7 +1016,7 @@ async def _send_with_split( return session.send_message_stream(text, files=files) return await session.send_message(text, files=files) except Exception as e: - logger.exception(f"Error sending message to Gemini: {e}") + logger.error(f"Error sending message to Gemini: {e}") raise logger.info( @@ -1031,7 +1038,7 @@ async def _send_with_split( return session.send_message_stream(instruction, files=final_files) return await session.send_message(instruction, files=final_files) except Exception as e: - logger.exception(f"Error sending large text as file to Gemini: {e}") + logger.error(f"Error sending large text as file to Gemini: {e}") raise @@ -1121,6 +1128,29 @@ def flush(self) -> str: return strip_system_hints(res) +# --- Media Processing Helpers --- + + +async def _process_image_item(image: Image): + """Process an image item by converting it to base64 and returning a standard result tuple.""" + try: + media_store = get_media_store_dir() + return "image", image, await _image_to_base64(image, media_store) + except Exception as exc: + logger.warning(f"Background image processing failed: {exc}") + return None + + +async def _process_media_item(media_item: GeneratedVideo | GeneratedMedia): + """Process a media item by saving it to a local file and returning a standard result tuple.""" + try: + media_store = get_media_store_dir() + return "media", media_item, await _media_to_local_file(media_item, media_store) + except Exception as exc: + logger.warning(f"Background media processing failed: {exc}") + return None + + # --- Response Builders & Streaming --- @@ -1143,11 +1173,16 @@ def _create_real_streaming_response( """ async def generate_stream(): - full_thoughts, full_text = "", "" + full_text = "" + full_thoughts = "" has_started = False all_outputs: list[ModelOutput] = [] suppressor = StreamingOutputFilter() + media_tasks = [] + seen_media_urls = set() + seen_image_urls = set() + async def _make_async_gen(item: ModelOutput) -> AsyncGenerator[ModelOutput]: yield item @@ -1187,8 +1222,20 @@ def make_chunk(delta_content: dict) -> str: yield make_chunk( {"delta": {"content": visible_delta}, "finish_reason": None} ) + + for img in chunk.images or []: + if img.url and img.url not in seen_image_urls: + seen_image_urls.add(img.url) + media_tasks.append(asyncio.create_task(_process_image_item(img))) + + m_list = (chunk.videos or []) + (chunk.media or []) + for m in m_list: + p_url = getattr(m, "url", None) or getattr(m, "mp3_url", None) + if p_url and p_url not in seen_media_urls: + seen_media_urls.add(p_url) + media_tasks.append(asyncio.create_task(_process_media_item(m))) except Exception as e: - logger.exception(f"Error during streaming: {e}") + logger.error(f"Error during streaming: {e}") yield f"data: {orjson.dumps({'error': {'message': f'Streaming error occurred: {e}', 'type': 'server_error', 'param': None, 'code': None}}).decode('utf-8')}\n\n" return @@ -1202,125 +1249,93 @@ def make_chunk(delta_content: dict) -> str: if remaining_text := suppressor.flush(): yield make_chunk({"delta": {"content": remaining_text}, "finish_reason": None}) - _thoughts, assistant_text, storage_output, detected_tool_calls = _process_llm_output( + _, _, storage_output, detected_tool_calls = _process_llm_output( full_thoughts, full_text, structured_requirement ) - image_map = {} - media_items = [] - media_url_to_idx = {} + seen_hashes = {} + seen_media_hashes = {} + media_store = get_media_store_dir() - for out in all_outputs: - for img in out.images or []: - if img.url: - image_map[img.url] = img + if media_tasks: + logger.debug(f"Waiting for {len(media_tasks)} background media tasks with heartbeat...") + while media_tasks: + done, pending = await asyncio.wait( + media_tasks, timeout=5.0, return_when=asyncio.FIRST_COMPLETED + ) + media_tasks = list(pending) - m_list = (out.videos or []) + (out.media or []) - for m in m_list: - v_url = getattr(m, "url", None) - a_url = getattr(m, "mp3_url", None) - if not v_url and not a_url: + if not done: + yield ": ping\n\n" continue - idx = (media_url_to_idx.get(v_url) if v_url else None) or ( - media_url_to_idx.get(a_url) if a_url else None - ) - - if idx is not None: - media_items[idx] = m - else: - idx = len(media_items) - media_items.append(m) + for task in done: + res = task.result() + if not res: + continue - if v_url: - media_url_to_idx[v_url] = idx - if a_url: - media_url_to_idx[a_url] = idx + rtype, original_item, media_data = res + if rtype == "image": + _, _, _, fname, fhash = media_data + if fhash in seen_hashes: + (media_store / fname).unlink(missing_ok=True) + fname = seen_hashes[fhash] + else: + seen_hashes[fhash] = fname + + img_url = f"{base_url}media/{fname}?token={get_media_token(fname)}" + title = getattr(original_item, "title", "Image") + md = f"![{title}]({img_url})" + storage_output += f"\n\n{md}" + yield make_chunk({"delta": {"content": f"\n\n{md}"}, "finish_reason": None}) + + elif rtype == "media": + m_dict = media_data + if not m_dict: + continue + + m_urls = {} + for mtype, (random_name, fhash) in m_dict.items(): + if fhash in seen_media_hashes: + existing_name = seen_media_hashes[fhash] + if random_name != existing_name: + (media_store / random_name).unlink(missing_ok=True) + m_urls[mtype] = ( + f"{base_url}media/{existing_name}?token={get_media_token(existing_name)}" + ) + else: + seen_media_hashes[fhash] = random_name + m_urls[mtype] = ( + f"{base_url}media/{random_name}?token={get_media_token(random_name)}" + ) - images = list(image_map.values()) - image_results = [] - seen_hashes = {} - for image in images: - try: - media_store = get_media_store_dir() - _, _, _, fname, fhash = await _image_to_base64(image, media_store) - if fhash in seen_hashes: - (media_store / fname).unlink(missing_ok=True) - fname = seen_hashes[fhash] - else: - seen_hashes[fhash] = fname - img_url = f"{base_url}media/{fname}?token={get_media_token(fname)}" - title = getattr(image, "title", "Image") - image_results.append(f"![{title}]({img_url})") - except Exception as exc: - logger.warning(f"Failed to process image in OpenAI stream: {exc}") - - media_results = [] - seen_media_hashes = {} - last_thumb_url = None - for media_item in media_items: - try: - media_store = get_media_store_dir() - m_dict = await _media_to_local_file(media_item, media_store) - - m_urls = {} - for mtype, (random_name, fhash) in m_dict.items(): - if fhash in seen_media_hashes: - existing_name = seen_media_hashes[fhash] - if random_name != existing_name: - (media_store / random_name).unlink(missing_ok=True) - m_urls[mtype] = ( - f"{base_url}media/{existing_name}?token={get_media_token(existing_name)}" + title = getattr(original_item, "title", "Media") + video_url = m_urls.get("video") + audio_url = m_urls.get("audio") + current_thumb = m_urls.get("video_thumbnail") or m_urls.get( + "audio_thumbnail" ) - continue - seen_media_hashes[fhash] = random_name - m_urls[mtype] = ( - f"{base_url}media/{random_name}?token={get_media_token(random_name)}" - ) - title = getattr(media_item, "title", "Media") - video_url = m_urls.get("video") - audio_url = m_urls.get("audio") - item_thumb = m_urls.get("video_thumbnail") or m_urls.get("audio_thumbnail") - if item_thumb: - last_thumb_url = item_thumb - - md_parts = [] - if video_url: - md_parts.append( - f"[![{title}]({last_thumb_url})]({video_url})" - if last_thumb_url - else f"[{title}]({video_url})" - ) - - if audio_url: - md_parts.append( - f"[![{title} - Audio]({last_thumb_url})]({audio_url})" - if last_thumb_url - else f"[{title} - Audio]({audio_url})" - ) - if md_parts: - media_results.append("\n\n".join(md_parts)) - except Exception as exc: - logger.warning(f"Failed to process media in OpenAI stream: {exc}") - - for image_url in image_results: - storage_output += f"\n\n{image_url}" - yield make_chunk( - { - "delta": {"content": f"\n\n{image_url}"}, - "finish_reason": None, - } - ) + md_parts = [] + if video_url: + md_parts.append( + f"[![{title}]({current_thumb})]({video_url})" + if current_thumb + else f"[{title}]({video_url})" + ) + if audio_url: + md_parts.append( + f"[![{title} - Audio]({current_thumb})]({audio_url})" + if current_thumb + else f"[{title} - Audio]({audio_url})" + ) - for media_md in media_results: - storage_output += f"\n\n{media_md}" - yield make_chunk( - { - "delta": {"content": f"\n\n{media_md}"}, - "finish_reason": None, - } - ) + if md_parts: + md = "\n\n".join(md_parts) + storage_output += f"\n\n{md}" + yield make_chunk( + {"delta": {"content": f"\n\n{md}"}, "finish_reason": None} + ) if detected_tool_calls: for idx, call in enumerate(detected_tool_calls): @@ -1341,7 +1356,7 @@ def make_chunk(delta_content: dict) -> str: ) p_tok, c_tok, t_tok, r_tok = _calculate_usage( - messages, assistant_text, detected_tool_calls, full_thoughts + messages, storage_output, detected_tool_calls, full_thoughts ) usage = CompletionUsage( prompt_tokens=p_tok, @@ -1381,7 +1396,6 @@ def _create_responses_real_streaming_response( client_wrapper: GeminiClientWrapper, session: ChatSession, request: ResponseCreateRequest, - media_store: Path, base_url: str, structured_requirement: StructuredOutputRequirement | None = None, ) -> StreamingResponse: @@ -1442,14 +1456,21 @@ def make_event(etype: str, data: dict) -> str: }, ) - full_thoughts, full_text = "", "" + full_text = "" + full_thoughts = "" + media_tasks = [] + seen_media_urls = set() + seen_image_urls = set() + all_outputs: list[ModelOutput] = [] thought_item_id = f"rs_{uuid.uuid4().hex[:24]}" message_item_id = f"msg_{uuid.uuid4().hex[:24]}" thought_open, message_open = False, False - current_index = 0 + next_output_index = 0 + thought_index = 0 + message_index = 0 suppressor = StreamingOutputFilter() try: @@ -1467,12 +1488,14 @@ async def _make_async_gen(item: ModelOutput) -> AsyncGenerator[ModelOutput]: if chunk.thoughts_delta: if not thought_open: + thought_index = next_output_index + next_output_index += 1 yield make_event( "response.output_item.added", { **base_event, "type": "response.output_item.added", - "output_index": current_index, + "output_index": thought_index, "item": ResponseReasoningItem( id=thought_item_id, type="reasoning", @@ -1488,7 +1511,7 @@ async def _make_async_gen(item: ModelOutput) -> AsyncGenerator[ModelOutput]: **base_event, "type": "response.reasoning_summary_part.added", "item_id": thought_item_id, - "output_index": current_index, + "output_index": thought_index, "summary_index": 0, "part": SummaryTextContent(text="").model_dump(mode="json"), }, @@ -1502,7 +1525,7 @@ async def _make_async_gen(item: ModelOutput) -> AsyncGenerator[ModelOutput]: **base_event, "type": "response.reasoning_summary_text.delta", "item_id": thought_item_id, - "output_index": current_index, + "output_index": thought_index, "summary_index": 0, "delta": chunk.thoughts_delta, }, @@ -1516,7 +1539,7 @@ async def _make_async_gen(item: ModelOutput) -> AsyncGenerator[ModelOutput]: **base_event, "type": "response.reasoning_summary_text.done", "item_id": thought_item_id, - "output_index": current_index, + "output_index": thought_index, "summary_index": 0, "text": full_thoughts, }, @@ -1527,7 +1550,7 @@ async def _make_async_gen(item: ModelOutput) -> AsyncGenerator[ModelOutput]: **base_event, "type": "response.reasoning_summary_part.done", "item_id": thought_item_id, - "output_index": current_index, + "output_index": thought_index, "summary_index": 0, "part": SummaryTextContent(text=full_thoughts).model_dump( mode="json" @@ -1539,7 +1562,7 @@ async def _make_async_gen(item: ModelOutput) -> AsyncGenerator[ModelOutput]: { **base_event, "type": "response.output_item.done", - "output_index": current_index, + "output_index": thought_index, "item": ResponseReasoningItem( id=thought_item_id, type="reasoning", @@ -1548,16 +1571,17 @@ async def _make_async_gen(item: ModelOutput) -> AsyncGenerator[ModelOutput]: ).model_dump(mode="json"), }, ) - current_index += 1 thought_open = False if not message_open: + message_index = next_output_index + next_output_index += 1 yield make_event( "response.output_item.added", { **base_event, "type": "response.output_item.added", - "output_index": current_index, + "output_index": message_index, "item": ResponseOutputMessage( id=message_item_id, type="message", @@ -1574,7 +1598,7 @@ async def _make_async_gen(item: ModelOutput) -> AsyncGenerator[ModelOutput]: **base_event, "type": "response.content_part.added", "item_id": message_item_id, - "output_index": current_index, + "output_index": message_index, "content_index": 0, "part": ResponseOutputText(type="output_text", text="").model_dump( mode="json" @@ -1591,15 +1615,27 @@ async def _make_async_gen(item: ModelOutput) -> AsyncGenerator[ModelOutput]: **base_event, "type": "response.output_text.delta", "item_id": message_item_id, - "output_index": current_index, + "output_index": message_index, "content_index": 0, "delta": visible, "logprobs": [], }, ) + for img in chunk.images or []: + if img.url and img.url not in seen_image_urls: + seen_image_urls.add(img.url) + media_tasks.append(asyncio.create_task(_process_image_item(img))) + + m_list = (chunk.videos or []) + (chunk.media or []) + for m in m_list: + p_url = getattr(m, "url", None) or getattr(m, "mp3_url", None) + if p_url and p_url not in seen_media_urls: + seen_media_urls.add(p_url) + media_tasks.append(asyncio.create_task(_process_media_item(m))) + except Exception as e: - logger.exception(f"Error during streaming: {e}") + logger.error(f"Error during streaming: {e}") yield make_event( "error", { @@ -1625,7 +1661,7 @@ async def _make_async_gen(item: ModelOutput) -> AsyncGenerator[ModelOutput]: **base_event, "type": "response.output_text.delta", "item_id": message_item_id, - "output_index": current_index, + "output_index": message_index, "content_index": 0, "delta": remaining, "logprobs": [], @@ -1639,7 +1675,7 @@ async def _make_async_gen(item: ModelOutput) -> AsyncGenerator[ModelOutput]: **base_event, "type": "response.reasoning_summary_text.done", "item_id": thought_item_id, - "output_index": current_index, + "output_index": thought_index, "summary_index": 0, "text": full_thoughts, }, @@ -1650,7 +1686,7 @@ async def _make_async_gen(item: ModelOutput) -> AsyncGenerator[ModelOutput]: **base_event, "type": "response.reasoning_summary_part.done", "item_id": thought_item_id, - "output_index": current_index, + "output_index": thought_index, "summary_index": 0, "part": SummaryTextContent(text=full_thoughts).model_dump(mode="json"), }, @@ -1660,7 +1696,7 @@ async def _make_async_gen(item: ModelOutput) -> AsyncGenerator[ModelOutput]: { **base_event, "type": "response.output_item.done", - "output_index": current_index, + "output_index": thought_index, "item": ResponseReasoningItem( id=thought_item_id, type="reasoning", @@ -1669,20 +1705,239 @@ async def _make_async_gen(item: ModelOutput) -> AsyncGenerator[ModelOutput]: ).model_dump(mode="json"), }, ) - current_index += 1 - _thoughts, assistant_text, storage_output, detected_tool_calls = _process_llm_output( + _, assistant_text, storage_output, detected_tool_calls = _process_llm_output( full_thoughts, full_text, structured_requirement ) + image_items = [] + seen_hashes = {} + seen_media_hashes = {} + media_store = get_media_store_dir() + + if media_tasks: + logger.debug( + f"Waiting for {len(media_tasks)} background media tasks in Responses with heartbeat..." + ) + while media_tasks: + done, pending = await asyncio.wait( + media_tasks, timeout=5.0, return_when=asyncio.FIRST_COMPLETED + ) + media_tasks = list(pending) + + if not done: + yield ": ping\n\n" + continue + + for task in done: + res = task.result() + if not res: + continue + + rtype, original_item, media_data = res + if rtype == "image": + b64, w, h, fname, fhash = media_data + if fhash in seen_hashes: + (media_store / fname).unlink(missing_ok=True) + b64, w, h, fname = seen_hashes[fhash] + else: + seen_hashes[fhash] = (b64, w, h, fname) + + parts = fname.rsplit(".", 1) + img_id = parts[0] + fmt = parts[1] if len(parts) > 1 else "png" + + img_item = ImageGenerationCall( + id=img_id, + result=b64, + output_format=fmt, + size=f"{w}x{h}" if w and h else None, + ) + + img_link = ( + f"![{fname}]({base_url}media/{fname}?token={get_media_token(fname)})" + ) + md_to_add = f"\n\n{img_link}" + + img_index = next_output_index + next_output_index += 1 + yield make_event( + "response.output_item.added", + { + **base_event, + "type": "response.output_item.added", + "output_index": img_index, + "item": img_item.model_dump(mode="json"), + }, + ) + yield make_event( + "response.output_item.done", + { + **base_event, + "type": "response.output_item.done", + "output_index": img_index, + "item": img_item.model_dump(mode="json"), + }, + ) + + if not message_open: + message_index = next_output_index + next_output_index += 1 + yield make_event( + "response.output_item.added", + { + **base_event, + "type": "response.output_item.added", + "output_index": message_index, + "item": ResponseOutputMessage( + id=message_item_id, + type="message", + status="in_progress", + role="assistant", + content=[], + ).model_dump(mode="json"), + }, + ) + yield make_event( + "response.content_part.added", + { + **base_event, + "type": "response.content_part.added", + "item_id": message_item_id, + "output_index": message_index, + "content_index": 0, + "part": ResponseOutputText( + type="output_text", text="" + ).model_dump(mode="json"), + }, + ) + message_open = True + + yield make_event( + "response.output_text.delta", + { + **base_event, + "type": "response.output_text.delta", + "item_id": message_item_id, + "output_index": message_index, + "content_index": 0, + "delta": md_to_add, + "logprobs": [], + }, + ) + assistant_text += md_to_add + storage_output += md_to_add + image_items.append(img_item) + + elif rtype == "media": + m_dict = media_data + if not m_dict: + continue + + m_urls = {} + for mtype, (random_name, fhash) in m_dict.items(): + if fhash in seen_media_hashes: + existing_name = seen_media_hashes[fhash] + if random_name != existing_name: + (media_store / random_name).unlink(missing_ok=True) + m_urls[mtype] = ( + f"{base_url}media/{existing_name}?token={get_media_token(existing_name)}" + ) + else: + seen_media_hashes[fhash] = random_name + m_urls[mtype] = ( + f"{base_url}media/{random_name}?token={get_media_token(random_name)}" + ) + + title = getattr(original_item, "title", "Media") + video_url = m_urls.get("video") + audio_url = m_urls.get("audio") + current_thumb = m_urls.get("video_thumbnail") or m_urls.get( + "audio_thumbnail" + ) + + md_parts = [] + if video_url: + md_parts.append( + f"[![{title}]({current_thumb})]({video_url})" + if current_thumb + else f"[{title}]({video_url})" + ) + if audio_url: + md_parts.append( + f"[![{title} - Audio]({current_thumb})]({audio_url})" + if current_thumb + else f"[{title} - Audio]({audio_url})" + ) + + if md_parts: + media_md = "\n\n".join(md_parts) + md_to_add = f"\n\n{media_md}" + + if not message_open: + message_index = next_output_index + next_output_index += 1 + yield make_event( + "response.output_item.added", + { + **base_event, + "type": "response.output_item.added", + "output_index": message_index, + "item": ResponseOutputMessage( + id=message_item_id, + type="message", + status="in_progress", + role="assistant", + content=[], + ).model_dump(mode="json"), + }, + ) + yield make_event( + "response.content_part.added", + { + **base_event, + "type": "response.content_part.added", + "item_id": message_item_id, + "output_index": message_index, + "content_index": 0, + "part": ResponseOutputText( + type="output_text", text="" + ).model_dump(mode="json"), + }, + ) + message_open = True + + yield make_event( + "response.output_text.delta", + { + **base_event, + "type": "response.output_text.delta", + "item_id": message_item_id, + "output_index": message_index, + "content_index": 0, + "delta": md_to_add, + "logprobs": [], + }, + ) + assistant_text += md_to_add + storage_output += md_to_add + + final_response_contents: list[ResponseOutputContent] = [] if message_open: + if assistant_text: + final_response_contents = [ + ResponseOutputText(type="output_text", text=assistant_text) + ] + else: + final_response_contents = [ResponseOutputText(type="output_text", text="")] + yield make_event( "response.output_text.done", { **base_event, "type": "response.output_text.done", "item_id": message_item_id, - "output_index": current_index, + "output_index": message_index, "content_index": 0, }, ) @@ -1692,171 +1947,33 @@ async def _make_async_gen(item: ModelOutput) -> AsyncGenerator[ModelOutput]: **base_event, "type": "response.content_part.done", "item_id": message_item_id, - "output_index": current_index, + "output_index": message_index, "content_index": 0, "part": ResponseOutputText(type="output_text", text=assistant_text).model_dump( mode="json" ), }, ) + yield make_event( "response.output_item.done", { **base_event, "type": "response.output_item.done", - "output_index": current_index, + "output_index": message_index, "item": ResponseOutputMessage( id=message_item_id, type="message", status="completed", role="assistant", - content=[ResponseOutputText(type="output_text", text=assistant_text)], + content=final_response_contents, ).model_dump(mode="json"), }, ) - current_index += 1 - - image_items = [] - final_response_contents = [] - seen_hashes = {} - - image_map = {} - media_items = [] - media_url_to_idx = {} - - for out in all_outputs: - for img in out.images or []: - if img.url: - image_map[img.url] = img - - m_list = (out.videos or []) + (out.media or []) - for m in m_list: - v_url = getattr(m, "url", None) - a_url = getattr(m, "mp3_url", None) - if not v_url and not a_url: - continue - - idx = (media_url_to_idx.get(v_url) if v_url else None) or ( - media_url_to_idx.get(a_url) if a_url else None - ) - - if idx is not None: - media_items[idx] = m - else: - idx = len(media_items) - media_items.append(m) - - if v_url: - media_url_to_idx[v_url] = idx - if a_url: - media_url_to_idx[a_url] = idx - - images = list(image_map.values()) - for image in images: - try: - b64, w, h, fname, fhash = await _image_to_base64(image, media_store) - if fhash in seen_hashes: - (media_store / fname).unlink(missing_ok=True) - b64, w, h, fname = seen_hashes[fhash] - else: - seen_hashes[fhash] = (b64, w, h, fname) - - parts = fname.rsplit(".", 1) - img_id = parts[0] - fmt = parts[1] if len(parts) > 1 else "png" - - img_item = ImageGenerationCall( - id=img_id, - result=b64, - output_format=fmt, - size=f"{w}x{h}" if w and h else None, - ) - - img_link = f"![{fname}]({base_url}media/{fname}?token={get_media_token(fname)})" - image_url_with_newline = f"\n\n{img_link}" - final_response_contents.append( - ResponseOutputText(type="output_text", text=image_url_with_newline) - ) - - yield make_event( - "response.output_item.added", - { - **base_event, - "type": "response.output_item.added", - "output_index": current_index, - "item": img_item.model_dump(mode="json"), - }, - ) - yield make_event( - "response.output_item.done", - { - **base_event, - "type": "response.output_item.done", - "output_index": current_index, - "item": img_item.model_dump(mode="json"), - }, - ) - current_index += 1 - image_items.append(img_item) - storage_output += image_url_with_newline - except Exception as e: - logger.warning(f"Image processing failed in stream: {e}") - - seen_media_hashes = {} - last_thumb_url = None - for media_item in media_items: - try: - m_dict = await _media_to_local_file(media_item, media_store) - - m_urls = {} - for mtype, (random_name, fhash) in m_dict.items(): - if fhash in seen_media_hashes: - existing_name = seen_media_hashes[fhash] - if random_name != existing_name: - (media_store / random_name).unlink(missing_ok=True) - m_urls[mtype] = ( - f"{base_url}media/{existing_name}?token={get_media_token(existing_name)}" - ) - continue - seen_media_hashes[fhash] = random_name - m_urls[mtype] = ( - f"{base_url}media/{random_name}?token={get_media_token(random_name)}" - ) - - title = getattr(media_item, "title", "Media") - video_url = m_urls.get("video") - audio_url = m_urls.get("audio") - item_thumb = m_urls.get("video_thumbnail") or m_urls.get("audio_thumbnail") - if item_thumb: - last_thumb_url = item_thumb - - md_parts = [] - if video_url: - md_parts.append( - f"[![{title}]({last_thumb_url})]({video_url})" - if last_thumb_url - else f"[{title}]({video_url})" - ) - - if audio_url: - md_parts.append( - f"[![{title} - Audio]({last_thumb_url})]({audio_url})" - if last_thumb_url - else f"[{title} - Audio]({audio_url})" - ) - - media_md = "\n\n".join(md_parts) - - if media_md: - media_md_with_newline = f"\n\n{media_md}" - final_response_contents.append( - ResponseOutputText(type="output_text", text=media_md_with_newline) - ) - storage_output += media_md_with_newline - except Exception: - logger.warning("Media processing failed in stream") for call in detected_tool_calls: + tc_index = next_output_index + next_output_index += 1 tc_item = ResponseFunctionToolCall( id=call.id, call_id=call.id, @@ -1869,7 +1986,7 @@ async def _make_async_gen(item: ModelOutput) -> AsyncGenerator[ModelOutput]: { **base_event, "type": "response.output_item.added", - "output_index": current_index, + "output_index": tc_index, "item": tc_item.model_dump(mode="json"), }, ) @@ -1878,19 +1995,13 @@ async def _make_async_gen(item: ModelOutput) -> AsyncGenerator[ModelOutput]: { **base_event, "type": "response.output_item.done", - "output_index": current_index, + "output_index": tc_index, "item": tc_item.model_dump(mode="json"), }, ) - current_index += 1 - - if assistant_text: - final_response_contents.insert( - 0, ResponseOutputText(type="output_text", text=assistant_text) - ) p_tok, c_tok, t_tok, r_tok = _calculate_usage( - messages, assistant_text, detected_tool_calls, full_thoughts + messages, storage_output, detected_tool_calls, full_thoughts ) usage = ResponseUsage( input_tokens=p_tok, @@ -1908,6 +2019,8 @@ async def _make_async_gen(item: ModelOutput) -> AsyncGenerator[ModelOutput]: usage, request, full_thoughts, + message_item_id, + thought_item_id, ) _persist_conversation( db, @@ -1949,7 +2062,6 @@ async def create_chat_completion( raw_request: Request, api_key: str = Depends(verify_api_key), tmp_dir: Path = Depends(get_temp_dir), - media_store: Path = Depends(get_media_store_dir), ): base_url = str(raw_request.base_url) pool, db = GeminiClientPool(), LMDBConversationStore() @@ -1996,7 +2108,7 @@ async def create_chat_completion( session = client.start_chat(model=model) m_input, files = await GeminiClientWrapper.process_conversation(msgs, tmp_dir) except Exception as e: - logger.exception("Error in preparing conversation") + logger.error(f"Error in preparing conversation: {e}") raise HTTPException( status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail=str(e) ) from e @@ -2013,7 +2125,7 @@ async def create_chat_completion( session, m_input, files=files, stream=bool(request.stream) ) except Exception as e: - logger.exception("Gemini API error") + logger.error(f"Gemini API error: {e}") raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail=str(e)) from e if request.stream: @@ -2038,7 +2150,7 @@ async def create_chat_completion( thoughts = resp_or_stream.thoughts raw_clean = GeminiClientWrapper.extract_output(resp_or_stream, include_thoughts=False) except Exception as exc: - logger.exception("Gemini output parsing failed.") + logger.error(f"Gemini output parsing failed: {exc}") raise HTTPException( status_code=status.HTTP_502_BAD_GATEWAY, detail="Malformed response." ) from exc @@ -2048,35 +2160,51 @@ async def create_chat_completion( ) images = resp_or_stream.images or [] + media_items: list[GeneratedVideo | GeneratedMedia] = (resp_or_stream.videos or []) + ( + resp_or_stream.media or [] + ) + unique_media = [] + seen_urls = set() + for m in media_items: + v_url = getattr(m, "url", None) + a_url = getattr(m, "mp3_url", None) + primary_url = v_url or a_url + if primary_url and primary_url not in seen_urls: + unique_media.append(m) + seen_urls.add(primary_url) + + tasks = [_process_image_item(img) for img in images] + [ + _process_media_item(m) for m in unique_media + ] + results = await asyncio.gather(*tasks) + image_markdown = "" + media_markdown = "" seen_hashes = {} - for image in images: - try: - b64, w, h, fname, fhash = await _image_to_base64(image, media_store) + seen_media_hashes = {} + media_store = get_media_store_dir() + + for res in results: + if not res: + continue + rtype, original_item, media_data = res + + if rtype == "image": + _, _, _, fname, fhash = media_data if fhash in seen_hashes: (media_store / fname).unlink(missing_ok=True) - b64, w, h, fname = seen_hashes[fhash] + fname = seen_hashes[fhash] else: - seen_hashes[fhash] = (b64, w, h, fname) + seen_hashes[fhash] = fname - img_url = f"![{fname}]({base_url}media/{fname}?token={get_media_token(fname)})" - image_markdown += f"\n\n{img_url}" - except Exception as exc: - logger.warning(f"Failed to process image in OpenAI response: {exc}") - - if image_markdown: - visible_output += image_markdown - storage_output += image_markdown + img_url = f"{base_url}media/{fname}?token={get_media_token(fname)}" + title = getattr(original_item, "title", "Image") + image_markdown += f"\n\n![{title}]({img_url})" - media_items: list[GeneratedVideo | GeneratedMedia] = (resp_or_stream.videos or []) + ( - resp_or_stream.media or [] - ) - media_markdown = "" - seen_media_hashes = {} - last_thumb_url = None - for m_item in media_items: - try: - m_dict = await _media_to_local_file(m_item, media_store) + elif rtype == "media": + m_dict = media_data + if not m_dict: + continue m_urls = {} for mtype, (random_name, fhash) in m_dict.items(): @@ -2087,39 +2215,37 @@ async def create_chat_completion( m_urls[mtype] = ( f"{base_url}media/{existing_name}?token={get_media_token(existing_name)}" ) - continue - seen_media_hashes[fhash] = random_name - m_urls[mtype] = ( - f"{base_url}media/{random_name}?token={get_media_token(random_name)}" - ) + else: + seen_media_hashes[fhash] = random_name + m_urls[mtype] = ( + f"{base_url}media/{random_name}?token={get_media_token(random_name)}" + ) - title = getattr(m_item, "title", "Media") + title = getattr(original_item, "title", "Media") video_url = m_urls.get("video") audio_url = m_urls.get("audio") - item_thumb = m_urls.get("video_thumbnail") or m_urls.get("audio_thumbnail") - if item_thumb: - last_thumb_url = item_thumb + current_thumb = m_urls.get("video_thumbnail") or m_urls.get("audio_thumbnail") md_parts = [] if video_url: md_parts.append( - f"[![{title}]({last_thumb_url})]({video_url})" - if last_thumb_url + f"[![{title}]({current_thumb})]({video_url})" + if current_thumb else f"[{title}]({video_url})" ) - if audio_url: md_parts.append( - f"[![{title} - Audio]({last_thumb_url})]({audio_url})" - if last_thumb_url + f"[![{title} - Audio]({current_thumb})]({audio_url})" + if current_thumb else f"[{title} - Audio]({audio_url})" ) if md_parts: - m_md = "\n\n".join(md_parts) - media_markdown += f"\n\n{m_md}" - except Exception as exc: - logger.warning(f"Failed to process media in OpenAI response: {exc}") + media_markdown += f"\n\n{'\n\n'.join(md_parts)}" + + if image_markdown: + visible_output += image_markdown + storage_output += image_markdown if media_markdown: visible_output += media_markdown @@ -2131,7 +2257,7 @@ async def create_chat_completion( ) p_tok, c_tok, t_tok, r_tok = _calculate_usage( - app_messages, visible_output, tool_calls, thoughts + app_messages, storage_output, tool_calls, thoughts ) usage = { "prompt_tokens": p_tok, @@ -2167,7 +2293,6 @@ async def create_response( raw_request: Request, api_key: str = Depends(verify_api_key), tmp_dir: Path = Depends(get_temp_dir), - media_store: Path = Depends(get_media_store_dir), ): base_url = str(raw_request.base_url) base_messages = _convert_responses_to_app_messages(request.input) @@ -2234,7 +2359,7 @@ async def create_response( session = client.start_chat(model=model) m_input, files = await GeminiClientWrapper.process_conversation(messages, tmp_dir) except Exception as e: - logger.exception("Error in preparing conversation") + logger.error(f"Error in preparing conversation: {e}") raise HTTPException( status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail=str(e) ) from e @@ -2251,7 +2376,7 @@ async def create_response( session, m_input, files=files, stream=bool(request.stream) ) except Exception as e: - logger.exception("Gemini API error") + logger.error(f"Gemini API error: {e}") raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail=str(e)) from e if request.stream: @@ -2267,7 +2392,6 @@ async def create_response( client, session, request, - media_store, base_url, struct_req, ) @@ -2278,7 +2402,7 @@ async def create_response( thoughts = resp_or_stream.thoughts raw_clean = GeminiClientWrapper.extract_output(resp_or_stream, include_thoughts=False) except Exception as exc: - logger.exception("Gemini parsing failed") + logger.error(f"Gemini parsing failed: {exc}") raise HTTPException( status_code=status.HTTP_502_BAD_GATEWAY, detail="Malformed response." ) from exc @@ -2294,11 +2418,32 @@ async def create_response( ) and not images: raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail="No images returned.") + unique_media = [] + seen_urls = set() + for m in (resp_or_stream.videos or []) + (resp_or_stream.media or []): + p_url = getattr(m, "url", None) or getattr(m, "mp3_url", None) + if p_url and p_url not in seen_urls: + unique_media.append(m) + seen_urls.add(p_url) + + tasks = [_process_image_item(img) for img in images] + [ + _process_media_item(m) for m in unique_media + ] + results = await asyncio.gather(*tasks) + contents, img_calls = [], [] seen_hashes = {} - for img in images: - try: - b64, w, h, fname, fhash = await _image_to_base64(img, media_store) + seen_media_hashes = {} + media_markdown = "" + media_store = get_media_store_dir() + + for res in results: + if not res: + continue + rtype, original_item, media_data = res + + if rtype == "image": + b64, w, h, fname, fhash = media_data if fhash in seen_hashes: (media_store / fname).unlink(missing_ok=True) b64, w, h, fname = seen_hashes[fhash] @@ -2307,47 +2452,17 @@ async def create_response( parts = fname.rsplit(".", 1) img_id = parts[0] - img_format = ( - parts[1] - if len(parts) > 1 - else ("png" if isinstance(img, GeneratedImage) else "jpeg") - ) - + fmt = parts[1] if len(parts) > 1 else "png" img_calls.append( ImageGenerationCall( - id=img_id, - result=b64, - output_format=img_format, - size=f"{w}x{h}" if w and h else None, + id=img_id, result=b64, output_format=fmt, size=f"{w}x{h}" if w and h else None ) ) - except Exception as e: - logger.warning(f"Image error: {e}") - - if assistant_text: - contents.append(ResponseOutputText(type="output_text", text=assistant_text)) - if not contents: - contents.append(ResponseOutputText(type="output_text", text="")) - - image_markdown = "" - for img_call in img_calls: - fname = f"{img_call.id}.{img_call.output_format}" - img_url = f"![{fname}]({base_url}media/{fname}?token={get_media_token(fname)})" - image_markdown += f"\n\n{img_url}" - - if image_markdown: - storage_output += image_markdown - contents.append(ResponseOutputText(type="output_text", text=image_markdown)) - media_items: list[GeneratedVideo | GeneratedMedia] = (resp_or_stream.videos or []) + ( - resp_or_stream.media or [] - ) - seen_media_hashes = {} - media_markdown = "" - last_thumb_url = None - for m_item in media_items: - try: - m_dict = await _media_to_local_file(m_item, media_store) + elif rtype == "media": + m_dict = media_data + if not m_dict: + continue m_urls = {} for mtype, (random_name, fhash) in m_dict.items(): @@ -2358,45 +2473,54 @@ async def create_response( m_urls[mtype] = ( f"{base_url}media/{existing_name}?token={get_media_token(existing_name)}" ) - continue - seen_media_hashes[fhash] = random_name - m_urls[mtype] = ( - f"{base_url}media/{random_name}?token={get_media_token(random_name)}" - ) + else: + seen_media_hashes[fhash] = random_name + m_urls[mtype] = ( + f"{base_url}media/{random_name}?token={get_media_token(random_name)}" + ) - title = getattr(m_item, "title", "Media") + title = getattr(original_item, "title", "Media") video_url = m_urls.get("video") audio_url = m_urls.get("audio") - item_thumb = m_urls.get("video_thumbnail") or m_urls.get("audio_thumbnail") - if item_thumb: - last_thumb_url = item_thumb + current_thumb = m_urls.get("video_thumbnail") or m_urls.get("audio_thumbnail") md_parts = [] if video_url: md_parts.append( - f"[![{title}]({last_thumb_url})]({video_url})" - if last_thumb_url + f"[![{title}]({current_thumb})]({video_url})" + if current_thumb else f"[{title}]({video_url})" ) - if audio_url: md_parts.append( - f"[![{title} - Audio]({last_thumb_url})]({audio_url})" - if last_thumb_url + f"[![{title} - Audio]({current_thumb})]({audio_url})" + if current_thumb else f"[{title} - Audio]({audio_url})" ) if md_parts: - m_md = "\n\n".join(md_parts) - media_markdown += f"\n\n{m_md}" - except Exception as exc: - logger.warning(f"Failed to process media in OpenAI response: {exc}") + media_markdown += f"\n\n{'\n\n'.join(md_parts)}" + + if assistant_text: + contents.append(ResponseOutputText(type="output_text", text=assistant_text)) + + image_markdown = "" + for ic in img_calls: + img_url = f"{base_url}media/{ic.id}.{ic.output_format}?token={get_media_token(f'{ic.id}.{ic.output_format}')}" + image_markdown += f"\n\n![{ic.id}]({img_url})" + + if image_markdown: + storage_output += image_markdown + contents.append(ResponseOutputText(type="output_text", text=image_markdown)) if media_markdown: storage_output += media_markdown contents.append(ResponseOutputText(type="output_text", text=media_markdown)) - p_tok, c_tok, t_tok, r_tok = _calculate_usage(messages, assistant_text, tool_calls, thoughts) + if not contents: + contents.append(ResponseOutputText(type="output_text", text="")) + + p_tok, c_tok, t_tok, r_tok = _calculate_usage(messages, storage_output, tool_calls, thoughts) usage = ResponseUsage( input_tokens=p_tok, output_tokens=c_tok, From baf67b979417deae4edd962b51583aff364ccdd5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C6=B0u=20Quang=20V=C5=A9?= Date: Mon, 9 Mar 2026 21:26:16 +0700 Subject: [PATCH 202/291] Update dependencies to latest versions --- uv.lock | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/uv.lock b/uv.lock index aba96fe..a16988f 100644 --- a/uv.lock +++ b/uv.lock @@ -176,8 +176,8 @@ dev = [{ name = "gemini-fastapi", extras = ["dev"] }] [[package]] name = "gemini-webapi" -version = "0.0.post258" -source = { git = "https://github.com/luuquangvu/Gemini-API.git?rev=move-httpx-to-curl_cffi#4fd2ee6c4e880f693768d32c73220e89661fcc12" } +version = "0.0.post259" +source = { git = "https://github.com/luuquangvu/Gemini-API.git?rev=move-httpx-to-curl_cffi#9d56d7c0aaf62c725734d39acc6113547a9aab6a" } dependencies = [ { name = "curl-cffi" }, { name = "loguru" }, From ddc225706b651f1604773fed57288662068a1984 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C6=B0u=20Quang=20V=C5=A9?= Date: Tue, 10 Mar 2026 08:29:08 +0700 Subject: [PATCH 203/291] Update dependencies to latest versions --- uv.lock | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/uv.lock b/uv.lock index a16988f..e50d714 100644 --- a/uv.lock +++ b/uv.lock @@ -176,8 +176,8 @@ dev = [{ name = "gemini-fastapi", extras = ["dev"] }] [[package]] name = "gemini-webapi" -version = "0.0.post259" -source = { git = "https://github.com/luuquangvu/Gemini-API.git?rev=move-httpx-to-curl_cffi#9d56d7c0aaf62c725734d39acc6113547a9aab6a" } +version = "0.0.post260" +source = { git = "https://github.com/luuquangvu/Gemini-API.git?rev=move-httpx-to-curl_cffi#8309ca686a2d3ffd5de00bd7dba3212a3f87c5c6" } dependencies = [ { name = "curl-cffi" }, { name = "loguru" }, From d05aa9d971561730805d0f3f3bdf51d8aa7ee53c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C6=B0u=20Quang=20V=C5=A9?= Date: Tue, 10 Mar 2026 20:19:30 +0700 Subject: [PATCH 204/291] Introduced cookies parameter and resolved text drift during video generation. --- README.md | 11 +++- README.zh.md | 13 +++- app/server/chat.py | 140 +++++++++++++++++++++++++++++++++++++++++-- app/services/pool.py | 1 + app/utils/config.py | 23 +++++-- config/config.yaml | 5 +- pyproject.toml | 2 +- uv.lock | 4 +- 8 files changed, 178 insertions(+), 21 deletions(-) diff --git a/README.md b/README.md index 6b6f485..5117a9a 100644 --- a/README.md +++ b/README.md @@ -54,8 +54,12 @@ Edit `config/config.yaml` and provide at least one credential pair: gemini: clients: - id: "client-a" - secure_1psid: "YOUR_SECURE_1PSID_HERE" - secure_1psidts: "YOUR_SECURE_1PSIDTS_HERE" + secure_1psid: "YOUR_SECURE_1PSID_HERE" # Optional if 'cookies' is provided + secure_1psidts: "YOUR_SECURE_1PSIDTS_HERE" # Optional if 'cookies' is provided + # OR use a cookies dictionary: + # cookies: + # __Secure-1PSID: "..." + # __Secure-1PSIDTS: "..." proxy: null # Optional proxy URL (null/empty keeps direct connection) ``` @@ -180,6 +184,9 @@ export CONFIG_GEMINI__CLIENTS__0__SECURE_1PSIDTS="your-secure-1psidts" # Override optional proxy settings for client 0 export CONFIG_GEMINI__CLIENTS__0__PROXY="socks5://127.0.0.1:1080" +# Override using a JSON cookies string for client 0 +export CONFIG_GEMINI__CLIENTS__0__COOKIES='{"__Secure-1PSID": "...", "__Secure-1PSIDTS": "..."}' + # Override conversation storage size limit export CONFIG_STORAGE__MAX_SIZE=268435456 # 256 MB ``` diff --git a/README.zh.md b/README.zh.md index d012d32..30678fe 100644 --- a/README.zh.md +++ b/README.zh.md @@ -54,9 +54,13 @@ pip install -e . gemini: clients: - id: "client-a" - secure_1psid: "YOUR_SECURE_1PSID_HERE" - secure_1psidts: "YOUR_SECURE_1PSIDTS_HERE" - proxy: null # Optional proxy URL (null/empty keeps direct connection) + secure_1psid: "YOUR_SECURE_1PSID_HERE" # 若已提供 'cookies',则此项可选 + secure_1psidts: "YOUR_SECURE_1PSIDTS_HERE" # 若已提供 'cookies',则此项可选 + # 或者使用 Cookies 字典: + # cookies: + # __Secure-1PSID: "..." + # __Secure-1PSIDTS: "..." + proxy: null # 可选代理 URL (null/空值则保持直连) ``` > [!NOTE] @@ -180,6 +184,9 @@ export CONFIG_GEMINI__CLIENTS__0__SECURE_1PSIDTS="your-secure-1psidts" # 覆盖 Client 0 的代理设置 export CONFIG_GEMINI__CLIENTS__0__PROXY="socks5://127.0.0.1:1080" +# 通过 JSON 字符串覆盖 Client 0 的 Cookie 配置 +export CONFIG_GEMINI__CLIENTS__0__COOKIES='{"__Secure-1PSID": "...", "__Secure-1PSIDTS": "..."}' + # 覆盖对话存储大小限制 export CONFIG_STORAGE__MAX_SIZE=268435456 # 256 MB ``` diff --git a/app/server/chat.py b/app/server/chat.py index e0df9c6..3e7154b 100644 --- a/app/server/chat.py +++ b/app/server/chat.py @@ -1241,10 +1241,32 @@ def make_chunk(delta_content: dict) -> str: if all_outputs: final_chunk = all_outputs[-1] - if final_chunk.text: - full_text = final_chunk.text if final_chunk.thoughts: - full_thoughts = final_chunk.thoughts + f_thoughts = final_chunk.thoughts + ft_len, ct_len = len(f_thoughts), len(full_thoughts) + if ft_len >= ct_len and f_thoughts.startswith(full_thoughts): + if ft_len > ct_len: + drift_t = f_thoughts[ct_len:] + full_thoughts = f_thoughts + yield make_chunk( + {"delta": {"reasoning_content": drift_t}, "finish_reason": None} + ) + else: + logger.debug("Significant thoughts drift detected, preferring accumulated.") + + if final_chunk.text: + f_text = final_chunk.text + f_len, c_len = len(f_text), len(full_text) + if f_len >= c_len and f_text.startswith(full_text): + if f_len > c_len: + drift = f_text[c_len:] + full_text = f_text + if visible_drift := suppressor.process(drift): + yield make_chunk( + {"delta": {"content": visible_drift}, "finish_reason": None} + ) + else: + logger.debug("Significant text drift detected, preferring accumulated state.") if remaining_text := suppressor.flush(): yield make_chunk({"delta": {"content": remaining_text}, "finish_reason": None}) @@ -1648,10 +1670,116 @@ async def _make_async_gen(item: ModelOutput) -> AsyncGenerator[ModelOutput]: if all_outputs: last = all_outputs[-1] - if last.text: - full_text = last.text if last.thoughts: - full_thoughts = last.thoughts + l_thoughts = last.thoughts + lt_len, ct_len = len(l_thoughts), len(full_thoughts) + if lt_len >= ct_len and l_thoughts.startswith(full_thoughts): + if lt_len > ct_len: + drift_t = l_thoughts[ct_len:] + full_thoughts = l_thoughts + if not thought_open: + thought_index = next_output_index + next_output_index += 1 + yield make_event( + "response.output_item.added", + { + **base_event, + "type": "response.output_item.added", + "output_index": thought_index, + "item": ResponseReasoningItem( + id=thought_item_id, + type="reasoning", + status="in_progress", + summary=[], + ).model_dump(mode="json"), + }, + ) + yield make_event( + "response.reasoning_summary_part.added", + { + **base_event, + "type": "response.reasoning_summary_part.added", + "item_id": thought_item_id, + "output_index": thought_index, + "summary_index": 0, + "part": SummaryTextContent(text="").model_dump(mode="json"), + }, + ) + thought_open = True + + yield make_event( + "response.reasoning_summary_text.delta", + { + **base_event, + "type": "response.reasoning_summary_text.delta", + "item_id": thought_item_id, + "output_index": thought_index, + "summary_index": 0, + "delta": drift_t, + }, + ) + else: + logger.debug( + "Significant thoughts drift detected in Responses API, preferring accumulated." + ) + + if last.text: + l_text = last.text + l_len, c_len = len(l_text), len(full_text) + if l_len >= c_len and l_text.startswith(full_text): + if l_len > c_len: + drift = l_text[c_len:] + full_text = l_text + if visible := suppressor.process(drift): + if not message_open: + message_index = next_output_index + next_output_index += 1 + yield make_event( + "response.output_item.added", + { + **base_event, + "type": "response.output_item.added", + "output_index": message_index, + "item": ResponseOutputMessage( + id=message_item_id, + type="message", + status="in_progress", + role="assistant", + content=[], + ).model_dump(mode="json"), + }, + ) + yield make_event( + "response.content_part.added", + { + **base_event, + "type": "response.content_part.added", + "item_id": message_item_id, + "output_index": message_index, + "content_index": 0, + "part": ResponseOutputText( + type="output_text", text="" + ).model_dump(mode="json"), + }, + ) + message_open = True + + yield make_event( + "response.output_text.delta", + { + **base_event, + "type": "response.output_text.delta", + "item_id": message_item_id, + "output_index": message_index, + "content_index": 0, + "delta": visible, + "logprobs": [], + }, + ) + else: + logger.debug( + "Significant text drift detected in Responses API, preferring accumulated." + ) remaining = suppressor.flush() if remaining and message_open: diff --git a/app/services/pool.py b/app/services/pool.py index 3b4197c..86f0e15 100644 --- a/app/services/pool.py +++ b/app/services/pool.py @@ -26,6 +26,7 @@ def __init__(self) -> None: client_id=c.id, secure_1psid=c.secure_1psid, secure_1psidts=c.secure_1psidts, + cookies=c.cookies, proxy=c.proxy, ) self._clients.append(client) diff --git a/app/utils/config.py b/app/utils/config.py index 03e6f6d..e96ac7b 100644 --- a/app/utils/config.py +++ b/app/utils/config.py @@ -39,10 +39,23 @@ class GeminiClientSettings(BaseModel): """Credential set for one Gemini client.""" id: str = Field(..., description="Unique identifier for the client") - secure_1psid: str = Field(..., description="Gemini Secure 1PSID") - secure_1psidts: str = Field(..., description="Gemini Secure 1PSIDTS") + secure_1psid: str | None = Field(default=None, description="Gemini Secure 1PSID") + secure_1psidts: str | None = Field(default=None, description="Gemini Secure 1PSIDTS") + cookies: dict[str, str] | None = Field( + default=None, description="Gemini cookies as a dictionary" + ) proxy: str | None = Field(default=None, description="Proxy URL for this Gemini client") + @field_validator("cookies", mode="before") + @classmethod + def _parse_cookies(cls, v: Any) -> Any: + if isinstance(v, str) and v.strip().startswith("{"): + try: + return orjson.loads(v) + except orjson.JSONDecodeError: + return v + return v + @field_validator("proxy", mode="before") @classmethod def _blank_proxy_to_none(cls, value: str | None) -> str | None: @@ -228,10 +241,10 @@ def settings_customise_sources( ) -def extract_gemini_clients_env() -> dict[int, dict[str, str]]: +def extract_gemini_clients_env() -> dict[int, dict[str, Any]]: """Extract and remove all Gemini clients related environment variables, return a mapping from index to field dict.""" prefix = "CONFIG_GEMINI__CLIENTS__" - env_overrides: dict[int, dict[str, str]] = {} + env_overrides: dict[int, dict[str, Any]] = {} to_delete = [] for k, v in os.environ.items(): if k.startswith(prefix): @@ -252,7 +265,7 @@ def extract_gemini_clients_env() -> dict[int, dict[str, str]]: def _merge_clients_with_env( base_clients: list[GeminiClientSettings] | None, - env_overrides: dict[int, dict[str, str]], + env_overrides: dict[int, dict[str, Any]], ): """Override base_clients with env_overrides, return the new clients list.""" if not env_overrides: diff --git a/config/config.yaml b/config/config.yaml index bfc9306..0218f14 100644 --- a/config/config.yaml +++ b/config/config.yaml @@ -19,8 +19,9 @@ cors: gemini: clients: - id: "example-id-1" # Arbitrary client ID - secure_1psid: "YOUR_SECURE_1PSID_HERE" - secure_1psidts: "YOUR_SECURE_1PSIDTS_HERE" + secure_1psid: "YOUR_SECURE_1PSID_HERE" # Optional: Gemini Secure 1PSID + secure_1psidts: "YOUR_SECURE_1PSIDTS_HERE" # Optional: Gemini Secure 1PSIDTS + cookies: null # Optional: Cookies dictionary (e.g. {__Secure-1PSID: "...", __Secure-1PSIDTS: "..."}) proxy: null # Optional proxy URL (null/empty means direct connection) timeout: 450 # Init timeout in seconds (Not less than 30s) watchdog_timeout: 90 # Watchdog timeout in seconds (Not less than 30s) diff --git a/pyproject.toml b/pyproject.toml index 459b6f4..3afe92a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -7,7 +7,7 @@ requires-python = "==3.13.*" dependencies = [ "curl-cffi>=0.14.0", "fastapi>=0.135.1", - "gemini-webapi>=1.20.0", + "gemini-webapi>=1.21.0", "httptools>=0.7.1", "lmdb>=1.7.5", "loguru>=0.7.3", diff --git a/uv.lock b/uv.lock index e50d714..c198b21 100644 --- a/uv.lock +++ b/uv.lock @@ -176,8 +176,8 @@ dev = [{ name = "gemini-fastapi", extras = ["dev"] }] [[package]] name = "gemini-webapi" -version = "0.0.post260" -source = { git = "https://github.com/luuquangvu/Gemini-API.git?rev=move-httpx-to-curl_cffi#8309ca686a2d3ffd5de00bd7dba3212a3f87c5c6" } +version = "0.0.post288" +source = { git = "https://github.com/luuquangvu/Gemini-API.git?rev=move-httpx-to-curl_cffi#e5cfac8ccc0a56c3b4da07a23596033bf88082f0" } dependencies = [ { name = "curl-cffi" }, { name = "loguru" }, From cb987032767c0225cc858c6f7247ee466840f3b5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C6=B0u=20Quang=20V=C5=A9?= Date: Tue, 10 Mar 2026 21:04:35 +0700 Subject: [PATCH 205/291] Fixed configuration validation failed --- app/utils/config.py | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/app/utils/config.py b/app/utils/config.py index e96ac7b..1f1cdde 100644 --- a/app/utils/config.py +++ b/app/utils/config.py @@ -53,7 +53,10 @@ def _parse_cookies(cls, v: Any) -> Any: try: return orjson.loads(v) except orjson.JSONDecodeError: - return v + try: + return ast.literal_eval(v) + except (ValueError, SyntaxError): + return v return v @field_validator("proxy", mode="before") @@ -80,7 +83,10 @@ def _parse_json_string(cls, v: Any) -> Any: try: return orjson.loads(v) except orjson.JSONDecodeError: - return v + try: + return ast.literal_eval(v) + except (ValueError, SyntaxError): + return v return v @@ -116,9 +122,12 @@ def _parse_models_json(cls, v: Any) -> Any: if isinstance(v, str) and v.strip().startswith("["): try: return orjson.loads(v) - except orjson.JSONDecodeError as e: - logger.warning(f"Failed to parse models JSON string: {e}") - return v + except orjson.JSONDecodeError: + try: + return ast.literal_eval(v) + except (ValueError, SyntaxError) as e: + logger.warning(f"Failed to parse models JSON or Python literal: {e}") + return v return v @field_validator("models") From 219fc61ff3cca6bafe341cbb41accb40949a32a5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C6=B0u=20Quang=20V=C5=A9?= Date: Tue, 10 Mar 2026 22:10:30 +0700 Subject: [PATCH 206/291] Using default configuration values from global settings --- app/services/client.py | 35 ++++++++++------------------------- app/services/pool.py | 21 +++------------------ 2 files changed, 13 insertions(+), 43 deletions(-) diff --git a/app/services/client.py b/app/services/client.py index d2e5270..92f3737 100644 --- a/app/services/client.py +++ b/app/services/client.py @@ -1,6 +1,6 @@ import io from pathlib import Path -from typing import Any, cast +from typing import Any import orjson from gemini_webapi import GeminiClient, ModelOutput @@ -29,36 +29,21 @@ def __init__(self, client_id: str, **kwargs): super().__init__(**kwargs) self.id = client_id - async def init( # type: ignore - self, - timeout: float = cast(float, _UNSET), - watchdog_timeout: float = cast(float, _UNSET), - auto_close: bool = False, - close_delay: float = cast(float, _UNSET), - auto_refresh: bool = cast(bool, _UNSET), - refresh_interval: float = cast(float, _UNSET), - verbose: bool = cast(bool, _UNSET), - ) -> None: + async def init(self, *args: Any, **kwargs: Any) -> None: """ - Inject default configuration values. + Inject default configuration values from global settings. """ config = g_config.gemini - timeout = cast(float, _resolve(timeout, config.timeout)) - watchdog_timeout = cast(float, _resolve(watchdog_timeout, config.watchdog_timeout)) - close_delay = timeout - auto_refresh = cast(bool, _resolve(auto_refresh, config.auto_refresh)) - refresh_interval = cast(float, _resolve(refresh_interval, config.refresh_interval)) - verbose = cast(bool, _resolve(verbose, config.verbose)) - + auto_close = kwargs.get("auto_close", False) try: await super().init( - timeout=timeout, - watchdog_timeout=watchdog_timeout, + timeout=config.timeout, + watchdog_timeout=config.watchdog_timeout, auto_close=auto_close, - close_delay=close_delay, - auto_refresh=auto_refresh, - refresh_interval=refresh_interval, - verbose=verbose, + close_delay=config.timeout, + auto_refresh=config.auto_refresh, + refresh_interval=config.refresh_interval, + verbose=config.verbose, ) except Exception: logger.exception(f"Failed to initialize GeminiClient {self.id}") diff --git a/app/services/pool.py b/app/services/pool.py index 86f0e15..febe91d 100644 --- a/app/services/pool.py +++ b/app/services/pool.py @@ -24,10 +24,7 @@ def __init__(self) -> None: for c in g_config.gemini.clients: client = GeminiClientWrapper( client_id=c.id, - secure_1psid=c.secure_1psid, - secure_1psidts=c.secure_1psidts, - cookies=c.cookies, - proxy=c.proxy, + **c.model_dump(exclude={"id"}), ) self._clients.append(client) self._id_map[c.id] = client @@ -40,13 +37,7 @@ async def init(self) -> None: for client in self._clients: if not client.running(): try: - await client.init( - timeout=g_config.gemini.timeout, - watchdog_timeout=g_config.gemini.watchdog_timeout, - auto_refresh=g_config.gemini.auto_refresh, - verbose=g_config.gemini.verbose, - refresh_interval=g_config.gemini.refresh_interval, - ) + await client.init() except Exception: logger.exception(f"Failed to initialize client {client.id}") @@ -93,13 +84,7 @@ async def _ensure_client_ready(self, client: GeminiClientWrapper) -> bool: return True try: - await client.init( - timeout=g_config.gemini.timeout, - watchdog_timeout=g_config.gemini.watchdog_timeout, - auto_refresh=g_config.gemini.auto_refresh, - verbose=g_config.gemini.verbose, - refresh_interval=g_config.gemini.refresh_interval, - ) + await client.init() logger.info(f"Restarted Gemini client {client.id} after it stopped.") return True except Exception: From 090bd0017462c9202ac81fcbb12d38f6fcc6549e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C6=B0u=20Quang=20V=C5=A9?= Date: Tue, 10 Mar 2026 22:34:08 +0700 Subject: [PATCH 207/291] Using default configuration values from global settings Update dependencies to latest versions --- app/services/client.py | 3 --- uv.lock | 4 ++-- 2 files changed, 2 insertions(+), 5 deletions(-) diff --git a/app/services/client.py b/app/services/client.py index 92f3737..e6638ab 100644 --- a/app/services/client.py +++ b/app/services/client.py @@ -34,13 +34,10 @@ async def init(self, *args: Any, **kwargs: Any) -> None: Inject default configuration values from global settings. """ config = g_config.gemini - auto_close = kwargs.get("auto_close", False) try: await super().init( timeout=config.timeout, watchdog_timeout=config.watchdog_timeout, - auto_close=auto_close, - close_delay=config.timeout, auto_refresh=config.auto_refresh, refresh_interval=config.refresh_interval, verbose=config.verbose, diff --git a/uv.lock b/uv.lock index c198b21..250849f 100644 --- a/uv.lock +++ b/uv.lock @@ -176,8 +176,8 @@ dev = [{ name = "gemini-fastapi", extras = ["dev"] }] [[package]] name = "gemini-webapi" -version = "0.0.post288" -source = { git = "https://github.com/luuquangvu/Gemini-API.git?rev=move-httpx-to-curl_cffi#e5cfac8ccc0a56c3b4da07a23596033bf88082f0" } +version = "0.0.post289" +source = { git = "https://github.com/luuquangvu/Gemini-API.git?rev=move-httpx-to-curl_cffi#d7d034db881abde77b475a869aea7911423b6d21" } dependencies = [ { name = "curl-cffi" }, { name = "loguru" }, From 7828a4adcfa378f55191d6bc22e6fa0d685f2785 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C6=B0u=20Quang=20V=C5=A9?= Date: Wed, 11 Mar 2026 09:13:53 +0700 Subject: [PATCH 208/291] Remove cookies parameter to avoid HTTP 401 error --- README.md | 10 ++-------- README.zh.md | 10 ++-------- app/utils/config.py | 20 ++------------------ config/config.yaml | 5 ++--- uv.lock | 4 ++-- 5 files changed, 10 insertions(+), 39 deletions(-) diff --git a/README.md b/README.md index 5117a9a..7d96646 100644 --- a/README.md +++ b/README.md @@ -54,12 +54,8 @@ Edit `config/config.yaml` and provide at least one credential pair: gemini: clients: - id: "client-a" - secure_1psid: "YOUR_SECURE_1PSID_HERE" # Optional if 'cookies' is provided - secure_1psidts: "YOUR_SECURE_1PSIDTS_HERE" # Optional if 'cookies' is provided - # OR use a cookies dictionary: - # cookies: - # __Secure-1PSID: "..." - # __Secure-1PSIDTS: "..." + secure_1psid: "YOUR_SECURE_1PSID_HERE" + secure_1psidts: "YOUR_SECURE_1PSIDTS_HERE" proxy: null # Optional proxy URL (null/empty keeps direct connection) ``` @@ -184,8 +180,6 @@ export CONFIG_GEMINI__CLIENTS__0__SECURE_1PSIDTS="your-secure-1psidts" # Override optional proxy settings for client 0 export CONFIG_GEMINI__CLIENTS__0__PROXY="socks5://127.0.0.1:1080" -# Override using a JSON cookies string for client 0 -export CONFIG_GEMINI__CLIENTS__0__COOKIES='{"__Secure-1PSID": "...", "__Secure-1PSIDTS": "..."}' # Override conversation storage size limit export CONFIG_STORAGE__MAX_SIZE=268435456 # 256 MB diff --git a/README.zh.md b/README.zh.md index 30678fe..e446667 100644 --- a/README.zh.md +++ b/README.zh.md @@ -54,12 +54,8 @@ pip install -e . gemini: clients: - id: "client-a" - secure_1psid: "YOUR_SECURE_1PSID_HERE" # 若已提供 'cookies',则此项可选 - secure_1psidts: "YOUR_SECURE_1PSIDTS_HERE" # 若已提供 'cookies',则此项可选 - # 或者使用 Cookies 字典: - # cookies: - # __Secure-1PSID: "..." - # __Secure-1PSIDTS: "..." + secure_1psid: "YOUR_SECURE_1PSID_HERE" + secure_1psidts: "YOUR_SECURE_1PSIDTS_HERE" proxy: null # 可选代理 URL (null/空值则保持直连) ``` @@ -184,8 +180,6 @@ export CONFIG_GEMINI__CLIENTS__0__SECURE_1PSIDTS="your-secure-1psidts" # 覆盖 Client 0 的代理设置 export CONFIG_GEMINI__CLIENTS__0__PROXY="socks5://127.0.0.1:1080" -# 通过 JSON 字符串覆盖 Client 0 的 Cookie 配置 -export CONFIG_GEMINI__CLIENTS__0__COOKIES='{"__Secure-1PSID": "...", "__Secure-1PSIDTS": "..."}' # 覆盖对话存储大小限制 export CONFIG_STORAGE__MAX_SIZE=268435456 # 256 MB diff --git a/app/utils/config.py b/app/utils/config.py index 1f1cdde..3a4ade9 100644 --- a/app/utils/config.py +++ b/app/utils/config.py @@ -41,24 +41,8 @@ class GeminiClientSettings(BaseModel): id: str = Field(..., description="Unique identifier for the client") secure_1psid: str | None = Field(default=None, description="Gemini Secure 1PSID") secure_1psidts: str | None = Field(default=None, description="Gemini Secure 1PSIDTS") - cookies: dict[str, str] | None = Field( - default=None, description="Gemini cookies as a dictionary" - ) proxy: str | None = Field(default=None, description="Proxy URL for this Gemini client") - @field_validator("cookies", mode="before") - @classmethod - def _parse_cookies(cls, v: Any) -> Any: - if isinstance(v, str) and v.strip().startswith("{"): - try: - return orjson.loads(v) - except orjson.JSONDecodeError: - try: - return ast.literal_eval(v) - except (ValueError, SyntaxError): - return v - return v - @field_validator("proxy", mode="before") @classmethod def _blank_proxy_to_none(cls, value: str | None) -> str | None: @@ -103,11 +87,11 @@ class GeminiConfig(BaseModel): ) timeout: int = Field(default=450, ge=30, description="Init timeout in seconds") watchdog_timeout: int = Field(default=90, ge=30, description="Watchdog timeout in seconds") - auto_refresh: bool = Field(True, description="Enable auto-refresh for Gemini cookies") + auto_refresh: bool = Field(True, description="Enable auto-refresh for Gemini sessions") refresh_interval: int = Field( default=600, ge=60, - description="Interval in seconds to refresh Gemini cookies (Not less than 60s)", + description="Interval in seconds to refresh Gemini sessions (Not less than 60s)", ) verbose: bool = Field(False, description="Enable verbose logging for Gemini API requests") max_chars_per_request: int = Field( diff --git a/config/config.yaml b/config/config.yaml index 0218f14..9321c61 100644 --- a/config/config.yaml +++ b/config/config.yaml @@ -19,9 +19,8 @@ cors: gemini: clients: - id: "example-id-1" # Arbitrary client ID - secure_1psid: "YOUR_SECURE_1PSID_HERE" # Optional: Gemini Secure 1PSID - secure_1psidts: "YOUR_SECURE_1PSIDTS_HERE" # Optional: Gemini Secure 1PSIDTS - cookies: null # Optional: Cookies dictionary (e.g. {__Secure-1PSID: "...", __Secure-1PSIDTS: "..."}) + secure_1psid: "YOUR_SECURE_1PSID_HERE" # Gemini Secure 1PSID + secure_1psidts: "YOUR_SECURE_1PSIDTS_HERE" # Gemini Secure 1PSIDTS proxy: null # Optional proxy URL (null/empty means direct connection) timeout: 450 # Init timeout in seconds (Not less than 30s) watchdog_timeout: 90 # Watchdog timeout in seconds (Not less than 30s) diff --git a/uv.lock b/uv.lock index 250849f..b987a84 100644 --- a/uv.lock +++ b/uv.lock @@ -176,8 +176,8 @@ dev = [{ name = "gemini-fastapi", extras = ["dev"] }] [[package]] name = "gemini-webapi" -version = "0.0.post289" -source = { git = "https://github.com/luuquangvu/Gemini-API.git?rev=move-httpx-to-curl_cffi#d7d034db881abde77b475a869aea7911423b6d21" } +version = "0.0.post290" +source = { git = "https://github.com/luuquangvu/Gemini-API.git?rev=move-httpx-to-curl_cffi#ee035ca48c998997a605d028a16f9eb595e9254d" } dependencies = [ { name = "curl-cffi" }, { name = "loguru" }, From f4b319c4818c2fc0c81905657c76fb0bae920813 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C6=B0u=20Quang=20V=C5=A9?= Date: Wed, 11 Mar 2026 10:07:29 +0700 Subject: [PATCH 209/291] Resolved content drift during streaming. --- app/server/chat.py | 194 +++++++++++++++++++++------------------------ 1 file changed, 89 insertions(+), 105 deletions(-) diff --git a/app/server/chat.py b/app/server/chat.py index 3e7154b..ee5d9cf 100644 --- a/app/server/chat.py +++ b/app/server/chat.py @@ -1244,29 +1244,23 @@ def make_chunk(delta_content: dict) -> str: if final_chunk.thoughts: f_thoughts = final_chunk.thoughts ft_len, ct_len = len(f_thoughts), len(full_thoughts) - if ft_len >= ct_len and f_thoughts.startswith(full_thoughts): - if ft_len > ct_len: - drift_t = f_thoughts[ct_len:] - full_thoughts = f_thoughts - yield make_chunk( - {"delta": {"reasoning_content": drift_t}, "finish_reason": None} - ) - else: - logger.debug("Significant thoughts drift detected, preferring accumulated.") + if ft_len > ct_len and f_thoughts.startswith(full_thoughts): + drift_t = f_thoughts[ct_len:] + full_thoughts = f_thoughts + yield make_chunk( + {"delta": {"reasoning_content": drift_t}, "finish_reason": None} + ) if final_chunk.text: f_text = final_chunk.text f_len, c_len = len(f_text), len(full_text) - if f_len >= c_len and f_text.startswith(full_text): - if f_len > c_len: - drift = f_text[c_len:] - full_text = f_text - if visible_drift := suppressor.process(drift): - yield make_chunk( - {"delta": {"content": visible_drift}, "finish_reason": None} - ) - else: - logger.debug("Significant text drift detected, preferring accumulated state.") + if f_len > c_len and f_text.startswith(full_text): + drift = f_text[c_len:] + full_text = f_text + if visible_drift := suppressor.process(drift): + yield make_chunk( + {"delta": {"content": visible_drift}, "finish_reason": None} + ) if remaining_text := suppressor.flush(): yield make_chunk({"delta": {"content": remaining_text}, "finish_reason": None}) @@ -1673,113 +1667,103 @@ async def _make_async_gen(item: ModelOutput) -> AsyncGenerator[ModelOutput]: if last.thoughts: l_thoughts = last.thoughts lt_len, ct_len = len(l_thoughts), len(full_thoughts) - if lt_len >= ct_len and l_thoughts.startswith(full_thoughts): - if lt_len > ct_len: - drift_t = l_thoughts[ct_len:] - full_thoughts = l_thoughts - if not thought_open: - thought_index = next_output_index - next_output_index += 1 - yield make_event( - "response.output_item.added", - { - **base_event, - "type": "response.output_item.added", - "output_index": thought_index, - "item": ResponseReasoningItem( - id=thought_item_id, - type="reasoning", - status="in_progress", - summary=[], - ).model_dump(mode="json"), - }, - ) - yield make_event( - "response.reasoning_summary_part.added", - { - **base_event, - "type": "response.reasoning_summary_part.added", - "item_id": thought_item_id, - "output_index": thought_index, - "summary_index": 0, - "part": SummaryTextContent(text="").model_dump(mode="json"), - }, - ) - thought_open = True - + if lt_len > ct_len and l_thoughts.startswith(full_thoughts): + drift_t = l_thoughts[ct_len:] + full_thoughts = l_thoughts + if not thought_open: + thought_index = next_output_index + next_output_index += 1 yield make_event( - "response.reasoning_summary_text.delta", + "response.output_item.added", { **base_event, - "type": "response.reasoning_summary_text.delta", + "type": "response.output_item.added", + "output_index": thought_index, + "item": ResponseReasoningItem( + id=thought_item_id, + type="reasoning", + status="in_progress", + summary=[], + ).model_dump(mode="json"), + }, + ) + yield make_event( + "response.reasoning_summary_part.added", + { + **base_event, + "type": "response.reasoning_summary_part.added", "item_id": thought_item_id, "output_index": thought_index, "summary_index": 0, - "delta": drift_t, + "part": SummaryTextContent(text="").model_dump(mode="json"), }, ) - else: - logger.debug( - "Significant thoughts drift detected in Responses API, preferring accumulated." + thought_open = True + + yield make_event( + "response.reasoning_summary_text.delta", + { + **base_event, + "type": "response.reasoning_summary_text.delta", + "item_id": thought_item_id, + "output_index": thought_index, + "summary_index": 0, + "delta": drift_t, + }, ) if last.text: l_text = last.text l_len, c_len = len(l_text), len(full_text) - if l_len >= c_len and l_text.startswith(full_text): - if l_len > c_len: - drift = l_text[c_len:] - full_text = l_text - if visible := suppressor.process(drift): - if not message_open: - message_index = next_output_index - next_output_index += 1 - yield make_event( - "response.output_item.added", - { - **base_event, - "type": "response.output_item.added", - "output_index": message_index, - "item": ResponseOutputMessage( - id=message_item_id, - type="message", - status="in_progress", - role="assistant", - content=[], - ).model_dump(mode="json"), - }, - ) - yield make_event( - "response.content_part.added", - { - **base_event, - "type": "response.content_part.added", - "item_id": message_item_id, - "output_index": message_index, - "content_index": 0, - "part": ResponseOutputText( - type="output_text", text="" - ).model_dump(mode="json"), - }, - ) - message_open = True - + if l_len > c_len and l_text.startswith(full_text): + drift = l_text[c_len:] + full_text = l_text + if visible := suppressor.process(drift): + if not message_open: + message_index = next_output_index + next_output_index += 1 yield make_event( - "response.output_text.delta", + "response.output_item.added", { **base_event, - "type": "response.output_text.delta", + "type": "response.output_item.added", + "output_index": message_index, + "item": ResponseOutputMessage( + id=message_item_id, + type="message", + status="in_progress", + role="assistant", + content=[], + ).model_dump(mode="json"), + }, + ) + yield make_event( + "response.content_part.added", + { + **base_event, + "type": "response.content_part.added", "item_id": message_item_id, "output_index": message_index, "content_index": 0, - "delta": visible, - "logprobs": [], + "part": ResponseOutputText( + type="output_text", text="" + ).model_dump(mode="json"), }, ) - else: - logger.debug( - "Significant text drift detected in Responses API, preferring accumulated." - ) + message_open = True + + yield make_event( + "response.output_text.delta", + { + **base_event, + "type": "response.output_text.delta", + "item_id": message_item_id, + "output_index": message_index, + "content_index": 0, + "delta": visible, + "logprobs": [], + }, + ) remaining = suppressor.flush() if remaining and message_open: From 2d3cf205ff780fc66457afc58cf448542430faa2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C6=B0u=20Quang=20V=C5=A9?= Date: Wed, 11 Mar 2026 12:09:19 +0700 Subject: [PATCH 210/291] Update dependencies to latest versions --- uv.lock | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/uv.lock b/uv.lock index b987a84..223d9b6 100644 --- a/uv.lock +++ b/uv.lock @@ -176,8 +176,8 @@ dev = [{ name = "gemini-fastapi", extras = ["dev"] }] [[package]] name = "gemini-webapi" -version = "0.0.post290" -source = { git = "https://github.com/luuquangvu/Gemini-API.git?rev=move-httpx-to-curl_cffi#ee035ca48c998997a605d028a16f9eb595e9254d" } +version = "0.0.post291" +source = { git = "https://github.com/luuquangvu/Gemini-API.git?rev=move-httpx-to-curl_cffi#00765f99cb95b0fbc8aaa668f84ebdf829fafe5a" } dependencies = [ { name = "curl-cffi" }, { name = "loguru" }, From 00895cbe5f689668410f00b1437a5427212cda2a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C6=B0u=20Quang=20V=C5=A9?= Date: Thu, 12 Mar 2026 14:35:14 +0700 Subject: [PATCH 211/291] Update regex patterns to prevent exposing system tags --- app/server/chat.py | 14 +++++++++----- app/utils/helper.py | 44 +++++++++++++++++++------------------------- 2 files changed, 28 insertions(+), 30 deletions(-) diff --git a/app/server/chat.py b/app/server/chat.py index ee5d9cf..a07a931 100644 --- a/app/server/chat.py +++ b/app/server/chat.py @@ -1079,12 +1079,16 @@ def process(self, chunk: str) -> str: match = STREAM_MASTER_RE.search(self.buffer) if not match: tail_match = STREAM_TAIL_RE.search(self.buffer) - keep_len = len(tail_match.group(0)) if tail_match else 0 - yield_len = len(self.buffer) - keep_len - if yield_len > 0: + if tail_match: + yield_len = len(self.buffer) - len(tail_match.group(0)) + if yield_len > 0: + if self._is_outputting(): + output.append(self.buffer[:yield_len]) + self.buffer = self.buffer[yield_len:] + else: if self._is_outputting(): - output.append(self.buffer[:yield_len]) - self.buffer = self.buffer[yield_len:] + output.append(self.buffer) + self.buffer = "" break start, end = match.span() diff --git a/app/utils/helper.py b/app/utils/helper.py index 3b1d90f..e7c0816 100644 --- a/app/utils/helper.py +++ b/app/utils/helper.py @@ -36,36 +36,32 @@ "CRITICAL: Do NOT mix natural language with protocol tags. Either respond naturally OR provide the protocol block alone. There is no middle ground.\n" ) TOOL_BLOCK_RE = re.compile( - r"\\?\[\s*ToolCalls\s*\\?]\s*(.*?)\s*\\?\[\s*\\?/\s*ToolCalls\s*\\?]", + r"\\?\[ToolCalls\\?](.*?)\\?\[\\?/ToolCalls\\?]", re.DOTALL | re.IGNORECASE, ) TOOL_CALL_RE = re.compile( - r"\\?\[\s*Call\s*\\?:\s*(?P(?:[^]\\]|\\.)+)\s*\\?]\s*(?P.*?)\s*\\?\[\s*\\?/\s*Call\s*\\?]", + r"\\?\[Call\\?:(?P[^]]+)\\?](?P.*?)\\?\[\\?/Call\\?]", re.DOTALL | re.IGNORECASE, ) RESPONSE_BLOCK_RE = re.compile( - r"\\?\[\s*ToolResults\s*\\?]\s*(.*?)\s*\\?\[\s*\\?/\s*ToolResults\s*\\?]", + r"\\?\[ToolResults\\?](.*?)\\?\[\\?/ToolResults\\?]", re.DOTALL | re.IGNORECASE, ) RESPONSE_ITEM_RE = re.compile( - r"\\?\[\s*Result\s*\\?:\s*(?P(?:[^]\\]|\\.)+)\s*\\?]\s*(?P.*?)\s*\\?\[\s*\\?/\s*Result\s*\\?]", + r"\\?\[Result\\?:(?P[^]]+)\\?](?P.*?)\\?\[\\?/Result\\?]", re.DOTALL | re.IGNORECASE, ) TAGGED_ARG_RE = re.compile( - r"\\?\[\s*CallParameter\s*\\?:\s*(?P(?:[^]\\]|\\.)+)\s*\\?]\s*(?P.*?)\s*\\?\[\s*\\?/\s*CallParameter\s*\\?]", + r"\\?\[CallParameter\\?:(?P[^]]+)\\?](?P.*?)\\?\[\\?/CallParameter\\?]", re.DOTALL | re.IGNORECASE, ) TAGGED_RESULT_RE = re.compile( - r"\\?\[\s*ToolResult\s*\\?]\s*(.*?)\s*\\?\[\s*\\?/\s*ToolResult\s*\\?]", + r"\\?\[ToolResult\\?](.*?)\\?\[\\?/ToolResult\\?]", re.DOTALL | re.IGNORECASE, ) -CONTROL_TOKEN_RE = re.compile( - r"\\?\s*<\s*\\?\|\s*im\s*\\?_(?:start|end)\s*\\?\|\s*>\s*", re.IGNORECASE -) -CHATML_START_RE = re.compile( - r"\\?\s*<\s*\\?\|\s*im\s*\\?_start\s*\\?\|\s*>\s*(\w+)\s*\n?", re.IGNORECASE -) -CHATML_END_RE = re.compile(r"\\?\s*<\s*\\?\|\s*im\s*\\?_end\s*\\?\|\s*>\s*", re.IGNORECASE) +CONTROL_TOKEN_RE = re.compile(r"\\?<\\?\|im\\?_(?:start|end)\\?\|\\?>", re.IGNORECASE) +CHATML_START_RE = re.compile(r"\\?<\\?\|im\\?_start\\?\|\\?>(\w+)\n?", re.IGNORECASE) +CHATML_END_RE = re.compile(r"\\?<\\?\|im\\?_end\\?\|\\?>", re.IGNORECASE) COMMONMARK_UNESCAPE_RE = re.compile(r"\\([!\"#$%&'()*+,\-./:;<=>?@\[\\\]^_`{|}~])") PARAM_FENCE_RE = re.compile(r"^(?P`{3,})") TOOL_HINT_STRIPPED = TOOL_WRAP_HINT.strip() @@ -89,19 +85,17 @@ # --- Streaming Specific Patterns --- _START_PATTERNS = { - "TOOL": r"\\?\[\s*ToolCalls\s*\\?\]", - "ORPHAN": r"\\?\[\s*Call\s*\\?:\s*(?:[^\]\\]|\\.)+\s*\\?\]", - "RESP": r"\\?\[\s*ToolResults\s*\\?\]", - "ARG": r"\\?\[\s*CallParameter\s*\\?:\s*(?:[^\]\\]|\\.)+\s*\\?\]", - "RESULT": r"\\?\[\s*ToolResult\s*\\?\]", - "ITEM": r"\\?\[\s*Result\s*\\?:\s*(?:[^\]\\]|\\.)+\s*\\?\]", - "TAG": r"\\?\s*<\s*\\?\|\s*im\s*\\?_start\s*\\?\|\s*>", + "TOOL": r"\\?\[ToolCalls\\?]", + "ORPHAN": r"\\?\[Call\\?:[^]]+\\?]", + "RESP": r"\\?\[ToolResults\\?]", + "ARG": r"\\?\[CallParameter\\?:[^]]+\\?]", + "RESULT": r"\\?\[ToolResult\\?]", + "ITEM": r"\\?\[Result\\?:[^]]+\\?]", + "TAG": r"\\?<\\?\|im\\?_start\\?\|\\?>", } -_PROTOCOL_ENDS = ( - r"\\?\[\s*\\?/\s*(?:ToolCalls|Call|ToolResults|CallParameter|ToolResult|Result)\s*\\?\]" -) -_TAG_END = r"\\?\s*<\s*\\?\|\s*im\s*\\?_end\s*\\?\|\s*>" +_PROTOCOL_ENDS = r"\\?\[\\?/(?:ToolCalls|Call|ToolResults|CallParameter|ToolResult|Result)\\?]" +_TAG_END = r"\\?<\\?\|im\\?_end\\?\|\\?>" if TOOL_HINT_START_ESC and TOOL_HINT_END_ESC: _START_PATTERNS["HINT"] = rf"\n?{TOOL_HINT_START_ESC}:?\s*" @@ -115,7 +109,7 @@ STREAM_MASTER_RE = re.compile("|".join(_master_parts), re.IGNORECASE) STREAM_TAIL_RE = re.compile( - r"(?:\\|\\?\[[TCRP/]?\s*[^]]*|\\?\s*<\s*\\?\|?\s*i?\s*m?\s*\\?_?(?:s?t?a?r?t?|e?n?d?)\s*\\?\|?\s*>?|)$", + r"(?:\\|\\?\[[^]]*|\\?<\\?\|?i?m?\\?_?(?:s?t?a?r?t?|e?n?d?)\\?\|?\\?>?)$", re.IGNORECASE, ) From f1de210d0a7143bc3d711b6ceb87c9bfe20b8f86 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C6=B0u=20Quang=20V=C5=A9?= Date: Thu, 12 Mar 2026 22:38:20 +0700 Subject: [PATCH 212/291] Remove the unused tag and clean up the redundant `extract_output` helper. --- app/server/chat.py | 41 ++++++++++++++++------------------------- app/services/client.py | 15 +-------------- uv.lock | 4 ++-- 3 files changed, 19 insertions(+), 41 deletions(-) diff --git a/app/server/chat.py b/app/server/chat.py index a07a931..0722c49 100644 --- a/app/server/chat.py +++ b/app/server/chat.py @@ -72,6 +72,7 @@ estimate_tokens, extract_image_dimensions, extract_tool_calls, + normalize_llm_text, remove_tool_call_blocks, strip_system_hints, text_from_message, @@ -1270,7 +1271,9 @@ def make_chunk(delta_content: dict) -> str: yield make_chunk({"delta": {"content": remaining_text}, "finish_reason": None}) _, _, storage_output, detected_tool_calls = _process_llm_output( - full_thoughts, full_text, structured_requirement + normalize_llm_text(full_thoughts or ""), + normalize_llm_text(full_text or ""), + structured_requirement, ) seen_hashes = {} @@ -1823,7 +1826,9 @@ async def _make_async_gen(item: ModelOutput) -> AsyncGenerator[ModelOutput]: ) _, assistant_text, storage_output, detected_tool_calls = _process_llm_output( - full_thoughts, full_text, structured_requirement + normalize_llm_text(full_thoughts or ""), + normalize_llm_text(full_text or ""), + structured_requirement, ) image_items = [] @@ -2262,17 +2267,10 @@ async def create_chat_completion( assert isinstance(resp_or_stream, ModelOutput) - try: - thoughts = resp_or_stream.thoughts - raw_clean = GeminiClientWrapper.extract_output(resp_or_stream, include_thoughts=False) - except Exception as exc: - logger.error(f"Gemini output parsing failed: {exc}") - raise HTTPException( - status_code=status.HTTP_502_BAD_GATEWAY, detail="Malformed response." - ) from exc - thoughts, visible_output, storage_output, tool_calls = _process_llm_output( - thoughts, raw_clean, structured_requirement + normalize_llm_text(resp_or_stream.thoughts or ""), + normalize_llm_text(resp_or_stream.text or ""), + structured_requirement, ) images = resp_or_stream.images or [] @@ -2412,8 +2410,8 @@ async def create_response( ): base_url = str(raw_request.base_url) base_messages = _convert_responses_to_app_messages(request.input) - struct_req = _build_structured_requirement(request.response_format) - extra_instr = [struct_req.instruction] if struct_req else [] + structured_requirement = _build_structured_requirement(request.response_format) + extra_instr = [structured_requirement.instruction] if structured_requirement else [] standard_tools, image_tools = [], [] if request.tools: @@ -2509,22 +2507,15 @@ async def create_response( session, request, base_url, - struct_req, + structured_requirement, ) assert isinstance(resp_or_stream, ModelOutput) - try: - thoughts = resp_or_stream.thoughts - raw_clean = GeminiClientWrapper.extract_output(resp_or_stream, include_thoughts=False) - except Exception as exc: - logger.error(f"Gemini parsing failed: {exc}") - raise HTTPException( - status_code=status.HTTP_502_BAD_GATEWAY, detail="Malformed response." - ) from exc - thoughts, assistant_text, storage_output, tool_calls = _process_llm_output( - thoughts, raw_clean, struct_req + normalize_llm_text(resp_or_stream.thoughts or ""), + normalize_llm_text(resp_or_stream.text or ""), + structured_requirement, ) images = resp_or_stream.images or [] if ( diff --git a/app/services/client.py b/app/services/client.py index e6638ab..b9ba25e 100644 --- a/app/services/client.py +++ b/app/services/client.py @@ -3,14 +3,13 @@ from typing import Any import orjson -from gemini_webapi import GeminiClient, ModelOutput +from gemini_webapi import GeminiClient from loguru import logger from app.models import AppMessage from app.utils import g_config from app.utils.helper import ( add_tag, - normalize_llm_text, save_file_to_tempfile, save_url_to_tempfile, ) @@ -173,15 +172,3 @@ async def process_conversation( conversation.append(add_tag("assistant", "", unclose=True)) return "\n".join(conversation), files - - @staticmethod - def extract_output(response: ModelOutput, include_thoughts: bool = True) -> str: - text = "" - if include_thoughts and response.thoughts: - text += f"{response.thoughts}\n" - if response.text: - text += response.text - else: - text += str(response) - - return normalize_llm_text(text) diff --git a/uv.lock b/uv.lock index 223d9b6..d62dfe3 100644 --- a/uv.lock +++ b/uv.lock @@ -176,8 +176,8 @@ dev = [{ name = "gemini-fastapi", extras = ["dev"] }] [[package]] name = "gemini-webapi" -version = "0.0.post291" -source = { git = "https://github.com/luuquangvu/Gemini-API.git?rev=move-httpx-to-curl_cffi#00765f99cb95b0fbc8aaa668f84ebdf829fafe5a" } +version = "0.0.post292" +source = { git = "https://github.com/luuquangvu/Gemini-API.git?rev=move-httpx-to-curl_cffi#ea90f9e903ca4f624ff6e94b200735922dfbc08f" } dependencies = [ { name = "curl-cffi" }, { name = "loguru" }, From 370f104d3ce70e3484599912d8aeb879e1b51c2c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C6=B0u=20Quang=20V=C5=A9?= Date: Fri, 13 Mar 2026 09:50:40 +0700 Subject: [PATCH 213/291] Clean up the redundant code and update dependencies to latest versions --- app/server/chat.py | 5 +- app/utils/config.py | 16 ++----- app/utils/helper.py | 2 +- pyproject.toml | 6 +-- uv.lock | 109 ++++++++++++++++++++++---------------------- 5 files changed, 62 insertions(+), 76 deletions(-) diff --git a/app/server/chat.py b/app/server/chat.py index 0722c49..3190b19 100644 --- a/app/server/chat.py +++ b/app/server/chat.py @@ -73,7 +73,6 @@ extract_image_dimensions, extract_tool_calls, normalize_llm_text, - remove_tool_call_blocks, strip_system_hints, text_from_message, ) @@ -357,9 +356,7 @@ def _process_llm_output( logger.debug(f"Detected {len(tool_calls)} tool call(s) in model output.") visible_output = visible_output.strip() - - storage_output = remove_tool_call_blocks(raw_text) - storage_output = storage_output.strip() + storage_output = visible_output if structured_requirement and visible_output: try: diff --git a/app/utils/config.py b/app/utils/config.py index 3a4ade9..e569fbf 100644 --- a/app/utils/config.py +++ b/app/utils/config.py @@ -1,7 +1,7 @@ import ast import os import sys -from typing import Any, Literal +from typing import Any, Literal, cast import orjson from loguru import logger @@ -250,7 +250,7 @@ def extract_gemini_clients_env() -> dict[int, dict[str, Any]]: idx = int(index_str) env_overrides.setdefault(idx, {})[field] = v to_delete.append(k) - # Remove these environment variables to avoid Pydantic parsing errors + for k in to_delete: del os.environ[k] return env_overrides @@ -306,9 +306,8 @@ def extract_gemini_models_env() -> dict[int, dict[str, Any]]: if parsed_successfully and isinstance(models_list, list): for idx, model_data in enumerate(models_list): if isinstance(model_data, dict): - env_overrides[idx] = model_data + env_overrides[idx] = cast(dict[str, Any], model_data) - # Remove the environment variable to avoid Pydantic parsing errors del os.environ[root_key] return env_overrides @@ -328,12 +327,10 @@ def _merge_models_with_env( for idx in sorted(env_overrides): overrides = env_overrides[idx] if idx < len(result_models): - # Update existing model: overwrite fields found in env model_dict = result_models[idx].model_dump() model_dict.update(overrides) result_models[idx] = GeminiModelConfig(**model_dict) elif idx == len(result_models): - # Append new models new_model = GeminiModelConfig(**overrides) result_models.append(new_model) else: @@ -352,20 +349,13 @@ def initialize_config() -> Config: Config: Configuration object """ try: - # First, extract and remove Gemini clients related environment variables env_clients_overrides = extract_gemini_clients_env() - # Extract and remove Gemini models related environment variables env_models_overrides = extract_gemini_models_env() - - # Then, initialize Config with pydantic_settings config = Config() - # Synthesize clients config.gemini.clients = _merge_clients_with_env( config.gemini.clients, env_clients_overrides ) - - # Synthesize models config.gemini.models = _merge_models_with_env(config.gemini.models, env_models_overrides) return config diff --git a/app/utils/helper.py b/app/utils/helper.py index e7c0816..a2d8471 100644 --- a/app/utils/helper.py +++ b/app/utils/helper.py @@ -33,7 +33,7 @@ "[/CallParameter]\n" "[/Call]\n" "[/ToolCalls]\n\n" - "CRITICAL: Do NOT mix natural language with protocol tags. Either respond naturally OR provide the protocol block alone. There is no middle ground.\n" + "CRITICAL: Do NOT mix natural language with protocol tags. Either respond naturally OR provide the protocol block alone. There is no middle ground." ) TOOL_BLOCK_RE = re.compile( r"\\?\[ToolCalls\\?](.*?)\\?\[\\?/ToolCalls\\?]", diff --git a/pyproject.toml b/pyproject.toml index 3afe92a..d5b96ff 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -9,7 +9,7 @@ dependencies = [ "fastapi>=0.135.1", "gemini-webapi>=1.21.0", "httptools>=0.7.1", - "lmdb>=1.7.5", + "lmdb>=1.8.1", "loguru>=0.7.3", "orjson>=3.11.7", "pydantic-settings[yaml]>=2.13.1", @@ -23,8 +23,8 @@ Repository = "https://github.com/Nativu5/Gemini-FastAPI" [project.optional-dependencies] dev = [ "pytest>=9.0.2", - "ruff>=0.15.5", - "ty>=0.0.21", + "ruff>=0.15.6", + "ty>=0.0.22", ] [dependency-groups] diff --git a/uv.lock b/uv.lock index d62dfe3..d1f1f66 100644 --- a/uv.lock +++ b/uv.lock @@ -159,13 +159,13 @@ requires-dist = [ { name = "fastapi", specifier = ">=0.135.1" }, { name = "gemini-webapi", git = "https://github.com/luuquangvu/Gemini-API.git?rev=move-httpx-to-curl_cffi" }, { name = "httptools", specifier = ">=0.7.1" }, - { name = "lmdb", specifier = ">=1.7.5" }, + { name = "lmdb", specifier = ">=1.8.1" }, { name = "loguru", specifier = ">=0.7.3" }, { name = "orjson", specifier = ">=3.11.7" }, { name = "pydantic-settings", extras = ["yaml"], specifier = ">=2.13.1" }, { name = "pytest", marker = "extra == 'dev'", specifier = ">=9.0.2" }, - { name = "ruff", marker = "extra == 'dev'", specifier = ">=0.15.5" }, - { name = "ty", marker = "extra == 'dev'", specifier = ">=0.0.21" }, + { name = "ruff", marker = "extra == 'dev'", specifier = ">=0.15.6" }, + { name = "ty", marker = "extra == 'dev'", specifier = ">=0.0.22" }, { name = "uvicorn", specifier = ">=0.41.0" }, { name = "uvloop", marker = "sys_platform != 'win32'", specifier = ">=0.22.1" }, ] @@ -176,8 +176,8 @@ dev = [{ name = "gemini-fastapi", extras = ["dev"] }] [[package]] name = "gemini-webapi" -version = "0.0.post292" -source = { git = "https://github.com/luuquangvu/Gemini-API.git?rev=move-httpx-to-curl_cffi#ea90f9e903ca4f624ff6e94b200735922dfbc08f" } +version = "0.0.post293" +source = { git = "https://github.com/luuquangvu/Gemini-API.git?rev=move-httpx-to-curl_cffi#be8c783bbf89c94fbcebb76a3f556d1ad830bb12" } dependencies = [ { name = "curl-cffi" }, { name = "loguru" }, @@ -229,17 +229,16 @@ wheels = [ [[package]] name = "lmdb" -version = "1.7.5" +version = "1.8.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/c7/a3/3756f2c6adba4a1413dba55e6c81a20b38a868656517308533e33cb59e1c/lmdb-1.7.5.tar.gz", hash = "sha256:f0604751762cb097059d5412444c4057b95f386c7ed958363cf63f453e5108da", size = 883490, upload-time = "2025-10-15T03:39:44.038Z" } +sdist = { url = "https://files.pythonhosted.org/packages/23/19/392f028e7ebcc1cc8212fe8a315a909b7a556278456f0bab9234d3a3b665/lmdb-1.8.1.tar.gz", hash = "sha256:44ef24033929e9cc227a7e17287473c452b462d716f118db885c667c80f57429", size = 886349, upload-time = "2026-03-12T23:21:48.42Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/38/f8/03275084218eacdbdf7e185d693e1db4cb79c35d18fac47fa0d388522a0d/lmdb-1.7.5-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:66ae02fa6179e46bb69fe446b7e956afe8706ae17ec1d4cd9f7056e161019156", size = 101508, upload-time = "2025-10-15T03:39:07.228Z" }, - { url = "https://files.pythonhosted.org/packages/20/b9/bc33ae2e4940359ba2fc412e6a755a2f126bc5062b4aaf35edd3a791f9a5/lmdb-1.7.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:bf65c573311ac8330c7908257f76b28ae3576020123400a81a6b650990dc028c", size = 100105, upload-time = "2025-10-15T03:39:08.491Z" }, - { url = "https://files.pythonhosted.org/packages/fa/f6/22f84b776a64d3992f052ecb637c35f1764a39df4f2190ecc5a3a1295bd7/lmdb-1.7.5-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:97bcb3fc12841a8828db918e494fe0fd016a73d2680ad830d75719bb3bf4e76a", size = 301500, upload-time = "2025-10-15T03:39:09.463Z" }, - { url = "https://files.pythonhosted.org/packages/2a/4d/8e6be8d7d5a30d47fa0ce4b55e3a8050ad689556e6e979d206b4ac67b733/lmdb-1.7.5-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:865f374f6206ab4aacb92ffb1dc612ee1a31a421db7c89733abe06b81ac87cb0", size = 302285, upload-time = "2025-10-15T03:39:10.856Z" }, - { url = "https://files.pythonhosted.org/packages/5e/dc/7e04fb31a8f88951db81ac677e3ccb3e09248eda40e6ad52f74fd9370c32/lmdb-1.7.5-cp313-cp313-win_amd64.whl", hash = "sha256:82a04d5ca2a6a799c8db7f209354c48aebb49ff338530f5813721fc4c68e4450", size = 99447, upload-time = "2025-10-15T03:39:12.151Z" }, - { url = "https://files.pythonhosted.org/packages/5b/50/e3f97efab17b3fad4afde99b3c957ecac4ffbefada6874a57ad0c695660a/lmdb-1.7.5-cp313-cp313-win_arm64.whl", hash = "sha256:0ad85a15acbfe8a42fdef92ee5e869610286d38507e976755f211be0fc905ca7", size = 94145, upload-time = "2025-10-15T03:39:13.461Z" }, - { url = "https://files.pythonhosted.org/packages/bd/2c/982cb5afed533d0cb8038232b40c19b5b85a2d887dec74dfd39e8351ef4b/lmdb-1.7.5-py3-none-any.whl", hash = "sha256:fc344bb8bc0786c87c4ccb19b31f09a38c08bd159ada6f037d669426fea06f03", size = 148539, upload-time = "2025-10-15T03:39:42.982Z" }, + { url = "https://files.pythonhosted.org/packages/75/5f/ec2fe6bb0986fae28db80292ffe5146ed7cc4d478d683d67050d2691a538/lmdb-1.8.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8b197007a5762411b7484e533ed1d03dca1b8ba1eee390233ac6e62ff45bd417", size = 101865, upload-time = "2026-03-12T23:21:12.902Z" }, + { url = "https://files.pythonhosted.org/packages/75/35/1e43bba9658292c2ac787f5d003baa5ed37cad1b52666526edf4c908fb7d/lmdb-1.8.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a9454649e62bc6f4a45f9aed175fcdd6fc2e91922bf970fd561053c616281d0a", size = 100462, upload-time = "2026-03-12T23:21:14.369Z" }, + { url = "https://files.pythonhosted.org/packages/64/8e/2b1a0caa42b6f980a8b8663a272b9a52d4fd51ef0ca36cdec768cea02978/lmdb-1.8.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a51881d284116d82ead233b20ae7f6dbec8624dd7b0593a755c84e0d0bc4cc29", size = 303313, upload-time = "2026-03-12T23:21:15.636Z" }, + { url = "https://files.pythonhosted.org/packages/40/51/8061694cf7b883d2a166965cbaa961ea1ce692ce1782ac58091b5aa0fdb5/lmdb-1.8.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:04c5472bfc38377a2b32ae3b494d82d9c8db7c64e9053ca1b7c86aa862ebaaf9", size = 303895, upload-time = "2026-03-12T23:21:16.769Z" }, + { url = "https://files.pythonhosted.org/packages/7f/0f/c189c40d833ecd64f3e375a0bb02378110fc958916053ee687ec2c7d5079/lmdb-1.8.1-cp313-cp313-win_amd64.whl", hash = "sha256:fe0e34b2b20f47a108c3e04b397d1e27f080a7b0256c33efb5aef7bd1bccb923", size = 99707, upload-time = "2026-03-12T23:21:17.862Z" }, + { url = "https://files.pythonhosted.org/packages/6c/c3/3c87bede5b62163b768e6a4bca893f59d3996cb6fc4052bfd67847c0efd7/lmdb-1.8.1-cp313-cp313-win_arm64.whl", hash = "sha256:c3550849cdbaf0ead6265cb5b10134b223c2cede7ce7a1f3390a55975e3a06d4", size = 94605, upload-time = "2026-03-12T23:21:19.102Z" }, ] [[package]] @@ -418,27 +417,27 @@ wheels = [ [[package]] name = "ruff" -version = "0.15.5" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/77/9b/840e0039e65fcf12758adf684d2289024d6140cde9268cc59887dc55189c/ruff-0.15.5.tar.gz", hash = "sha256:7c3601d3b6d76dce18c5c824fc8d06f4eef33d6df0c21ec7799510cde0f159a2", size = 4574214, upload-time = "2026-03-05T20:06:34.946Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/47/20/5369c3ce21588c708bcbe517a8fbe1a8dfdb5dfd5137e14790b1da71612c/ruff-0.15.5-py3-none-linux_armv6l.whl", hash = "sha256:4ae44c42281f42e3b06b988e442d344a5b9b72450ff3c892e30d11b29a96a57c", size = 10478185, upload-time = "2026-03-05T20:06:29.093Z" }, - { url = "https://files.pythonhosted.org/packages/44/ed/e81dd668547da281e5dce710cf0bc60193f8d3d43833e8241d006720e42b/ruff-0.15.5-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:6edd3792d408ebcf61adabc01822da687579a1a023f297618ac27a5b51ef0080", size = 10859201, upload-time = "2026-03-05T20:06:32.632Z" }, - { url = "https://files.pythonhosted.org/packages/c4/8f/533075f00aaf19b07c5cd6aa6e5d89424b06b3b3f4583bfa9c640a079059/ruff-0.15.5-py3-none-macosx_11_0_arm64.whl", hash = "sha256:89f463f7c8205a9f8dea9d658d59eff49db05f88f89cc3047fb1a02d9f344010", size = 10184752, upload-time = "2026-03-05T20:06:40.312Z" }, - { url = "https://files.pythonhosted.org/packages/66/0e/ba49e2c3fa0395b3152bad634c7432f7edfc509c133b8f4529053ff024fb/ruff-0.15.5-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ba786a8295c6574c1116704cf0b9e6563de3432ac888d8f83685654fe528fd65", size = 10534857, upload-time = "2026-03-05T20:06:19.581Z" }, - { url = "https://files.pythonhosted.org/packages/59/71/39234440f27a226475a0659561adb0d784b4d247dfe7f43ffc12dd02e288/ruff-0.15.5-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:fd4b801e57955fe9f02b31d20375ab3a5c4415f2e5105b79fb94cf2642c91440", size = 10309120, upload-time = "2026-03-05T20:06:00.435Z" }, - { url = "https://files.pythonhosted.org/packages/f5/87/4140aa86a93df032156982b726f4952aaec4a883bb98cb6ef73c347da253/ruff-0.15.5-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:391f7c73388f3d8c11b794dbbc2959a5b5afe66642c142a6effa90b45f6f5204", size = 11047428, upload-time = "2026-03-05T20:05:51.867Z" }, - { url = "https://files.pythonhosted.org/packages/5a/f7/4953e7e3287676f78fbe85e3a0ca414c5ca81237b7575bdadc00229ac240/ruff-0.15.5-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8dc18f30302e379fe1e998548b0f5e9f4dff907f52f73ad6da419ea9c19d66c8", size = 11914251, upload-time = "2026-03-05T20:06:22.887Z" }, - { url = "https://files.pythonhosted.org/packages/77/46/0f7c865c10cf896ccf5a939c3e84e1cfaeed608ff5249584799a74d33835/ruff-0.15.5-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1cc6e7f90087e2d27f98dc34ed1b3ab7c8f0d273cc5431415454e22c0bd2a681", size = 11333801, upload-time = "2026-03-05T20:05:57.168Z" }, - { url = "https://files.pythonhosted.org/packages/d3/01/a10fe54b653061585e655f5286c2662ebddb68831ed3eaebfb0eb08c0a16/ruff-0.15.5-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c1cb7169f53c1ddb06e71a9aebd7e98fc0fea936b39afb36d8e86d36ecc2636a", size = 11206821, upload-time = "2026-03-05T20:06:03.441Z" }, - { url = "https://files.pythonhosted.org/packages/7a/0d/2132ceaf20c5e8699aa83da2706ecb5c5dcdf78b453f77edca7fb70f8a93/ruff-0.15.5-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:9b037924500a31ee17389b5c8c4d88874cc6ea8e42f12e9c61a3d754ff72f1ca", size = 11133326, upload-time = "2026-03-05T20:06:25.655Z" }, - { url = "https://files.pythonhosted.org/packages/72/cb/2e5259a7eb2a0f87c08c0fe5bf5825a1e4b90883a52685524596bfc93072/ruff-0.15.5-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:65bb414e5b4eadd95a8c1e4804f6772bbe8995889f203a01f77ddf2d790929dd", size = 10510820, upload-time = "2026-03-05T20:06:37.79Z" }, - { url = "https://files.pythonhosted.org/packages/ff/20/b67ce78f9e6c59ffbdb5b4503d0090e749b5f2d31b599b554698a80d861c/ruff-0.15.5-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:d20aa469ae3b57033519c559e9bc9cd9e782842e39be05b50e852c7c981fa01d", size = 10302395, upload-time = "2026-03-05T20:05:54.504Z" }, - { url = "https://files.pythonhosted.org/packages/5f/e5/719f1acccd31b720d477751558ed74e9c88134adcc377e5e886af89d3072/ruff-0.15.5-py3-none-musllinux_1_2_i686.whl", hash = "sha256:15388dd28c9161cdb8eda68993533acc870aa4e646a0a277aa166de9ad5a8752", size = 10754069, upload-time = "2026-03-05T20:06:06.422Z" }, - { url = "https://files.pythonhosted.org/packages/c3/9c/d1db14469e32d98f3ca27079dbd30b7b44dbb5317d06ab36718dee3baf03/ruff-0.15.5-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:b30da330cbd03bed0c21420b6b953158f60c74c54c5f4c1dabbdf3a57bf355d2", size = 11304315, upload-time = "2026-03-05T20:06:10.867Z" }, - { url = "https://files.pythonhosted.org/packages/28/3a/950367aee7c69027f4f422059227b290ed780366b6aecee5de5039d50fa8/ruff-0.15.5-py3-none-win32.whl", hash = "sha256:732e5ee1f98ba5b3679029989a06ca39a950cced52143a0ea82a2102cb592b74", size = 10551676, upload-time = "2026-03-05T20:06:13.705Z" }, - { url = "https://files.pythonhosted.org/packages/b8/00/bf077a505b4e649bdd3c47ff8ec967735ce2544c8e4a43aba42ee9bf935d/ruff-0.15.5-py3-none-win_amd64.whl", hash = "sha256:821d41c5fa9e19117616c35eaa3f4b75046ec76c65e7ae20a333e9a8696bc7fe", size = 11678972, upload-time = "2026-03-05T20:06:45.379Z" }, - { url = "https://files.pythonhosted.org/packages/fe/4e/cd76eca6db6115604b7626668e891c9dd03330384082e33662fb0f113614/ruff-0.15.5-py3-none-win_arm64.whl", hash = "sha256:b498d1c60d2fe5c10c45ec3f698901065772730b411f164ae270bb6bfcc4740b", size = 10965572, upload-time = "2026-03-05T20:06:16.984Z" }, +version = "0.15.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/51/df/f8629c19c5318601d3121e230f74cbee7a3732339c52b21daa2b82ef9c7d/ruff-0.15.6.tar.gz", hash = "sha256:8394c7bb153a4e3811a4ecdacd4a8e6a4fa8097028119160dffecdcdf9b56ae4", size = 4597916, upload-time = "2026-03-12T23:05:47.51Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9e/2f/4e03a7e5ce99b517e98d3b4951f411de2b0fa8348d39cf446671adcce9a2/ruff-0.15.6-py3-none-linux_armv6l.whl", hash = "sha256:7c98c3b16407b2cf3d0f2b80c80187384bc92c6774d85fefa913ecd941256fff", size = 10508953, upload-time = "2026-03-12T23:05:17.246Z" }, + { url = "https://files.pythonhosted.org/packages/70/60/55bcdc3e9f80bcf39edf0cd272da6fa511a3d94d5a0dd9e0adf76ceebdb4/ruff-0.15.6-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:ee7dcfaad8b282a284df4aa6ddc2741b3f4a18b0555d626805555a820ea181c3", size = 10942257, upload-time = "2026-03-12T23:05:23.076Z" }, + { url = "https://files.pythonhosted.org/packages/e7/f9/005c29bd1726c0f492bfa215e95154cf480574140cb5f867c797c18c790b/ruff-0.15.6-py3-none-macosx_11_0_arm64.whl", hash = "sha256:3bd9967851a25f038fc8b9ae88a7fbd1b609f30349231dffaa37b6804923c4bb", size = 10322683, upload-time = "2026-03-12T23:05:33.738Z" }, + { url = "https://files.pythonhosted.org/packages/5f/74/2f861f5fd7cbb2146bddb5501450300ce41562da36d21868c69b7a828169/ruff-0.15.6-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:13f4594b04e42cd24a41da653886b04d2ff87adbf57497ed4f728b0e8a4866f8", size = 10660986, upload-time = "2026-03-12T23:05:53.245Z" }, + { url = "https://files.pythonhosted.org/packages/c1/a1/309f2364a424eccb763cdafc49df843c282609f47fe53aa83f38272389e0/ruff-0.15.6-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e2ed8aea2f3fe57886d3f00ea5b8aae5bf68d5e195f487f037a955ff9fbaac9e", size = 10332177, upload-time = "2026-03-12T23:05:56.145Z" }, + { url = "https://files.pythonhosted.org/packages/30/41/7ebf1d32658b4bab20f8ac80972fb19cd4e2c6b78552be263a680edc55ac/ruff-0.15.6-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:70789d3e7830b848b548aae96766431c0dc01a6c78c13381f423bf7076c66d15", size = 11170783, upload-time = "2026-03-12T23:06:01.742Z" }, + { url = "https://files.pythonhosted.org/packages/76/be/6d488f6adca047df82cd62c304638bcb00821c36bd4881cfca221561fdfc/ruff-0.15.6-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:542aaf1de3154cea088ced5a819ce872611256ffe2498e750bbae5247a8114e9", size = 12044201, upload-time = "2026-03-12T23:05:28.697Z" }, + { url = "https://files.pythonhosted.org/packages/71/68/e6f125df4af7e6d0b498f8d373274794bc5156b324e8ab4bf5c1b4fc0ec7/ruff-0.15.6-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1c22e6f02c16cfac3888aa636e9eba857254d15bbacc9906c9689fdecb1953ab", size = 11421561, upload-time = "2026-03-12T23:05:31.236Z" }, + { url = "https://files.pythonhosted.org/packages/f1/9f/f85ef5fd01a52e0b472b26dc1b4bd228b8f6f0435975442ffa4741278703/ruff-0.15.6-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:98893c4c0aadc8e448cfa315bd0cc343a5323d740fe5f28ef8a3f9e21b381f7e", size = 11310928, upload-time = "2026-03-12T23:05:45.288Z" }, + { url = "https://files.pythonhosted.org/packages/8c/26/b75f8c421f5654304b89471ed384ae8c7f42b4dff58fa6ce1626d7f2b59a/ruff-0.15.6-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:70d263770d234912374493e8cc1e7385c5d49376e41dfa51c5c3453169dc581c", size = 11235186, upload-time = "2026-03-12T23:05:50.677Z" }, + { url = "https://files.pythonhosted.org/packages/fc/d4/d5a6d065962ff7a68a86c9b4f5500f7d101a0792078de636526c0edd40da/ruff-0.15.6-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:55a1ad63c5a6e54b1f21b7514dfadc0c7fb40093fa22e95143cf3f64ebdcd512", size = 10635231, upload-time = "2026-03-12T23:05:37.044Z" }, + { url = "https://files.pythonhosted.org/packages/d6/56/7c3acf3d50910375349016cf33de24be021532042afbed87942858992491/ruff-0.15.6-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:8dc473ba093c5ec238bb1e7429ee676dca24643c471e11fbaa8a857925b061c0", size = 10340357, upload-time = "2026-03-12T23:06:04.748Z" }, + { url = "https://files.pythonhosted.org/packages/06/54/6faa39e9c1033ff6a3b6e76b5df536931cd30caf64988e112bbf91ef5ce5/ruff-0.15.6-py3-none-musllinux_1_2_i686.whl", hash = "sha256:85b042377c2a5561131767974617006f99f7e13c63c111b998f29fc1e58a4cfb", size = 10860583, upload-time = "2026-03-12T23:05:58.978Z" }, + { url = "https://files.pythonhosted.org/packages/cb/1e/509a201b843b4dfb0b32acdedf68d951d3377988cae43949ba4c4133a96a/ruff-0.15.6-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:cef49e30bc5a86a6a92098a7fbf6e467a234d90b63305d6f3ec01225a9d092e0", size = 11410976, upload-time = "2026-03-12T23:05:39.955Z" }, + { url = "https://files.pythonhosted.org/packages/6c/25/3fc9114abf979a41673ce877c08016f8e660ad6cf508c3957f537d2e9fa9/ruff-0.15.6-py3-none-win32.whl", hash = "sha256:bbf67d39832404812a2d23020dda68fee7f18ce15654e96fb1d3ad21a5fe436c", size = 10616872, upload-time = "2026-03-12T23:05:42.451Z" }, + { url = "https://files.pythonhosted.org/packages/89/7a/09ece68445ceac348df06e08bf75db72d0e8427765b96c9c0ffabc1be1d9/ruff-0.15.6-py3-none-win_amd64.whl", hash = "sha256:aee25bc84c2f1007ecb5037dff75cef00414fdf17c23f07dc13e577883dca406", size = 11787271, upload-time = "2026-03-12T23:05:20.168Z" }, + { url = "https://files.pythonhosted.org/packages/7f/d0/578c47dd68152ddddddf31cd7fc67dc30b7cdf639a86275fda821b0d9d98/ruff-0.15.6-py3-none-win_arm64.whl", hash = "sha256:c34de3dd0b0ba203be50ae70f5910b17188556630e2178fd7d79fc030eb0d837", size = 11060497, upload-time = "2026-03-12T23:05:25.968Z" }, ] [[package]] @@ -455,26 +454,26 @@ wheels = [ [[package]] name = "ty" -version = "0.0.21" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/ee/20/2ba8fd9493c89c41dfe9dbb73bc70a28b28028463bc0d2897ba8be36230a/ty-0.0.21.tar.gz", hash = "sha256:a4c2ba5d67d64df8fcdefd8b280ac1149d24a73dbda82fa953a0dff9d21400ed", size = 5297967, upload-time = "2026-03-06T01:57:13.809Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/36/70/edf38bb37517531681d1c37f5df64744e5ad02673c02eb48447eae4bea08/ty-0.0.21-py3-none-linux_armv6l.whl", hash = "sha256:7bdf2f572378de78e1f388d24691c89db51b7caf07cf90f2bfcc1d6b18b70a76", size = 10299222, upload-time = "2026-03-06T01:57:16.64Z" }, - { url = "https://files.pythonhosted.org/packages/72/62/0047b0bd19afeefbc7286f20a5f78a2aa39f92b4d89853f0d7185ab89edc/ty-0.0.21-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:7e9613994610431ab8625025bd2880dbcb77c5c9fabdd21134cda12d840a529d", size = 10130513, upload-time = "2026-03-06T01:57:29.93Z" }, - { url = "https://files.pythonhosted.org/packages/a2/20/0b93a9e91aaed23155780258cdfdb4726ef68b6985378ac069bc427291a0/ty-0.0.21-py3-none-macosx_11_0_arm64.whl", hash = "sha256:56d3b198b64dd0a19b2b66e257deaed2ecea568e722ae5352f3c6fb62027f89d", size = 9605425, upload-time = "2026-03-06T01:57:27.115Z" }, - { url = "https://files.pythonhosted.org/packages/ea/fd/9945e2fa2996a1287b1e1d7ce050e97e1f420233b271e770934bfa0880a0/ty-0.0.21-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d23d2c34f7a77d974bb08f0860ef700addc8a683d81a0319f71c08f87506cfd0", size = 10108298, upload-time = "2026-03-06T01:57:35.429Z" }, - { url = "https://files.pythonhosted.org/packages/52/e7/4ec52fcb15f3200826c9f048472c062549a05b0d1ef0b51f32d527b513c4/ty-0.0.21-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:56b01fd2519637a4ca88344f61c96225f540c98ff18bca321d4eaa7bb0f7aa2f", size = 10121556, upload-time = "2026-03-06T01:57:03.242Z" }, - { url = "https://files.pythonhosted.org/packages/ee/c0/ad457be2a8abea0f25549598bd098554540ced66229488daa0d558dad3c8/ty-0.0.21-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e9de7e11c63c6afc40f3e9ba716374add171aee7fabc70b5146a510705c6d41b", size = 10603264, upload-time = "2026-03-06T01:56:52.134Z" }, - { url = "https://files.pythonhosted.org/packages/f8/5b/2ecc7a2175243a4bcb72f5298ae41feabbb93b764bb0dc45722f3752c2c2/ty-0.0.21-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:62f7f5b235c4f7876db305c36997aea07b7af29b1a068f373d0e2547e25f32ff", size = 11196428, upload-time = "2026-03-06T01:57:32.94Z" }, - { url = "https://files.pythonhosted.org/packages/37/f5/aff507d6a901f328ef96a298032b0c11aaaf950a146ed7dd3b5bf2cd3acf/ty-0.0.21-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ee8399f7c453a425291e6688efe430cfae7ab0ac4ffd50eba9f872bf878b54f6", size = 10866355, upload-time = "2026-03-06T01:56:57.831Z" }, - { url = "https://files.pythonhosted.org/packages/be/30/822bbcb92d55b65989aa7ed06d9585f28ade9c9447369194ed4b0fb3b5b9/ty-0.0.21-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:210e7568c9f886c4d01308d751949ee714ad7ad9d7d928d2ba90d329dd880367", size = 10738177, upload-time = "2026-03-06T01:57:11.256Z" }, - { url = "https://files.pythonhosted.org/packages/57/cc/46e7991b6469e93ac2c7e533a028983e402485580150ac864c56352a3a82/ty-0.0.21-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:53508e345b11569f78b21ba8e2b4e61df38a9754947fb3cd9f2ef574367338fb", size = 10079158, upload-time = "2026-03-06T01:57:00.516Z" }, - { url = "https://files.pythonhosted.org/packages/15/c2/0bbdadfbd008240f8f1a87dc877433cb3884436097926107ccf06e618199/ty-0.0.21-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:553e43571f4a35604c36cfd07d8b61a5eb7a714e3c67f8c4ff2cf674fefbaef9", size = 10150535, upload-time = "2026-03-06T01:57:08.815Z" }, - { url = "https://files.pythonhosted.org/packages/c5/b5/2dbdb7b57b5362200ef0a39738ebd31331726328336def0143ac097ee59d/ty-0.0.21-py3-none-musllinux_1_2_i686.whl", hash = "sha256:666f6822e3b9200abfa7e95eb0ddd576460adb8d66b550c0ad2c70abc84a2048", size = 10319803, upload-time = "2026-03-06T01:57:19.106Z" }, - { url = "https://files.pythonhosted.org/packages/72/84/70e52c0b7abc7c2086f9876ef454a73b161d3125315536d8d7e911c94ca4/ty-0.0.21-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:a0854d008347ce4a5fb351af132f660a390ab2a1163444d075251d43e6f74b9b", size = 10826239, upload-time = "2026-03-06T01:57:21.727Z" }, - { url = "https://files.pythonhosted.org/packages/a1/8a/1f72480fd013bbc6cd1929002abbbcde9a0b08ead6a15154de9d7f7fa37e/ty-0.0.21-py3-none-win32.whl", hash = "sha256:bef3ab4c7b966bcc276a8ac6c11b63ba222d21355b48d471ea782c4104eee4e0", size = 9693196, upload-time = "2026-03-06T01:57:24.126Z" }, - { url = "https://files.pythonhosted.org/packages/8d/f8/1104808b875c26c640e536945753a78562d606bef4e241d9dbf3d92477f6/ty-0.0.21-py3-none-win_amd64.whl", hash = "sha256:a709d576e5bea84b745d43058d8b9cd4f27f74a0b24acb4b0cbb7d3d41e0d050", size = 10668660, upload-time = "2026-03-06T01:56:55.06Z" }, - { url = "https://files.pythonhosted.org/packages/1b/b8/25e0adc404bbf986977657b25318991f93097b49f8aea640d93c0b0db68e/ty-0.0.21-py3-none-win_arm64.whl", hash = "sha256:f72047996598ac20553fb7e21ba5741e3c82dee4e9eadf10d954551a5fe09391", size = 10104161, upload-time = "2026-03-06T01:57:06.072Z" }, +version = "0.0.22" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f5/ee/b73c99daf598ae66a2d5d3ba6de7729d2152ab732dee7ccb8ab9446cc6d7/ty-0.0.22.tar.gz", hash = "sha256:391fc4d3a543950341b750d7f4aa94866a73e7cdbf3e9e4e4e8cfc8b7bef4f10", size = 5333861, upload-time = "2026-03-12T17:40:30.052Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d7/f7/078f554f612723597f76cc6af70da4daed63ed721241a3f60788259c9adf/ty-0.0.22-py3-none-linux_armv6l.whl", hash = "sha256:03d37220d81016cb9d2a9c9ec11704d84f2df838f1dbf1296d91ea7fba57f8b5", size = 10328232, upload-time = "2026-03-12T17:40:19.402Z" }, + { url = "https://files.pythonhosted.org/packages/90/0b/4cfe84485d1b20bb50cdbc990f6e66b8c50cff569c7544adf0805b57ddb9/ty-0.0.22-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:3249c65b24829a312cd5cbf722ff5551ffe17b0a9781a8a372ca037d23aa1c71", size = 10148554, upload-time = "2026-03-12T17:40:25.586Z" }, + { url = "https://files.pythonhosted.org/packages/1b/7e/df31baf70d63880c9719d2cc8403b0b99c3c0d0f68f390a1109d9b231933/ty-0.0.22-py3-none-macosx_11_0_arm64.whl", hash = "sha256:470778f4335f1660f017fe2970afb7e4ce4f8b608795b19406976b8902b221a5", size = 9627910, upload-time = "2026-03-12T17:40:17.447Z" }, + { url = "https://files.pythonhosted.org/packages/99/0f/a418bcca9c87083533d6c73b65e56c6ade26b8d76a7558b3d3cc0f0eb52a/ty-0.0.22-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:75649b04b84ace92cb5c6e27013247f220f58a9a30b30eb2301992814deea0c4", size = 10155025, upload-time = "2026-03-12T17:40:21.344Z" }, + { url = "https://files.pythonhosted.org/packages/2d/3d/1974c567a58f369602065409d9109c0a81f5abbf1ae552433a89d07141a9/ty-0.0.22-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bc270f2344210cbed7d965ddeade61ffa81d93dffcdc0fded3540dccb860a9e1", size = 10133614, upload-time = "2026-03-12T17:40:23.549Z" }, + { url = "https://files.pythonhosted.org/packages/c0/c1/2da9e27c79a1fe9209589a73c989e416a7380bd77dcdf22960b3d30252bf/ty-0.0.22-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:548215b226f9726ea4d9beb77055363a8a398eb42809f042895f7a285afcb538", size = 10647101, upload-time = "2026-03-12T17:40:15.569Z" }, + { url = "https://files.pythonhosted.org/packages/c2/93/4e12c2f0ec792fd4ab9c9f70e59465d09345a453ebedb67d3bf99fd75a71/ty-0.0.22-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ed0bd1d34eba800b82ebee65269a85a9bbb2a325237e4baaf1413223f69e1899", size = 11231886, upload-time = "2026-03-12T17:40:06.875Z" }, + { url = "https://files.pythonhosted.org/packages/2a/9f/c255a078e4f2ce135497fffa4a5d3a122e4c49a00416fb78d72d7b79e119/ty-0.0.22-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:dbd429a31507da9a1b0873a21215113c42cc683aa5fba96c978794485db5560a", size = 10901527, upload-time = "2026-03-12T17:40:34.429Z" }, + { url = "https://files.pythonhosted.org/packages/f2/0d/d1bdee7e16d978ea929837fb03463efc116ee8ad05d215a5efd5d80e56d3/ty-0.0.22-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c7eb85a437b3be817796e7c0f84243611de53c7d4ea102a0dca179debfe7cec0", size = 10726505, upload-time = "2026-03-12T17:40:36.342Z" }, + { url = "https://files.pythonhosted.org/packages/a5/d4/6548d2a353f794582ec94d886b310589c70316fe43476a558e53073ea911/ty-0.0.22-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:b8e32e362e0666cc0769d2862a273def6b61117b8fbb9df493274d536afcd02e", size = 10128777, upload-time = "2026-03-12T17:40:38.517Z" }, + { url = "https://files.pythonhosted.org/packages/9f/d2/eb9185d3fe1fa12decb1c0a045416063bc40122187769b3dfb324da9e51c/ty-0.0.22-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:667deb1aaf802f396a626cc5a52cd55d935e8d0b46d1be068cf874f7d6f4bdb5", size = 10164992, upload-time = "2026-03-12T17:40:27.833Z" }, + { url = "https://files.pythonhosted.org/packages/7b/ec/067bb6d78cc6f5c4f55f0c3f760eb792b144697b454938fb9d10652caeb2/ty-0.0.22-py3-none-musllinux_1_2_i686.whl", hash = "sha256:e160681dbf602160e091d94a68207d1393733aedd95e3dc0b2d010bb39a70d78", size = 10342871, upload-time = "2026-03-12T17:40:13.447Z" }, + { url = "https://files.pythonhosted.org/packages/c0/04/dd3a87f54f78ceef5e6ab2add2f3bb85d45829318740f459886654b71a5d/ty-0.0.22-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:255598763079b80513d98084c4897df688e42666d7e4371349f97d258166389d", size = 10823909, upload-time = "2026-03-12T17:40:11.444Z" }, + { url = "https://files.pythonhosted.org/packages/d7/29/4b12e8ff99dec65487ec5342bd5b51fae1482e93a669d098777d55ca5eda/ty-0.0.22-py3-none-win32.whl", hash = "sha256:de0d88d9f788defddfec5507bf356bfc8b90ee301b7d6204f7609e7ac270276f", size = 9746013, upload-time = "2026-03-12T17:40:32.272Z" }, + { url = "https://files.pythonhosted.org/packages/84/16/e246795ed66ff8ee1a47497019f86ea1b4fb238bfca3068f2e08c52ef03b/ty-0.0.22-py3-none-win_amd64.whl", hash = "sha256:c216f750769ac9f3e9e61feabf3fd44c0697dce762bdcd105443d47e1a81c2b9", size = 10709350, upload-time = "2026-03-12T17:40:40.82Z" }, + { url = "https://files.pythonhosted.org/packages/3b/a4/5aafcebc4f597164381b0a82e7a8780d8f9f52df3884b16909a76282a0da/ty-0.0.22-py3-none-win_arm64.whl", hash = "sha256:49795260b9b9e3d6f04424f8ddb34907fac88c33a91b83478a74cead5dde567f", size = 10137248, upload-time = "2026-03-12T17:40:09.244Z" }, ] [[package]] From a5960098551ddedec086486113a72baed1138eed Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C6=B0u=20Quang=20V=C5=A9?= Date: Fri, 13 Mar 2026 13:23:47 +0700 Subject: [PATCH 214/291] Include random delays to stagger the start times for clients in the pool. --- app/server/health.py | 12 +++--------- app/services/pool.py | 23 +++++++++++++---------- 2 files changed, 16 insertions(+), 19 deletions(-) diff --git a/app/server/health.py b/app/server/health.py index 444c938..449081f 100644 --- a/app/server/health.py +++ b/app/server/health.py @@ -11,19 +11,13 @@ async def health_check(): pool = GeminiClientPool() db = LMDBConversationStore() - - try: - await pool.init() - except Exception as e: - logger.error(f"Failed to initialize Gemini clients: {e}") - return HealthCheckResponse(ok=False, error=str(e)) - client_status = pool.status() + stat = db.stats() if not all(client_status.values()): - logger.warning("One or more Gemini clients not running") + down_clients = [client_id for client_id, status in client_status.items() if not status] + logger.warning(f"One or more Gemini clients not running: {', '.join(down_clients)}") - stat = db.stats() if not stat: logger.error("Failed to retrieve LMDB conversation store stats") return HealthCheckResponse( diff --git a/app/services/pool.py b/app/services/pool.py index febe91d..60ce5ee 100644 --- a/app/services/pool.py +++ b/app/services/pool.py @@ -1,4 +1,5 @@ import asyncio +import random from collections import deque from loguru import logger @@ -32,18 +33,20 @@ def __init__(self) -> None: self._restart_locks[c.id] = asyncio.Lock() async def init(self) -> None: - """Initialize all clients in the pool.""" - success_count = 0 - for client in self._clients: - if not client.running(): - try: - await client.init() - except Exception: - logger.exception(f"Failed to initialize client {client.id}") + """Initialize all clients in the pool with staggered start times.""" + clients_to_init = [c for c in self._clients if not c.running()] + for i, client in enumerate(clients_to_init): + try: + await client.init() + except Exception: + logger.error(f"Failed to initialize client {client.id}") - if client.running(): - success_count += 1 + if i < len(clients_to_init) - 1: + delay = random.uniform(5, 30) + logger.info(f"Staggering next initialization by {delay:.2f}s") + await asyncio.sleep(delay) + success_count = sum(1 for client in self._clients if client.running()) if success_count == 0: raise RuntimeError("Failed to initialize any Gemini clients") From d038d65d733c1924c2010d6a41e3503910034075 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C6=B0u=20Quang=20V=C5=A9?= Date: Sat, 14 Mar 2026 11:07:05 +0700 Subject: [PATCH 215/291] Allow remembering and recovering conversation metadata during retention days, but update the README to specify that users need to enable Gemini Apps activity for it to work as expected. --- README.md | 23 ++++++++++++++--------- README.zh.md | 28 +++++++++++++++------------- app/server/chat.py | 28 ++++++++-------------------- pyproject.toml | 2 +- uv.lock | 40 ++++++++++++++++++++-------------------- 5 files changed, 58 insertions(+), 63 deletions(-) diff --git a/README.md b/README.md index 7d96646..9bc463e 100644 --- a/README.md +++ b/README.md @@ -1,22 +1,22 @@ # Gemini-FastAPI [![Python 3.13](https://img.shields.io/badge/python-3.13+-blue.svg)](https://www.python.org/downloads/) -[![FastAPI](https://img.shields.io/badge/FastAPI-0.115+-green.svg)](https://fastapi.tiangolo.com/) +[![FastAPI](https://img.shields.io/badge/FastAPI-green.svg)](https://fastapi.tiangolo.com/) [![License](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE) [ English | [中文](README.zh.md) ] Web-based Gemini models wrapped into an OpenAI-compatible API. Powered by [HanaokaYuzu/Gemini-API](https://github.com/HanaokaYuzu/Gemini-API). -**✅ Call Gemini's web-based models via API without an API Key, completely free!** +**Call Gemini's web-based models via API without an API Key, completely free!** ## Features -- **🔐 No Google API Key Required**: Use web cookies to freely access Gemini's models via API. -- **🔍 Google Search Included**: Get up-to-date answers using web-based Gemini's search capabilities. -- **💾 Conversation Persistence**: LMDB-based storage supporting multi-turn conversations. -- **🖼️ Multi-modal Support**: Support for handling text, images, and file uploads. -- **⚖️ Multi-account Load Balancing**: Distribute requests across multiple accounts with per-account proxy settings. +- **No Google API Key Required**: Use web cookies to freely access Gemini's models via API. +- **Google Search Included**: Get up-to-date answers using web-based Gemini's search capabilities. +- **Conversation Persistence**: LMDB-based storage supporting multi-turn conversations. +- **Multi-modal Support**: Support for handling text, images, and file uploads. +- **Multi-account Load Balancing**: Distribute requests across multiple accounts with per-account proxy settings. ## Quick Start @@ -25,7 +25,7 @@ Web-based Gemini models wrapped into an OpenAI-compatible API. Powered by [Hanao ### Prerequisites - Python 3.13 -- Google account with Gemini access on web +- Google account with Gemini access on web (Enable **[Gemini Apps activity](https://myactivity.google.com/product/gemini)** for best conversation persistence) - `secure_1psid` and `secure_1psidts` cookies from Gemini web interface ### Installation @@ -96,7 +96,7 @@ These endpoints are designed to be compatible with OpenAI's API structure, allow ### Utility Endpoints - **`GET /health`**: Health check endpoint. Returns the status of the server, configured Gemini clients, and conversation storage. -- **`GET /images/{filename}`**: Internal endpoint to serve generated images. Requires a valid token (automatically included in image URLs returned by the API). +- **`GET /media/{filename}`**: Internal endpoint to serve generated media. Requires a valid token (automatically included in image URLs returned by the API). ## Docker Deployment @@ -205,6 +205,11 @@ To use Gemini-FastAPI, you need to extract your Gemini session cookies: - `__Secure-1PSID` - `__Secure-1PSIDTS` +> [!IMPORTANT] +> **Enable [Gemini Apps activity](https://myactivity.google.com/product/gemini)** to ensure stable conversation persistence. +> +> While active chat turns may work temporarily without it, any transient error, TLS session restart, or server reboot can cause Google to expire the conversation metadata. If this setting is disabled, the model will **completely lose the context of your multi-turn conversation**, making old threads unreachable even if they are stored in your local LMDB. + > [!TIP] > For detailed instructions, refer to the [HanaokaYuzu/Gemini-API authentication guide](https://github.com/HanaokaYuzu/Gemini-API?tab=readme-ov-file#authentication). diff --git a/README.zh.md b/README.zh.md index e446667..fb85a48 100644 --- a/README.zh.md +++ b/README.zh.md @@ -1,22 +1,22 @@ # Gemini-FastAPI [![Python 3.13](https://img.shields.io/badge/python-3.13+-blue.svg)](https://www.python.org/downloads/) -[![FastAPI](https://img.shields.io/badge/FastAPI-0.115+-green.svg)](https://fastapi.tiangolo.com/) +[![FastAPI](https://img.shields.io/badge/FastAPI-green.svg)](https://fastapi.tiangolo.com/) [![License](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE) [ [English](README.md) | 中文 ] 将 Gemini 网页端模型封装为兼容 OpenAI API 的 API Server。基于 [HanaokaYuzu/Gemini-API](https://github.com/HanaokaYuzu/Gemini-API) 实现。 -**✅ 无需 API Key,免费通过 API 调用 Gemini 网页端模型!** +**无需 API Key,免费通过 API 调用 Gemini 网页端模型!** ## 功能特性 -- 🔐 **无需 Google API Key**:只需网页 Cookie,即可免费通过 API 调用 Gemini 模型。 -- 🔍 **内置 Google 搜索**:API 已内置 Gemini 网页端的搜索能力,模型响应更加准确。 -- 💾 **会话持久化**:基于 LMDB 存储,支持多轮对话历史记录。 -- 🖼️ **多模态支持**:可处理文本、图片及文件上传。 -- ⚖️ **多账户负载均衡**:支持多账户分发请求,可为每个账户单独配置代理。 +- **无需 Google API Key**:只需网页 Cookie,即可免费通过 API 调用 Gemini 模型。 +- **内置 Google 搜索**:API 已内置 Gemini 网页端的搜索能力,模型响应更加准确。 +- **会话持久化**:基于 LMDB 存储,支持多轮对话历史记录。 +- **多模态支持**:可处理文本、图片及文件上传。 +- **多账户负载均衡**:支持多账户分发请求,可为每个账户单独配置代理。 ## 快速开始 @@ -25,7 +25,7 @@ ### 前置条件 - Python 3.13 -- 拥有网页版 Gemini 访问权限的 Google 账号 +- 拥有网页版 Gemini 访问权限的 Google 账号 (开启 **[Gemini Apps 应用活动](https://myactivity.google.com/product/gemini)** 以获得最佳会话持久化体验) - 从 Gemini 网页获取的 `secure_1psid` 和 `secure_1psidts` Cookie ### 安装 @@ -93,10 +93,10 @@ python run.py - **`POST /v1/responses`**: 用于复杂交互模式的专用接口,支持分步输出、生成图片及工具调用等更丰富的响应项。 -### 辅助与系统接口 +### 实用工具接口 -- **`GET /health`**: 健康检查接口。返回服务器运行状态、已配置的 Gemini 客户端健康度以及对话存储统计信息。 -- **`GET /images/{filename}`**: 用于访问生成的图片的内部接口。需携带有效 Token(API 返回的图片 URL 中已自动包含该 Token)。 +- **`GET /health`**: 健康检查接口。返回服务器、已配置的 Gemini 客户端以及对话存储的状态。 +- **`GET /media/{filename}`**: 用于分发生成的媒体内容的内部接口。需要有效的 Token(API 返回的图片 URL 中已自动包含该 Token)。 ## Docker 部署 @@ -204,8 +204,10 @@ export CONFIG_STORAGE__MAX_SIZE=268435456 # 256 MB - `__Secure-1PSID` - `__Secure-1PSIDTS` -> [!TIP] -> 详细操作请参考 [HanaokaYuzu/Gemini-API 认证指南](https://github.com/HanaokaYuzu/Gemini-API?tab=readme-ov-file#authentication)。 +> [!IMPORTANT] +> **请开启 [Gemini Apps 应用活动](https://myactivity.google.com/product/gemini)** 以确保稳定的会话持久化。 +> +> 虽然在没有开启该设置的情况下,连续的聊天过程可能暂时正常,但任何瞬时错误、TLS 会话重启或服务器重启都可能导致 Google 端过期的会话元数据。如果该设置被禁用,模型将 **完全丢失多轮对话的上下文**,导致即使本地 LMDB 中存有历史记录,旧对话也将无法继续。 ### 代理设置 diff --git a/app/server/chat.py b/app/server/chat.py index 3190b19..e492c07 100644 --- a/app/server/chat.py +++ b/app/server/chat.py @@ -78,7 +78,6 @@ ) MAX_CHARS_PER_REQUEST = int(g_config.gemini.max_chars_per_request * 0.9) -METADATA_TTL_MINUTES = 60 router = APIRouter() @@ -972,24 +971,13 @@ async def _find_reusable_session( if search_history[-1].role in {"assistant", "system", "tool"}: try: if conv := db.find(model.model_name, search_history): - 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}/{len(messages)}. Client: {conv.client_id}" - ) - return session, client, remain - else: - logger.debug( - f"Matched conversation at length {search_end} is too old ({age_minutes:.1f}m), skipping reuse." - ) - else: - # Log that we tried this prefix but failed - pass + 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}/{len(messages)}. Client: {conv.client_id}" + ) + return session, client, remain except Exception as e: logger.warning( f"Error checking LMDB for reusable session at length {search_end}: {e}" @@ -1024,7 +1012,7 @@ async def _send_with_split( file_obj.name = "message.txt" try: final_files: list[Any] = list(files) if files else [] - final_files.append(file_obj) + final_files.insert(0, file_obj) instruction = ( "The user's input exceeds the character limit and is provided in the attached file `message.txt`.\n\n" "**System Instruction:**\n" diff --git a/pyproject.toml b/pyproject.toml index d5b96ff..35d3097 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -24,7 +24,7 @@ Repository = "https://github.com/Nativu5/Gemini-FastAPI" dev = [ "pytest>=9.0.2", "ruff>=0.15.6", - "ty>=0.0.22", + "ty>=0.0.23", ] [dependency-groups] diff --git a/uv.lock b/uv.lock index d1f1f66..820072e 100644 --- a/uv.lock +++ b/uv.lock @@ -454,26 +454,26 @@ wheels = [ [[package]] name = "ty" -version = "0.0.22" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f5/ee/b73c99daf598ae66a2d5d3ba6de7729d2152ab732dee7ccb8ab9446cc6d7/ty-0.0.22.tar.gz", hash = "sha256:391fc4d3a543950341b750d7f4aa94866a73e7cdbf3e9e4e4e8cfc8b7bef4f10", size = 5333861, upload-time = "2026-03-12T17:40:30.052Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/d7/f7/078f554f612723597f76cc6af70da4daed63ed721241a3f60788259c9adf/ty-0.0.22-py3-none-linux_armv6l.whl", hash = "sha256:03d37220d81016cb9d2a9c9ec11704d84f2df838f1dbf1296d91ea7fba57f8b5", size = 10328232, upload-time = "2026-03-12T17:40:19.402Z" }, - { url = "https://files.pythonhosted.org/packages/90/0b/4cfe84485d1b20bb50cdbc990f6e66b8c50cff569c7544adf0805b57ddb9/ty-0.0.22-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:3249c65b24829a312cd5cbf722ff5551ffe17b0a9781a8a372ca037d23aa1c71", size = 10148554, upload-time = "2026-03-12T17:40:25.586Z" }, - { url = "https://files.pythonhosted.org/packages/1b/7e/df31baf70d63880c9719d2cc8403b0b99c3c0d0f68f390a1109d9b231933/ty-0.0.22-py3-none-macosx_11_0_arm64.whl", hash = "sha256:470778f4335f1660f017fe2970afb7e4ce4f8b608795b19406976b8902b221a5", size = 9627910, upload-time = "2026-03-12T17:40:17.447Z" }, - { url = "https://files.pythonhosted.org/packages/99/0f/a418bcca9c87083533d6c73b65e56c6ade26b8d76a7558b3d3cc0f0eb52a/ty-0.0.22-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:75649b04b84ace92cb5c6e27013247f220f58a9a30b30eb2301992814deea0c4", size = 10155025, upload-time = "2026-03-12T17:40:21.344Z" }, - { url = "https://files.pythonhosted.org/packages/2d/3d/1974c567a58f369602065409d9109c0a81f5abbf1ae552433a89d07141a9/ty-0.0.22-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bc270f2344210cbed7d965ddeade61ffa81d93dffcdc0fded3540dccb860a9e1", size = 10133614, upload-time = "2026-03-12T17:40:23.549Z" }, - { url = "https://files.pythonhosted.org/packages/c0/c1/2da9e27c79a1fe9209589a73c989e416a7380bd77dcdf22960b3d30252bf/ty-0.0.22-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:548215b226f9726ea4d9beb77055363a8a398eb42809f042895f7a285afcb538", size = 10647101, upload-time = "2026-03-12T17:40:15.569Z" }, - { url = "https://files.pythonhosted.org/packages/c2/93/4e12c2f0ec792fd4ab9c9f70e59465d09345a453ebedb67d3bf99fd75a71/ty-0.0.22-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ed0bd1d34eba800b82ebee65269a85a9bbb2a325237e4baaf1413223f69e1899", size = 11231886, upload-time = "2026-03-12T17:40:06.875Z" }, - { url = "https://files.pythonhosted.org/packages/2a/9f/c255a078e4f2ce135497fffa4a5d3a122e4c49a00416fb78d72d7b79e119/ty-0.0.22-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:dbd429a31507da9a1b0873a21215113c42cc683aa5fba96c978794485db5560a", size = 10901527, upload-time = "2026-03-12T17:40:34.429Z" }, - { url = "https://files.pythonhosted.org/packages/f2/0d/d1bdee7e16d978ea929837fb03463efc116ee8ad05d215a5efd5d80e56d3/ty-0.0.22-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c7eb85a437b3be817796e7c0f84243611de53c7d4ea102a0dca179debfe7cec0", size = 10726505, upload-time = "2026-03-12T17:40:36.342Z" }, - { url = "https://files.pythonhosted.org/packages/a5/d4/6548d2a353f794582ec94d886b310589c70316fe43476a558e53073ea911/ty-0.0.22-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:b8e32e362e0666cc0769d2862a273def6b61117b8fbb9df493274d536afcd02e", size = 10128777, upload-time = "2026-03-12T17:40:38.517Z" }, - { url = "https://files.pythonhosted.org/packages/9f/d2/eb9185d3fe1fa12decb1c0a045416063bc40122187769b3dfb324da9e51c/ty-0.0.22-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:667deb1aaf802f396a626cc5a52cd55d935e8d0b46d1be068cf874f7d6f4bdb5", size = 10164992, upload-time = "2026-03-12T17:40:27.833Z" }, - { url = "https://files.pythonhosted.org/packages/7b/ec/067bb6d78cc6f5c4f55f0c3f760eb792b144697b454938fb9d10652caeb2/ty-0.0.22-py3-none-musllinux_1_2_i686.whl", hash = "sha256:e160681dbf602160e091d94a68207d1393733aedd95e3dc0b2d010bb39a70d78", size = 10342871, upload-time = "2026-03-12T17:40:13.447Z" }, - { url = "https://files.pythonhosted.org/packages/c0/04/dd3a87f54f78ceef5e6ab2add2f3bb85d45829318740f459886654b71a5d/ty-0.0.22-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:255598763079b80513d98084c4897df688e42666d7e4371349f97d258166389d", size = 10823909, upload-time = "2026-03-12T17:40:11.444Z" }, - { url = "https://files.pythonhosted.org/packages/d7/29/4b12e8ff99dec65487ec5342bd5b51fae1482e93a669d098777d55ca5eda/ty-0.0.22-py3-none-win32.whl", hash = "sha256:de0d88d9f788defddfec5507bf356bfc8b90ee301b7d6204f7609e7ac270276f", size = 9746013, upload-time = "2026-03-12T17:40:32.272Z" }, - { url = "https://files.pythonhosted.org/packages/84/16/e246795ed66ff8ee1a47497019f86ea1b4fb238bfca3068f2e08c52ef03b/ty-0.0.22-py3-none-win_amd64.whl", hash = "sha256:c216f750769ac9f3e9e61feabf3fd44c0697dce762bdcd105443d47e1a81c2b9", size = 10709350, upload-time = "2026-03-12T17:40:40.82Z" }, - { url = "https://files.pythonhosted.org/packages/3b/a4/5aafcebc4f597164381b0a82e7a8780d8f9f52df3884b16909a76282a0da/ty-0.0.22-py3-none-win_arm64.whl", hash = "sha256:49795260b9b9e3d6f04424f8ddb34907fac88c33a91b83478a74cead5dde567f", size = 10137248, upload-time = "2026-03-12T17:40:09.244Z" }, +version = "0.0.23" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/75/ba/d3c998ff4cf6b5d75b39356db55fe1b7caceecc522b9586174e6a5dee6f7/ty-0.0.23.tar.gz", hash = "sha256:5fb05db58f202af366f80ef70f806e48f5237807fe424ec787c9f289e3f3a4ef", size = 5341461, upload-time = "2026-03-13T12:34:23.125Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/21/aab32603dfdfacd4819e52fa8c6074e7bd578218a5142729452fc6a62db6/ty-0.0.23-py3-none-linux_armv6l.whl", hash = "sha256:e810eef1a5f1cfc0731a58af8d2f334906a96835829767aed00026f1334a8dd7", size = 10329096, upload-time = "2026-03-13T12:34:09.432Z" }, + { url = "https://files.pythonhosted.org/packages/9f/a9/dd3287a82dce3df546ec560296208d4905dcf06346b6e18c2f3c63523bd1/ty-0.0.23-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:e43d36bd89a151ddcad01acaeff7dcc507cb73ff164c1878d2d11549d39a061c", size = 10156631, upload-time = "2026-03-13T12:34:53.122Z" }, + { url = "https://files.pythonhosted.org/packages/0f/01/3f25909b02fac29bb0a62b2251f8d62e65d697781ffa4cf6b47a4c075c85/ty-0.0.23-py3-none-macosx_11_0_arm64.whl", hash = "sha256:bd6a340969577b4645f231572c4e46012acba2d10d4c0c6570fe1ab74e76ae00", size = 9653211, upload-time = "2026-03-13T12:34:15.049Z" }, + { url = "https://files.pythonhosted.org/packages/d5/60/bfc0479572a6f4b90501c869635faf8d84c8c68ffc5dd87d04f049affabc/ty-0.0.23-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:341441783e626eeb7b1ec2160432956aed5734932ab2d1c26f94d0c98b229937", size = 10156143, upload-time = "2026-03-13T12:34:34.468Z" }, + { url = "https://files.pythonhosted.org/packages/3a/81/8a93e923535a340f54bea20ff196f6b2787782b2f2f399bd191c4bc132d6/ty-0.0.23-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8ce1dc66c26d4167e2c78d12fa870ef5a7ec9cc344d2baaa6243297cfa88bd52", size = 10136632, upload-time = "2026-03-13T12:34:28.832Z" }, + { url = "https://files.pythonhosted.org/packages/da/cb/2ac81c850c58acc9f976814404d28389c9c1c939676e32287b9cff61381e/ty-0.0.23-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:bae1e7a294bf8528836f7617dc5c360ea2dddb63789fc9471ae6753534adca05", size = 10655025, upload-time = "2026-03-13T12:34:37.105Z" }, + { url = "https://files.pythonhosted.org/packages/b5/9b/bac771774c198c318ae699fc013d8cd99ed9caf993f661fba11238759244/ty-0.0.23-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d2b162768764d9dc177c83fb497a51532bb67cbebe57b8fa0f2668436bf53f3c", size = 11230107, upload-time = "2026-03-13T12:34:20.751Z" }, + { url = "https://files.pythonhosted.org/packages/14/09/7644fb0e297265e18243f878aca343593323b9bb19ed5278dcbc63781be0/ty-0.0.23-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d28384e48ca03b34e4e2beee0e230c39bbfb68994bb44927fec61ef3642900da", size = 10934177, upload-time = "2026-03-13T12:34:17.904Z" }, + { url = "https://files.pythonhosted.org/packages/18/14/69a25a0cad493fb6a947302471b579a03516a3b00e7bece77fdc6b4afb9b/ty-0.0.23-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:559d9a299df793cb7a7902caed5eda8a720ff69164c31c979673e928f02251ee", size = 10752487, upload-time = "2026-03-13T12:34:31.785Z" }, + { url = "https://files.pythonhosted.org/packages/9d/2a/42fc3cbccf95af0a62308ebed67e084798ab7a85ef073c9986ef18032743/ty-0.0.23-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:32a7b8a14a98e1d20a9d8d2af23637ed7efdb297ac1fa2450b8e465d05b94482", size = 10133007, upload-time = "2026-03-13T12:34:42.838Z" }, + { url = "https://files.pythonhosted.org/packages/e1/69/307833f1b52fa3670e0a1d496e43ef7df556ecde838192d3fcb9b35e360d/ty-0.0.23-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:6f803b9b9cca87af793467973b9abdd4b83e6b96d9b5e749d662cff7ead70b6d", size = 10169698, upload-time = "2026-03-13T12:34:12.351Z" }, + { url = "https://files.pythonhosted.org/packages/89/ae/5dd379ec22d0b1cba410d7af31c366fcedff191d5b867145913a64889f66/ty-0.0.23-py3-none-musllinux_1_2_i686.whl", hash = "sha256:4a0bf086ec8e2197b7ea7ebfcf4be36cb6a52b235f8be61647ef1b2d99d6ffd3", size = 10346080, upload-time = "2026-03-13T12:34:40.012Z" }, + { url = "https://files.pythonhosted.org/packages/98/c7/dfc83203d37998620bba9c4873a080c8850a784a8a46f56f8163c5b4e320/ty-0.0.23-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:252539c3fcd7aeb9b8d5c14e2040682c3e1d7ff640906d63fd2c4ce35865a4ba", size = 10848162, upload-time = "2026-03-13T12:34:45.421Z" }, + { url = "https://files.pythonhosted.org/packages/89/08/05481511cfbcc1fd834b6c67aaae090cb609a079189ddf2032139ccfc490/ty-0.0.23-py3-none-win32.whl", hash = "sha256:51b591d19eef23bbc3807aef77d38fa1f003c354e1da908aa80ea2dca0993f77", size = 9748283, upload-time = "2026-03-13T12:34:50.607Z" }, + { url = "https://files.pythonhosted.org/packages/31/2e/eaed4ff5c85e857a02415084c394e02c30476b65e158eec1938fdaa9a205/ty-0.0.23-py3-none-win_amd64.whl", hash = "sha256:1e137e955f05c501cfbb81dd2190c8fb7d01ec037c7e287024129c722a83c9ad", size = 10698355, upload-time = "2026-03-13T12:34:26.134Z" }, + { url = "https://files.pythonhosted.org/packages/91/29/b32cb7b4c7d56b9ed50117f8ad6e45834aec293e4cb14749daab4e9236d5/ty-0.0.23-py3-none-win_arm64.whl", hash = "sha256:a0399bd13fd2cd6683fd0a2d59b9355155d46546d8203e152c556ddbdeb20842", size = 10155890, upload-time = "2026-03-13T12:34:48.082Z" }, ] [[package]] From b929c69bc20ae48774ed2b8afa8f0c010447cd5f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C6=B0u=20Quang=20V=C5=A9?= Date: Sat, 14 Mar 2026 20:38:44 +0700 Subject: [PATCH 216/291] Implement a graceful shutdown to prevent losing cookies. --- app/main.py | 5 +++++ app/services/pool.py | 12 ++++++++++++ uv.lock | 6 +++--- 3 files changed, 20 insertions(+), 3 deletions(-) diff --git a/app/main.py b/app/main.py index 9782726..47e3956 100644 --- a/app/main.py +++ b/app/main.py @@ -76,6 +76,11 @@ async def lifespan(app: FastAPI): yield finally: cleanup_stop_event.set() + try: + await pool.close() + except Exception: + logger.exception("Failed to close Gemini client pool gracefully.") + try: await cleanup_task except asyncio.CancelledError: diff --git a/app/services/pool.py b/app/services/pool.py index 60ce5ee..847e79a 100644 --- a/app/services/pool.py +++ b/app/services/pool.py @@ -99,6 +99,18 @@ def clients(self) -> list[GeminiClientWrapper]: """Return managed clients.""" return self._clients + async def close(self) -> None: + """Close all clients in the pool.""" + if not self._clients: + return + + logger.info(f"Closing {len(self._clients)} Gemini clients...") + await asyncio.gather( + *(client.close() for client in self._clients if client.running()), + return_exceptions=True, + ) + logger.info("All Gemini clients closed.") + def status(self) -> dict[str, bool]: """Return running status for each client.""" return {client.id: client.running() for client in self._clients} diff --git a/uv.lock b/uv.lock index 820072e..421c419 100644 --- a/uv.lock +++ b/uv.lock @@ -165,7 +165,7 @@ requires-dist = [ { name = "pydantic-settings", extras = ["yaml"], specifier = ">=2.13.1" }, { name = "pytest", marker = "extra == 'dev'", specifier = ">=9.0.2" }, { name = "ruff", marker = "extra == 'dev'", specifier = ">=0.15.6" }, - { name = "ty", marker = "extra == 'dev'", specifier = ">=0.0.22" }, + { name = "ty", marker = "extra == 'dev'", specifier = ">=0.0.23" }, { name = "uvicorn", specifier = ">=0.41.0" }, { name = "uvloop", marker = "sys_platform != 'win32'", specifier = ">=0.22.1" }, ] @@ -176,8 +176,8 @@ dev = [{ name = "gemini-fastapi", extras = ["dev"] }] [[package]] name = "gemini-webapi" -version = "0.0.post293" -source = { git = "https://github.com/luuquangvu/Gemini-API.git?rev=move-httpx-to-curl_cffi#be8c783bbf89c94fbcebb76a3f556d1ad830bb12" } +version = "0.0.post295" +source = { git = "https://github.com/luuquangvu/Gemini-API.git?rev=move-httpx-to-curl_cffi#52b60b6b44b4b1386b9f8977afd916ce782384f1" } dependencies = [ { name = "curl-cffi" }, { name = "loguru" }, From 07811acf5ccd1556bc18650aca59a829a73e6d52 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C6=B0u=20Quang=20V=C5=A9?= Date: Tue, 17 Mar 2026 16:42:21 +0700 Subject: [PATCH 217/291] Add healthcheck to Dockerfile --- Dockerfile | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index 5c669c2..2499479 100644 --- a/Dockerfile +++ b/Dockerfile @@ -3,10 +3,14 @@ FROM ghcr.io/astral-sh/uv:python3.13-trixie-slim LABEL org.opencontainers.image.title="Gemini-FastAPI" \ org.opencontainers.image.description="Web-based Gemini models wrapped into an OpenAI-compatible API." +USER root + WORKDIR /app +SHELL ["/bin/bash", "-o", "pipefail", "-c"] + RUN apt-get update && apt-get install -y --no-install-recommends \ - tini git \ + tini curl ca-certificates git \ && rm -rf /var/lib/apt/lists/* ENV UV_COMPILE_BYTECODE=1 \ @@ -22,6 +26,9 @@ COPY run.py . EXPOSE 8000 +HEALTHCHECK --interval=30s --timeout=10s --start-period=300s --retries=3 \ + CMD curl -f http://localhost:8000/health || exit 1 + ENTRYPOINT ["/usr/bin/tini", "--"] CMD ["uv", "run", "--no-dev", "run.py"] From 24fdb6325da9f469e9c2f01953abdffaaff54dce Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C6=B0u=20Quang=20V=C5=A9?= Date: Tue, 17 Mar 2026 22:08:42 +0700 Subject: [PATCH 218/291] Fix a rare issue where whitespace leaks occur during a tool call while streaming. --- app/server/chat.py | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/app/server/chat.py b/app/server/chat.py index e492c07..9ef558a 100644 --- a/app/server/chat.py +++ b/app/server/chat.py @@ -947,7 +947,7 @@ async def _get_available_models(pool: GeminiClientPool) -> list[ModelData]: ModelData( id=am.id, created=now, - owned_by="gemini-web", + owned_by="google", ) ) seen_model_ids.add(am.id) @@ -1045,6 +1045,8 @@ def state(self): def _is_outputting(self) -> bool: """Determines if the current state allows yielding text to the stream.""" + if self.state == "POST_BLOCK": + return False return self.state == "NORMAL" or (self.state == "IN_BLOCK" and self.current_role != "tool") def process(self, chunk: str) -> str: @@ -1062,6 +1064,13 @@ def process(self, chunk: str) -> str: else: break + if self.state == "POST_BLOCK": + stripped = self.buffer.lstrip() + if not stripped: + break + self.buffer = stripped + self.stack[-1] = "NORMAL" + match = STREAM_MASTER_RE.search(self.buffer) if not match: tail_match = STREAM_TAIL_RE.search(self.buffer) @@ -1096,7 +1105,13 @@ def process(self, chunk: str) -> str: else: self.stack = ["NORMAL"] - if self.state == "NORMAL": + if self.state == "NORMAL" and matched_group in ( + "PROTOCOL_EXIT", + "HINT_EXIT", + ): + self.stack[-1] = "POST_BLOCK" + + if self.state in ("NORMAL", "POST_BLOCK"): self.current_role = "" self.buffer = self.buffer[end:] From aa4569e069cc411d17764e5510667475e2d8787a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C6=B0u=20Quang=20V=C5=A9?= Date: Wed, 18 Mar 2026 15:47:07 +0700 Subject: [PATCH 219/291] Update dependencies to latest versions --- pyproject.toml | 6 +++--- uv.lock | 32 ++++++++++++++++---------------- 2 files changed, 19 insertions(+), 19 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 35d3097..84e6b22 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -9,11 +9,11 @@ dependencies = [ "fastapi>=0.135.1", "gemini-webapi>=1.21.0", "httptools>=0.7.1", - "lmdb>=1.8.1", + "lmdb>=2.0.0", "loguru>=0.7.3", "orjson>=3.11.7", "pydantic-settings[yaml]>=2.13.1", - "uvicorn>=0.41.0", + "uvicorn>=0.42.0", "uvloop>=0.22.1; sys_platform != 'win32'", ] @@ -65,4 +65,4 @@ quote-style = "double" indent-style = "space" [tool.uv.sources] -gemini-webapi = { git = "https://github.com/luuquangvu/Gemini-API.git", rev = "move-httpx-to-curl_cffi" } +gemini-webapi = { git = "https://github.com/HanaokaYuzu/Gemini-API.git" } diff --git a/uv.lock b/uv.lock index 421c419..e30b63d 100644 --- a/uv.lock +++ b/uv.lock @@ -157,16 +157,16 @@ dev = [ requires-dist = [ { name = "curl-cffi", specifier = ">=0.14.0" }, { name = "fastapi", specifier = ">=0.135.1" }, - { name = "gemini-webapi", git = "https://github.com/luuquangvu/Gemini-API.git?rev=move-httpx-to-curl_cffi" }, + { name = "gemini-webapi", git = "https://github.com/HanaokaYuzu/Gemini-API.git" }, { name = "httptools", specifier = ">=0.7.1" }, - { name = "lmdb", specifier = ">=1.8.1" }, + { name = "lmdb", specifier = ">=2.0.0" }, { name = "loguru", specifier = ">=0.7.3" }, { name = "orjson", specifier = ">=3.11.7" }, { name = "pydantic-settings", extras = ["yaml"], specifier = ">=2.13.1" }, { name = "pytest", marker = "extra == 'dev'", specifier = ">=9.0.2" }, { name = "ruff", marker = "extra == 'dev'", specifier = ">=0.15.6" }, { name = "ty", marker = "extra == 'dev'", specifier = ">=0.0.23" }, - { name = "uvicorn", specifier = ">=0.41.0" }, + { name = "uvicorn", specifier = ">=0.42.0" }, { name = "uvloop", marker = "sys_platform != 'win32'", specifier = ">=0.22.1" }, ] provides-extras = ["dev"] @@ -176,8 +176,8 @@ dev = [{ name = "gemini-fastapi", extras = ["dev"] }] [[package]] name = "gemini-webapi" -version = "0.0.post295" -source = { git = "https://github.com/luuquangvu/Gemini-API.git?rev=move-httpx-to-curl_cffi#52b60b6b44b4b1386b9f8977afd916ce782384f1" } +version = "1.21.0.post4" +source = { git = "https://github.com/HanaokaYuzu/Gemini-API.git#37f3149d4d985af5100f20cecded0c257e8bbb36" } dependencies = [ { name = "curl-cffi" }, { name = "loguru" }, @@ -229,16 +229,16 @@ wheels = [ [[package]] name = "lmdb" -version = "1.8.1" +version = "2.0.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/23/19/392f028e7ebcc1cc8212fe8a315a909b7a556278456f0bab9234d3a3b665/lmdb-1.8.1.tar.gz", hash = "sha256:44ef24033929e9cc227a7e17287473c452b462d716f118db885c667c80f57429", size = 886349, upload-time = "2026-03-12T23:21:48.42Z" } +sdist = { url = "https://files.pythonhosted.org/packages/14/d7/c8b34be72e94a18512eccb8fb4611ba84621d57c4579cb7431454f0f9b4a/lmdb-2.0.0.tar.gz", hash = "sha256:3130b625004f6aed2cbda7ea70c41375bfe57b3b2389051eaa8fcef2ff18411c", size = 899492, upload-time = "2026-03-18T03:56:06.816Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/75/5f/ec2fe6bb0986fae28db80292ffe5146ed7cc4d478d683d67050d2691a538/lmdb-1.8.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8b197007a5762411b7484e533ed1d03dca1b8ba1eee390233ac6e62ff45bd417", size = 101865, upload-time = "2026-03-12T23:21:12.902Z" }, - { url = "https://files.pythonhosted.org/packages/75/35/1e43bba9658292c2ac787f5d003baa5ed37cad1b52666526edf4c908fb7d/lmdb-1.8.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a9454649e62bc6f4a45f9aed175fcdd6fc2e91922bf970fd561053c616281d0a", size = 100462, upload-time = "2026-03-12T23:21:14.369Z" }, - { url = "https://files.pythonhosted.org/packages/64/8e/2b1a0caa42b6f980a8b8663a272b9a52d4fd51ef0ca36cdec768cea02978/lmdb-1.8.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a51881d284116d82ead233b20ae7f6dbec8624dd7b0593a755c84e0d0bc4cc29", size = 303313, upload-time = "2026-03-12T23:21:15.636Z" }, - { url = "https://files.pythonhosted.org/packages/40/51/8061694cf7b883d2a166965cbaa961ea1ce692ce1782ac58091b5aa0fdb5/lmdb-1.8.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:04c5472bfc38377a2b32ae3b494d82d9c8db7c64e9053ca1b7c86aa862ebaaf9", size = 303895, upload-time = "2026-03-12T23:21:16.769Z" }, - { url = "https://files.pythonhosted.org/packages/7f/0f/c189c40d833ecd64f3e375a0bb02378110fc958916053ee687ec2c7d5079/lmdb-1.8.1-cp313-cp313-win_amd64.whl", hash = "sha256:fe0e34b2b20f47a108c3e04b397d1e27f080a7b0256c33efb5aef7bd1bccb923", size = 99707, upload-time = "2026-03-12T23:21:17.862Z" }, - { url = "https://files.pythonhosted.org/packages/6c/c3/3c87bede5b62163b768e6a4bca893f59d3996cb6fc4052bfd67847c0efd7/lmdb-1.8.1-cp313-cp313-win_arm64.whl", hash = "sha256:c3550849cdbaf0ead6265cb5b10134b223c2cede7ce7a1f3390a55975e3a06d4", size = 94605, upload-time = "2026-03-12T23:21:19.102Z" }, + { url = "https://files.pythonhosted.org/packages/e2/d8/69a1b30ef2ea79f46c9fb6b2658d2a86fb738e42213fb536f3086b4d8a5c/lmdb-2.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:35f05c02e3629a7003398cf657278b2e93e74d2a4c77d3ee2485b65f4b2ada9a", size = 108272, upload-time = "2026-03-18T03:55:39.851Z" }, + { url = "https://files.pythonhosted.org/packages/30/3d/f67c003ad3fc292c6f2a9215edb7f852b5ba643deeced7c377feecb5d764/lmdb-2.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:b5589e4d0c64be988129338299e3c1236838d056009c8ee215967f595ac32ac8", size = 107106, upload-time = "2026-03-18T03:55:41.125Z" }, + { url = "https://files.pythonhosted.org/packages/53/9a/974d36bcebee3309522748a5eb6d80dc1895ea1a7cb69f047c7ac041d77d/lmdb-2.0.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:47a6ad68d763de441377f0d20036c60d7a88ad10c5303408f77412225cddc4b3", size = 324018, upload-time = "2026-03-18T03:55:42.547Z" }, + { url = "https://files.pythonhosted.org/packages/1d/88/d3b959f44012a877d30f0d9a40bc93c17605fc53c7b10d8f64b97b0c959c/lmdb-2.0.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8dc7156fc503000f9f76b1393cecac48a2d9d95f945a677c47d340aefad871a4", size = 327333, upload-time = "2026-03-18T03:55:44.13Z" }, + { url = "https://files.pythonhosted.org/packages/cd/e1/c659167677a46b86a013bc625418ccbd3a9e88cb1804d8c3c3b316acf504/lmdb-2.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:88670fb86764d93454cb39ca1e8c08cb6dc2ef9b9d71f3f6ff9ef416d4202265", size = 104562, upload-time = "2026-03-18T03:55:45.758Z" }, + { url = "https://files.pythonhosted.org/packages/07/2f/8de426d45a42ef8c8cae4dd1953166f8539f7e7406970adea3cecae00649/lmdb-2.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:85a57eb3a85d231912819bcc8c75ed2baddba8f6d07ea79db62105a513ec2254", size = 98913, upload-time = "2026-03-18T03:55:47.013Z" }, ] [[package]] @@ -499,15 +499,15 @@ wheels = [ [[package]] name = "uvicorn" -version = "0.41.0" +version = "0.42.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "click" }, { name = "h11" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/32/ce/eeb58ae4ac36fe09e3842eb02e0eb676bf2c53ae062b98f1b2531673efdd/uvicorn-0.41.0.tar.gz", hash = "sha256:09d11cf7008da33113824ee5a1c6422d89fbc2ff476540d69a34c87fab8b571a", size = 82633, upload-time = "2026-02-16T23:07:24.1Z" } +sdist = { url = "https://files.pythonhosted.org/packages/e3/ad/4a96c425be6fb67e0621e62d86c402b4a17ab2be7f7c055d9bd2f638b9e2/uvicorn-0.42.0.tar.gz", hash = "sha256:9b1f190ce15a2dd22e7758651d9b6d12df09a13d51ba5bf4fc33c383a48e1775", size = 85393, upload-time = "2026-03-16T06:19:50.077Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/83/e4/d04a086285c20886c0daad0e026f250869201013d18f81d9ff5eada73a88/uvicorn-0.41.0-py3-none-any.whl", hash = "sha256:29e35b1d2c36a04b9e180d4007ede3bcb32a85fbdfd6c6aeb3f26839de088187", size = 68783, upload-time = "2026-02-16T23:07:22.357Z" }, + { url = "https://files.pythonhosted.org/packages/0a/89/f8827ccff89c1586027a105e5630ff6139a64da2515e24dafe860bd9ae4d/uvicorn-0.42.0-py3-none-any.whl", hash = "sha256:96c30f5c7abe6f74ae8900a70e92b85ad6613b745d4879eb9b16ccad15645359", size = 68830, upload-time = "2026-03-16T06:19:48.325Z" }, ] [[package]] From 3a68e96aa546ac3c14873428f215619f66b8c56e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C6=B0u=20Quang=20V=C5=A9?= Date: Wed, 18 Mar 2026 16:06:27 +0700 Subject: [PATCH 220/291] Remove unused code --- app/server/chat.py | 5 ----- 1 file changed, 5 deletions(-) diff --git a/app/server/chat.py b/app/server/chat.py index 9ef558a..ec31626 100644 --- a/app/server/chat.py +++ b/app/server/chat.py @@ -2365,11 +2365,6 @@ async def create_chat_completion( visible_output += media_markdown storage_output += media_markdown - if tool_calls: - logger.debug( - f"Detected tool calls: {reprlib.repr([tc.model_dump(mode='json') for tc in tool_calls])}" - ) - p_tok, c_tok, t_tok, r_tok = _calculate_usage( app_messages, storage_output, tool_calls, thoughts ) From 4c9f4bfb4a3b71272444d0d80ba0ffa82d72b102 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C6=B0u=20Quang=20V=C5=A9?= Date: Thu, 19 Mar 2026 15:52:56 +0700 Subject: [PATCH 221/291] Update dependencies to latest versions --- pyproject.toml | 2 +- uv.lock | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 84e6b22..fff8e63 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -65,4 +65,4 @@ quote-style = "double" indent-style = "space" [tool.uv.sources] -gemini-webapi = { git = "https://github.com/HanaokaYuzu/Gemini-API.git" } +gemini-webapi = { git = "https://github.com/luuquangvu/Gemini-API.git", rev = "retrieve-dynamic-models" } diff --git a/uv.lock b/uv.lock index e30b63d..b63e813 100644 --- a/uv.lock +++ b/uv.lock @@ -157,7 +157,7 @@ dev = [ requires-dist = [ { name = "curl-cffi", specifier = ">=0.14.0" }, { name = "fastapi", specifier = ">=0.135.1" }, - { name = "gemini-webapi", git = "https://github.com/HanaokaYuzu/Gemini-API.git" }, + { name = "gemini-webapi", git = "https://github.com/luuquangvu/Gemini-API.git?rev=retrieve-dynamic-models" }, { name = "httptools", specifier = ">=0.7.1" }, { name = "lmdb", specifier = ">=2.0.0" }, { name = "loguru", specifier = ">=0.7.3" }, @@ -176,8 +176,8 @@ dev = [{ name = "gemini-fastapi", extras = ["dev"] }] [[package]] name = "gemini-webapi" -version = "1.21.0.post4" -source = { git = "https://github.com/HanaokaYuzu/Gemini-API.git#37f3149d4d985af5100f20cecded0c257e8bbb36" } +version = "0.0.post219" +source = { git = "https://github.com/luuquangvu/Gemini-API.git?rev=retrieve-dynamic-models#1440fc7e2b5079daaecf3b37c9880f4f1bde6619" } dependencies = [ { name = "curl-cffi" }, { name = "loguru" }, From 49f11445e7b1167396f0a907e7de2728eea41e38 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C6=B0u=20Quang=20V=C5=A9?= Date: Thu, 19 Mar 2026 15:56:57 +0700 Subject: [PATCH 222/291] Update dependencies to latest versions --- pyproject.toml | 2 +- uv.lock | 18 +++++++++--------- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index fff8e63..a5a4ee9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -9,7 +9,7 @@ dependencies = [ "fastapi>=0.135.1", "gemini-webapi>=1.21.0", "httptools>=0.7.1", - "lmdb>=2.0.0", + "lmdb>=2.1.0", "loguru>=0.7.3", "orjson>=3.11.7", "pydantic-settings[yaml]>=2.13.1", diff --git a/uv.lock b/uv.lock index b63e813..bfa5668 100644 --- a/uv.lock +++ b/uv.lock @@ -159,7 +159,7 @@ requires-dist = [ { name = "fastapi", specifier = ">=0.135.1" }, { name = "gemini-webapi", git = "https://github.com/luuquangvu/Gemini-API.git?rev=retrieve-dynamic-models" }, { name = "httptools", specifier = ">=0.7.1" }, - { name = "lmdb", specifier = ">=2.0.0" }, + { name = "lmdb", specifier = ">=2.1.0" }, { name = "loguru", specifier = ">=0.7.3" }, { name = "orjson", specifier = ">=3.11.7" }, { name = "pydantic-settings", extras = ["yaml"], specifier = ">=2.13.1" }, @@ -229,16 +229,16 @@ wheels = [ [[package]] name = "lmdb" -version = "2.0.0" +version = "2.1.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/14/d7/c8b34be72e94a18512eccb8fb4611ba84621d57c4579cb7431454f0f9b4a/lmdb-2.0.0.tar.gz", hash = "sha256:3130b625004f6aed2cbda7ea70c41375bfe57b3b2389051eaa8fcef2ff18411c", size = 899492, upload-time = "2026-03-18T03:56:06.816Z" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/a9/b06257089086ceb1d10ba8c65c605c9063e34e7b65fa456d2f66bfb2fc1d/lmdb-2.1.0.tar.gz", hash = "sha256:812be2c49aeb191565425c4e010956017d7290bc9bc3f3c7e4950336b4ddb764", size = 912813, upload-time = "2026-03-19T04:58:51.399Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e2/d8/69a1b30ef2ea79f46c9fb6b2658d2a86fb738e42213fb536f3086b4d8a5c/lmdb-2.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:35f05c02e3629a7003398cf657278b2e93e74d2a4c77d3ee2485b65f4b2ada9a", size = 108272, upload-time = "2026-03-18T03:55:39.851Z" }, - { url = "https://files.pythonhosted.org/packages/30/3d/f67c003ad3fc292c6f2a9215edb7f852b5ba643deeced7c377feecb5d764/lmdb-2.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:b5589e4d0c64be988129338299e3c1236838d056009c8ee215967f595ac32ac8", size = 107106, upload-time = "2026-03-18T03:55:41.125Z" }, - { url = "https://files.pythonhosted.org/packages/53/9a/974d36bcebee3309522748a5eb6d80dc1895ea1a7cb69f047c7ac041d77d/lmdb-2.0.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:47a6ad68d763de441377f0d20036c60d7a88ad10c5303408f77412225cddc4b3", size = 324018, upload-time = "2026-03-18T03:55:42.547Z" }, - { url = "https://files.pythonhosted.org/packages/1d/88/d3b959f44012a877d30f0d9a40bc93c17605fc53c7b10d8f64b97b0c959c/lmdb-2.0.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8dc7156fc503000f9f76b1393cecac48a2d9d95f945a677c47d340aefad871a4", size = 327333, upload-time = "2026-03-18T03:55:44.13Z" }, - { url = "https://files.pythonhosted.org/packages/cd/e1/c659167677a46b86a013bc625418ccbd3a9e88cb1804d8c3c3b316acf504/lmdb-2.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:88670fb86764d93454cb39ca1e8c08cb6dc2ef9b9d71f3f6ff9ef416d4202265", size = 104562, upload-time = "2026-03-18T03:55:45.758Z" }, - { url = "https://files.pythonhosted.org/packages/07/2f/8de426d45a42ef8c8cae4dd1953166f8539f7e7406970adea3cecae00649/lmdb-2.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:85a57eb3a85d231912819bcc8c75ed2baddba8f6d07ea79db62105a513ec2254", size = 98913, upload-time = "2026-03-18T03:55:47.013Z" }, + { url = "https://files.pythonhosted.org/packages/91/92/8357de991821250e75ddafe32476c6c8e66fe773cda073ed020dbd1797e4/lmdb-2.1.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:d3284f7b6f98ddecc4d126218b0509134cb36c167c0c104d0458521ca954c342", size = 108997, upload-time = "2026-03-19T04:58:26.566Z" }, + { url = "https://files.pythonhosted.org/packages/9a/21/aa5f034479bc844cd1d3db606c01177b69f64ff6436061bcde2bcb88111a/lmdb-2.1.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:90a7d1caffd5cbdbecac88b598dace8dd038ac2c2b805c9faa0984a0b17bf997", size = 107790, upload-time = "2026-03-19T04:58:27.729Z" }, + { url = "https://files.pythonhosted.org/packages/f5/d9/94977566909c81c5e2407f546e4e179a63ef6417a749095d3c7682f71d76/lmdb-2.1.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6fb2c908b30a4f63e5f15c158904ad6e8f474b5cf90bc08789599a5aa2d01b6b", size = 324669, upload-time = "2026-03-19T04:58:28.92Z" }, + { url = "https://files.pythonhosted.org/packages/b8/42/08f14c83529a42456b26ab5be1ab332688f9bc36d38ccaf231c989ea41da/lmdb-2.1.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d48538aae010686a9dfabd1c0a976a0836c5044b8869249d05869ebd14c32639", size = 327653, upload-time = "2026-03-19T04:58:30.395Z" }, + { url = "https://files.pythonhosted.org/packages/cc/18/42a90551d15bc3435e0258bb65b32a4d75f03a017cf1ac164c614f811664/lmdb-2.1.0-cp313-cp313-win_amd64.whl", hash = "sha256:52df6033da9a5e74cb407460d1a122958c437d0d142f1286bb238c2f550f03fb", size = 104803, upload-time = "2026-03-19T04:58:32.067Z" }, + { url = "https://files.pythonhosted.org/packages/ad/f7/60db1dbdf56e8e8c56674682d460d1e64a797c5c24abd2de9e5ebeb7c544/lmdb-2.1.0-cp313-cp313-win_arm64.whl", hash = "sha256:170e40658d26730ccea9735cb0f73b9255b1a5c0f83d68e5f3f2f205d8a9e238", size = 99084, upload-time = "2026-03-19T04:58:33.437Z" }, ] [[package]] From db9065943c9ace5c7be7e1096ec3aad5a8d38bee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C6=B0u=20Quang=20V=C5=A9?= Date: Thu, 19 Mar 2026 16:26:28 +0700 Subject: [PATCH 223/291] Update dependencies to latest versions --- uv.lock | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/uv.lock b/uv.lock index bfa5668..f8f9021 100644 --- a/uv.lock +++ b/uv.lock @@ -176,8 +176,8 @@ dev = [{ name = "gemini-fastapi", extras = ["dev"] }] [[package]] name = "gemini-webapi" -version = "0.0.post219" -source = { git = "https://github.com/luuquangvu/Gemini-API.git?rev=retrieve-dynamic-models#1440fc7e2b5079daaecf3b37c9880f4f1bde6619" } +version = "0.0.post220" +source = { git = "https://github.com/luuquangvu/Gemini-API.git?rev=retrieve-dynamic-models#0477c51f5cc5ecc4aac9e5c11943d15974f4f24a" } dependencies = [ { name = "curl-cffi" }, { name = "loguru" }, From d32ee022bb98395d65f40693510747b795530bee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C6=B0u=20Quang=20V=C5=A9?= Date: Thu, 19 Mar 2026 17:25:51 +0700 Subject: [PATCH 224/291] Update dependencies to latest versions --- uv.lock | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/uv.lock b/uv.lock index f8f9021..edfdd23 100644 --- a/uv.lock +++ b/uv.lock @@ -176,8 +176,8 @@ dev = [{ name = "gemini-fastapi", extras = ["dev"] }] [[package]] name = "gemini-webapi" -version = "0.0.post220" -source = { git = "https://github.com/luuquangvu/Gemini-API.git?rev=retrieve-dynamic-models#0477c51f5cc5ecc4aac9e5c11943d15974f4f24a" } +version = "0.0.post221" +source = { git = "https://github.com/luuquangvu/Gemini-API.git?rev=retrieve-dynamic-models#00b1b72b3b43cb0fad1be9be7316d63cc78bfc12" } dependencies = [ { name = "curl-cffi" }, { name = "loguru" }, From 08d5e1d7d602d11a786e7696f32c88ac31e50603 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C6=B0u=20Quang=20V=C5=A9?= Date: Thu, 19 Mar 2026 17:35:50 +0700 Subject: [PATCH 225/291] Update dependencies to latest versions --- uv.lock | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/uv.lock b/uv.lock index edfdd23..21798c1 100644 --- a/uv.lock +++ b/uv.lock @@ -176,8 +176,8 @@ dev = [{ name = "gemini-fastapi", extras = ["dev"] }] [[package]] name = "gemini-webapi" -version = "0.0.post221" -source = { git = "https://github.com/luuquangvu/Gemini-API.git?rev=retrieve-dynamic-models#00b1b72b3b43cb0fad1be9be7316d63cc78bfc12" } +version = "0.0.post222" +source = { git = "https://github.com/luuquangvu/Gemini-API.git?rev=retrieve-dynamic-models#ac1f04e7da2281b988f6fbef1aa73252e28c4b23" } dependencies = [ { name = "curl-cffi" }, { name = "loguru" }, From 5041132ff08e064daaffaedfab2b094eabcbb5d4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C6=B0u=20Quang=20V=C5=A9?= Date: Thu, 19 Mar 2026 18:06:40 +0700 Subject: [PATCH 226/291] Temporarily downgrade lmdb to version 1.x https://github.com/jnwatson/py-lmdb/issues/431 --- pyproject.toml | 2 +- uv.lock | 18 +++++++++--------- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index a5a4ee9..c81b70b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -9,7 +9,7 @@ dependencies = [ "fastapi>=0.135.1", "gemini-webapi>=1.21.0", "httptools>=0.7.1", - "lmdb>=2.1.0", + "lmdb~=1.8.1", "loguru>=0.7.3", "orjson>=3.11.7", "pydantic-settings[yaml]>=2.13.1", diff --git a/uv.lock b/uv.lock index 21798c1..50bac58 100644 --- a/uv.lock +++ b/uv.lock @@ -159,7 +159,7 @@ requires-dist = [ { name = "fastapi", specifier = ">=0.135.1" }, { name = "gemini-webapi", git = "https://github.com/luuquangvu/Gemini-API.git?rev=retrieve-dynamic-models" }, { name = "httptools", specifier = ">=0.7.1" }, - { name = "lmdb", specifier = ">=2.1.0" }, + { name = "lmdb", specifier = "~=1.8.1" }, { name = "loguru", specifier = ">=0.7.3" }, { name = "orjson", specifier = ">=3.11.7" }, { name = "pydantic-settings", extras = ["yaml"], specifier = ">=2.13.1" }, @@ -229,16 +229,16 @@ wheels = [ [[package]] name = "lmdb" -version = "2.1.0" +version = "1.8.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/d8/a9/b06257089086ceb1d10ba8c65c605c9063e34e7b65fa456d2f66bfb2fc1d/lmdb-2.1.0.tar.gz", hash = "sha256:812be2c49aeb191565425c4e010956017d7290bc9bc3f3c7e4950336b4ddb764", size = 912813, upload-time = "2026-03-19T04:58:51.399Z" } +sdist = { url = "https://files.pythonhosted.org/packages/23/19/392f028e7ebcc1cc8212fe8a315a909b7a556278456f0bab9234d3a3b665/lmdb-1.8.1.tar.gz", hash = "sha256:44ef24033929e9cc227a7e17287473c452b462d716f118db885c667c80f57429", size = 886349, upload-time = "2026-03-12T23:21:48.42Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/91/92/8357de991821250e75ddafe32476c6c8e66fe773cda073ed020dbd1797e4/lmdb-2.1.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:d3284f7b6f98ddecc4d126218b0509134cb36c167c0c104d0458521ca954c342", size = 108997, upload-time = "2026-03-19T04:58:26.566Z" }, - { url = "https://files.pythonhosted.org/packages/9a/21/aa5f034479bc844cd1d3db606c01177b69f64ff6436061bcde2bcb88111a/lmdb-2.1.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:90a7d1caffd5cbdbecac88b598dace8dd038ac2c2b805c9faa0984a0b17bf997", size = 107790, upload-time = "2026-03-19T04:58:27.729Z" }, - { url = "https://files.pythonhosted.org/packages/f5/d9/94977566909c81c5e2407f546e4e179a63ef6417a749095d3c7682f71d76/lmdb-2.1.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6fb2c908b30a4f63e5f15c158904ad6e8f474b5cf90bc08789599a5aa2d01b6b", size = 324669, upload-time = "2026-03-19T04:58:28.92Z" }, - { url = "https://files.pythonhosted.org/packages/b8/42/08f14c83529a42456b26ab5be1ab332688f9bc36d38ccaf231c989ea41da/lmdb-2.1.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d48538aae010686a9dfabd1c0a976a0836c5044b8869249d05869ebd14c32639", size = 327653, upload-time = "2026-03-19T04:58:30.395Z" }, - { url = "https://files.pythonhosted.org/packages/cc/18/42a90551d15bc3435e0258bb65b32a4d75f03a017cf1ac164c614f811664/lmdb-2.1.0-cp313-cp313-win_amd64.whl", hash = "sha256:52df6033da9a5e74cb407460d1a122958c437d0d142f1286bb238c2f550f03fb", size = 104803, upload-time = "2026-03-19T04:58:32.067Z" }, - { url = "https://files.pythonhosted.org/packages/ad/f7/60db1dbdf56e8e8c56674682d460d1e64a797c5c24abd2de9e5ebeb7c544/lmdb-2.1.0-cp313-cp313-win_arm64.whl", hash = "sha256:170e40658d26730ccea9735cb0f73b9255b1a5c0f83d68e5f3f2f205d8a9e238", size = 99084, upload-time = "2026-03-19T04:58:33.437Z" }, + { url = "https://files.pythonhosted.org/packages/75/5f/ec2fe6bb0986fae28db80292ffe5146ed7cc4d478d683d67050d2691a538/lmdb-1.8.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8b197007a5762411b7484e533ed1d03dca1b8ba1eee390233ac6e62ff45bd417", size = 101865, upload-time = "2026-03-12T23:21:12.902Z" }, + { url = "https://files.pythonhosted.org/packages/75/35/1e43bba9658292c2ac787f5d003baa5ed37cad1b52666526edf4c908fb7d/lmdb-1.8.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a9454649e62bc6f4a45f9aed175fcdd6fc2e91922bf970fd561053c616281d0a", size = 100462, upload-time = "2026-03-12T23:21:14.369Z" }, + { url = "https://files.pythonhosted.org/packages/64/8e/2b1a0caa42b6f980a8b8663a272b9a52d4fd51ef0ca36cdec768cea02978/lmdb-1.8.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a51881d284116d82ead233b20ae7f6dbec8624dd7b0593a755c84e0d0bc4cc29", size = 303313, upload-time = "2026-03-12T23:21:15.636Z" }, + { url = "https://files.pythonhosted.org/packages/40/51/8061694cf7b883d2a166965cbaa961ea1ce692ce1782ac58091b5aa0fdb5/lmdb-1.8.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:04c5472bfc38377a2b32ae3b494d82d9c8db7c64e9053ca1b7c86aa862ebaaf9", size = 303895, upload-time = "2026-03-12T23:21:16.769Z" }, + { url = "https://files.pythonhosted.org/packages/7f/0f/c189c40d833ecd64f3e375a0bb02378110fc958916053ee687ec2c7d5079/lmdb-1.8.1-cp313-cp313-win_amd64.whl", hash = "sha256:fe0e34b2b20f47a108c3e04b397d1e27f080a7b0256c33efb5aef7bd1bccb923", size = 99707, upload-time = "2026-03-12T23:21:17.862Z" }, + { url = "https://files.pythonhosted.org/packages/6c/c3/3c87bede5b62163b768e6a4bca893f59d3996cb6fc4052bfd67847c0efd7/lmdb-1.8.1-cp313-cp313-win_arm64.whl", hash = "sha256:c3550849cdbaf0ead6265cb5b10134b223c2cede7ce7a1f3390a55975e3a06d4", size = 94605, upload-time = "2026-03-12T23:21:19.102Z" }, ] [[package]] From 5829ac01145ae39138541e70d5eaea1d228ee02a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C6=B0u=20Quang=20V=C5=A9?= Date: Thu, 19 Mar 2026 18:28:22 +0700 Subject: [PATCH 227/291] Update dependencies to latest versions --- uv.lock | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/uv.lock b/uv.lock index 50bac58..2c09798 100644 --- a/uv.lock +++ b/uv.lock @@ -176,8 +176,8 @@ dev = [{ name = "gemini-fastapi", extras = ["dev"] }] [[package]] name = "gemini-webapi" -version = "0.0.post222" -source = { git = "https://github.com/luuquangvu/Gemini-API.git?rev=retrieve-dynamic-models#ac1f04e7da2281b988f6fbef1aa73252e28c4b23" } +version = "0.0.post223" +source = { git = "https://github.com/luuquangvu/Gemini-API.git?rev=retrieve-dynamic-models#201b6562e8d8e4049c2c9abe13ad2790ca72420a" } dependencies = [ { name = "curl-cffi" }, { name = "loguru" }, From 6a0f99b0ea6c444914c2f7a117c8bc39d23e47e5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C6=B0u=20Quang=20V=C5=A9?= Date: Thu, 19 Mar 2026 19:24:27 +0700 Subject: [PATCH 228/291] Update dependencies to latest versions --- uv.lock | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/uv.lock b/uv.lock index 2c09798..8568ac2 100644 --- a/uv.lock +++ b/uv.lock @@ -176,8 +176,8 @@ dev = [{ name = "gemini-fastapi", extras = ["dev"] }] [[package]] name = "gemini-webapi" -version = "0.0.post223" -source = { git = "https://github.com/luuquangvu/Gemini-API.git?rev=retrieve-dynamic-models#201b6562e8d8e4049c2c9abe13ad2790ca72420a" } +version = "0.0.post224" +source = { git = "https://github.com/luuquangvu/Gemini-API.git?rev=retrieve-dynamic-models#40d2cb47ee8586c9ba5afb1a12a3be78454b3bbb" } dependencies = [ { name = "curl-cffi" }, { name = "loguru" }, From 7abc979e64530a73b1ad3df259e6c2e2c3081b0f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C6=B0u=20Quang=20V=C5=A9?= Date: Fri, 20 Mar 2026 09:41:21 +0700 Subject: [PATCH 229/291] Update dependencies to latest versions --- app/server/chat.py | 9 ++-- pyproject.toml | 6 +-- uv.lock | 108 ++++++++++++++++++++++----------------------- 3 files changed, 62 insertions(+), 61 deletions(-) diff --git a/app/server/chat.py b/app/server/chat.py index ec31626..9df91ca 100644 --- a/app/server/chat.py +++ b/app/server/chat.py @@ -941,16 +941,17 @@ async def _get_available_models(pool: GeminiClientPool) -> list[ModelData]: client_models = client.list_models() if client_models: - for am in client_models: - if am.id and am.id not in seen_model_ids: + for model in client_models: + model_id = model.model_name if model.model_name else model.model_id + if model_id and model_id not in seen_model_ids: models_data.append( ModelData( - id=am.id, + id=model_id, created=now, owned_by="google", ) ) - seen_model_ids.add(am.id) + seen_model_ids.add(model_id) return models_data diff --git a/pyproject.toml b/pyproject.toml index c81b70b..206eddc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -9,7 +9,7 @@ dependencies = [ "fastapi>=0.135.1", "gemini-webapi>=1.21.0", "httptools>=0.7.1", - "lmdb~=1.8.1", + "lmdb>=2.1.1", "loguru>=0.7.3", "orjson>=3.11.7", "pydantic-settings[yaml]>=2.13.1", @@ -23,8 +23,8 @@ Repository = "https://github.com/Nativu5/Gemini-FastAPI" [project.optional-dependencies] dev = [ "pytest>=9.0.2", - "ruff>=0.15.6", - "ty>=0.0.23", + "ruff>=0.15.7", + "ty>=0.0.24", ] [dependency-groups] diff --git a/uv.lock b/uv.lock index 8568ac2..00eba05 100644 --- a/uv.lock +++ b/uv.lock @@ -159,13 +159,13 @@ requires-dist = [ { name = "fastapi", specifier = ">=0.135.1" }, { name = "gemini-webapi", git = "https://github.com/luuquangvu/Gemini-API.git?rev=retrieve-dynamic-models" }, { name = "httptools", specifier = ">=0.7.1" }, - { name = "lmdb", specifier = "~=1.8.1" }, + { name = "lmdb", specifier = ">=2.1.1" }, { name = "loguru", specifier = ">=0.7.3" }, { name = "orjson", specifier = ">=3.11.7" }, { name = "pydantic-settings", extras = ["yaml"], specifier = ">=2.13.1" }, { name = "pytest", marker = "extra == 'dev'", specifier = ">=9.0.2" }, - { name = "ruff", marker = "extra == 'dev'", specifier = ">=0.15.6" }, - { name = "ty", marker = "extra == 'dev'", specifier = ">=0.0.23" }, + { name = "ruff", marker = "extra == 'dev'", specifier = ">=0.15.7" }, + { name = "ty", marker = "extra == 'dev'", specifier = ">=0.0.24" }, { name = "uvicorn", specifier = ">=0.42.0" }, { name = "uvloop", marker = "sys_platform != 'win32'", specifier = ">=0.22.1" }, ] @@ -176,8 +176,8 @@ dev = [{ name = "gemini-fastapi", extras = ["dev"] }] [[package]] name = "gemini-webapi" -version = "0.0.post224" -source = { git = "https://github.com/luuquangvu/Gemini-API.git?rev=retrieve-dynamic-models#40d2cb47ee8586c9ba5afb1a12a3be78454b3bbb" } +version = "0.0.post227" +source = { git = "https://github.com/luuquangvu/Gemini-API.git?rev=retrieve-dynamic-models#e53ea40be2896a882355bafae5f8d20cd373bcf9" } dependencies = [ { name = "curl-cffi" }, { name = "loguru" }, @@ -229,16 +229,16 @@ wheels = [ [[package]] name = "lmdb" -version = "1.8.1" +version = "2.1.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/23/19/392f028e7ebcc1cc8212fe8a315a909b7a556278456f0bab9234d3a3b665/lmdb-1.8.1.tar.gz", hash = "sha256:44ef24033929e9cc227a7e17287473c452b462d716f118db885c667c80f57429", size = 886349, upload-time = "2026-03-12T23:21:48.42Z" } +sdist = { url = "https://files.pythonhosted.org/packages/5a/4e/d78a06af228216102fcb4b703f4b6a2f565978224d5623e20e02d90aeed3/lmdb-2.1.1.tar.gz", hash = "sha256:0317062326ae2f66e3bbde6400907651ea58c27a250097511a28d13c467fa5f1", size = 913160, upload-time = "2026-03-19T13:56:26.139Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/75/5f/ec2fe6bb0986fae28db80292ffe5146ed7cc4d478d683d67050d2691a538/lmdb-1.8.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8b197007a5762411b7484e533ed1d03dca1b8ba1eee390233ac6e62ff45bd417", size = 101865, upload-time = "2026-03-12T23:21:12.902Z" }, - { url = "https://files.pythonhosted.org/packages/75/35/1e43bba9658292c2ac787f5d003baa5ed37cad1b52666526edf4c908fb7d/lmdb-1.8.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a9454649e62bc6f4a45f9aed175fcdd6fc2e91922bf970fd561053c616281d0a", size = 100462, upload-time = "2026-03-12T23:21:14.369Z" }, - { url = "https://files.pythonhosted.org/packages/64/8e/2b1a0caa42b6f980a8b8663a272b9a52d4fd51ef0ca36cdec768cea02978/lmdb-1.8.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a51881d284116d82ead233b20ae7f6dbec8624dd7b0593a755c84e0d0bc4cc29", size = 303313, upload-time = "2026-03-12T23:21:15.636Z" }, - { url = "https://files.pythonhosted.org/packages/40/51/8061694cf7b883d2a166965cbaa961ea1ce692ce1782ac58091b5aa0fdb5/lmdb-1.8.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:04c5472bfc38377a2b32ae3b494d82d9c8db7c64e9053ca1b7c86aa862ebaaf9", size = 303895, upload-time = "2026-03-12T23:21:16.769Z" }, - { url = "https://files.pythonhosted.org/packages/7f/0f/c189c40d833ecd64f3e375a0bb02378110fc958916053ee687ec2c7d5079/lmdb-1.8.1-cp313-cp313-win_amd64.whl", hash = "sha256:fe0e34b2b20f47a108c3e04b397d1e27f080a7b0256c33efb5aef7bd1bccb923", size = 99707, upload-time = "2026-03-12T23:21:17.862Z" }, - { url = "https://files.pythonhosted.org/packages/6c/c3/3c87bede5b62163b768e6a4bca893f59d3996cb6fc4052bfd67847c0efd7/lmdb-1.8.1-cp313-cp313-win_arm64.whl", hash = "sha256:c3550849cdbaf0ead6265cb5b10134b223c2cede7ce7a1f3390a55975e3a06d4", size = 94605, upload-time = "2026-03-12T23:21:19.102Z" }, + { url = "https://files.pythonhosted.org/packages/af/64/0ad40c3d06f89d7e5b6894537a0bd2829b592ef191f61b704157ad641f33/lmdb-2.1.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f43f7a7907fa726597d235dd83febe7ec47b45b78952b848f0bdf50b58fe1bf1", size = 109123, upload-time = "2026-03-19T13:55:58.522Z" }, + { url = "https://files.pythonhosted.org/packages/a6/62/86257b169acc61282110166581167f099c08bbb76aea7bc4da0c0b64c8b2/lmdb-2.1.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:24382db5cd139866d222471f4fb14fb511b16bf1ed0686bdf7fc808bf07ed8a4", size = 107803, upload-time = "2026-03-19T13:55:59.907Z" }, + { url = "https://files.pythonhosted.org/packages/26/2d/8889fa81eb232dd5fec10f3178e22f3ae4f385c46be6124e29709f3bfdbb/lmdb-2.1.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:76e8c14a76bb7695c1778181dce9c352fb565b5e113c74981ec692d5d6820efb", size = 324703, upload-time = "2026-03-19T13:56:01.309Z" }, + { url = "https://files.pythonhosted.org/packages/dc/d9/ec2e2370d35214e12abd1c9dada369c460e694f0c6fe385a200a2a25eaf3/lmdb-2.1.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7700999c4fa7762577d4b3deedd48f6c25ce396dfb17f61dd48f50dcf99f78d6", size = 328101, upload-time = "2026-03-19T13:56:02.806Z" }, + { url = "https://files.pythonhosted.org/packages/24/c7/e65ca28f46479e92dfc7250dab5259ae6eaa0e5075db47f52a4a1462adb1/lmdb-2.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:74e4442102423e185347108cc67933411ec13e41866f57f6e9868c6ef5642b88", size = 104800, upload-time = "2026-03-19T13:56:04.173Z" }, + { url = "https://files.pythonhosted.org/packages/33/51/e8f12e4b7a0ef82b42d8a37201db99f8dd7d26113a6b0cbf5c441692e2ad/lmdb-2.1.1-cp313-cp313-win_arm64.whl", hash = "sha256:26b0412a38fffd8587becde3c2b0ee606b8906ae0ee5060b0ed6d44610703dec", size = 99048, upload-time = "2026-03-19T13:56:05.499Z" }, ] [[package]] @@ -417,27 +417,27 @@ wheels = [ [[package]] name = "ruff" -version = "0.15.6" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/51/df/f8629c19c5318601d3121e230f74cbee7a3732339c52b21daa2b82ef9c7d/ruff-0.15.6.tar.gz", hash = "sha256:8394c7bb153a4e3811a4ecdacd4a8e6a4fa8097028119160dffecdcdf9b56ae4", size = 4597916, upload-time = "2026-03-12T23:05:47.51Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/9e/2f/4e03a7e5ce99b517e98d3b4951f411de2b0fa8348d39cf446671adcce9a2/ruff-0.15.6-py3-none-linux_armv6l.whl", hash = "sha256:7c98c3b16407b2cf3d0f2b80c80187384bc92c6774d85fefa913ecd941256fff", size = 10508953, upload-time = "2026-03-12T23:05:17.246Z" }, - { url = "https://files.pythonhosted.org/packages/70/60/55bcdc3e9f80bcf39edf0cd272da6fa511a3d94d5a0dd9e0adf76ceebdb4/ruff-0.15.6-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:ee7dcfaad8b282a284df4aa6ddc2741b3f4a18b0555d626805555a820ea181c3", size = 10942257, upload-time = "2026-03-12T23:05:23.076Z" }, - { url = "https://files.pythonhosted.org/packages/e7/f9/005c29bd1726c0f492bfa215e95154cf480574140cb5f867c797c18c790b/ruff-0.15.6-py3-none-macosx_11_0_arm64.whl", hash = "sha256:3bd9967851a25f038fc8b9ae88a7fbd1b609f30349231dffaa37b6804923c4bb", size = 10322683, upload-time = "2026-03-12T23:05:33.738Z" }, - { url = "https://files.pythonhosted.org/packages/5f/74/2f861f5fd7cbb2146bddb5501450300ce41562da36d21868c69b7a828169/ruff-0.15.6-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:13f4594b04e42cd24a41da653886b04d2ff87adbf57497ed4f728b0e8a4866f8", size = 10660986, upload-time = "2026-03-12T23:05:53.245Z" }, - { url = "https://files.pythonhosted.org/packages/c1/a1/309f2364a424eccb763cdafc49df843c282609f47fe53aa83f38272389e0/ruff-0.15.6-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e2ed8aea2f3fe57886d3f00ea5b8aae5bf68d5e195f487f037a955ff9fbaac9e", size = 10332177, upload-time = "2026-03-12T23:05:56.145Z" }, - { url = "https://files.pythonhosted.org/packages/30/41/7ebf1d32658b4bab20f8ac80972fb19cd4e2c6b78552be263a680edc55ac/ruff-0.15.6-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:70789d3e7830b848b548aae96766431c0dc01a6c78c13381f423bf7076c66d15", size = 11170783, upload-time = "2026-03-12T23:06:01.742Z" }, - { url = "https://files.pythonhosted.org/packages/76/be/6d488f6adca047df82cd62c304638bcb00821c36bd4881cfca221561fdfc/ruff-0.15.6-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:542aaf1de3154cea088ced5a819ce872611256ffe2498e750bbae5247a8114e9", size = 12044201, upload-time = "2026-03-12T23:05:28.697Z" }, - { url = "https://files.pythonhosted.org/packages/71/68/e6f125df4af7e6d0b498f8d373274794bc5156b324e8ab4bf5c1b4fc0ec7/ruff-0.15.6-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1c22e6f02c16cfac3888aa636e9eba857254d15bbacc9906c9689fdecb1953ab", size = 11421561, upload-time = "2026-03-12T23:05:31.236Z" }, - { url = "https://files.pythonhosted.org/packages/f1/9f/f85ef5fd01a52e0b472b26dc1b4bd228b8f6f0435975442ffa4741278703/ruff-0.15.6-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:98893c4c0aadc8e448cfa315bd0cc343a5323d740fe5f28ef8a3f9e21b381f7e", size = 11310928, upload-time = "2026-03-12T23:05:45.288Z" }, - { url = "https://files.pythonhosted.org/packages/8c/26/b75f8c421f5654304b89471ed384ae8c7f42b4dff58fa6ce1626d7f2b59a/ruff-0.15.6-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:70d263770d234912374493e8cc1e7385c5d49376e41dfa51c5c3453169dc581c", size = 11235186, upload-time = "2026-03-12T23:05:50.677Z" }, - { url = "https://files.pythonhosted.org/packages/fc/d4/d5a6d065962ff7a68a86c9b4f5500f7d101a0792078de636526c0edd40da/ruff-0.15.6-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:55a1ad63c5a6e54b1f21b7514dfadc0c7fb40093fa22e95143cf3f64ebdcd512", size = 10635231, upload-time = "2026-03-12T23:05:37.044Z" }, - { url = "https://files.pythonhosted.org/packages/d6/56/7c3acf3d50910375349016cf33de24be021532042afbed87942858992491/ruff-0.15.6-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:8dc473ba093c5ec238bb1e7429ee676dca24643c471e11fbaa8a857925b061c0", size = 10340357, upload-time = "2026-03-12T23:06:04.748Z" }, - { url = "https://files.pythonhosted.org/packages/06/54/6faa39e9c1033ff6a3b6e76b5df536931cd30caf64988e112bbf91ef5ce5/ruff-0.15.6-py3-none-musllinux_1_2_i686.whl", hash = "sha256:85b042377c2a5561131767974617006f99f7e13c63c111b998f29fc1e58a4cfb", size = 10860583, upload-time = "2026-03-12T23:05:58.978Z" }, - { url = "https://files.pythonhosted.org/packages/cb/1e/509a201b843b4dfb0b32acdedf68d951d3377988cae43949ba4c4133a96a/ruff-0.15.6-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:cef49e30bc5a86a6a92098a7fbf6e467a234d90b63305d6f3ec01225a9d092e0", size = 11410976, upload-time = "2026-03-12T23:05:39.955Z" }, - { url = "https://files.pythonhosted.org/packages/6c/25/3fc9114abf979a41673ce877c08016f8e660ad6cf508c3957f537d2e9fa9/ruff-0.15.6-py3-none-win32.whl", hash = "sha256:bbf67d39832404812a2d23020dda68fee7f18ce15654e96fb1d3ad21a5fe436c", size = 10616872, upload-time = "2026-03-12T23:05:42.451Z" }, - { url = "https://files.pythonhosted.org/packages/89/7a/09ece68445ceac348df06e08bf75db72d0e8427765b96c9c0ffabc1be1d9/ruff-0.15.6-py3-none-win_amd64.whl", hash = "sha256:aee25bc84c2f1007ecb5037dff75cef00414fdf17c23f07dc13e577883dca406", size = 11787271, upload-time = "2026-03-12T23:05:20.168Z" }, - { url = "https://files.pythonhosted.org/packages/7f/d0/578c47dd68152ddddddf31cd7fc67dc30b7cdf639a86275fda821b0d9d98/ruff-0.15.6-py3-none-win_arm64.whl", hash = "sha256:c34de3dd0b0ba203be50ae70f5910b17188556630e2178fd7d79fc030eb0d837", size = 11060497, upload-time = "2026-03-12T23:05:25.968Z" }, +version = "0.15.7" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a1/22/9e4f66ee588588dc6c9af6a994e12d26e19efbe874d1a909d09a6dac7a59/ruff-0.15.7.tar.gz", hash = "sha256:04f1ae61fc20fe0b148617c324d9d009b5f63412c0b16474f3d5f1a1a665f7ac", size = 4601277, upload-time = "2026-03-19T16:26:22.605Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/41/2f/0b08ced94412af091807b6119ca03755d651d3d93a242682bf020189db94/ruff-0.15.7-py3-none-linux_armv6l.whl", hash = "sha256:a81cc5b6910fb7dfc7c32d20652e50fa05963f6e13ead3c5915c41ac5d16668e", size = 10489037, upload-time = "2026-03-19T16:26:32.47Z" }, + { url = "https://files.pythonhosted.org/packages/91/4a/82e0fa632e5c8b1eba5ee86ecd929e8ff327bbdbfb3c6ac5d81631bef605/ruff-0.15.7-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:722d165bd52403f3bdabc0ce9e41fc47070ac56d7a91b4e0d097b516a53a3477", size = 10955433, upload-time = "2026-03-19T16:27:00.205Z" }, + { url = "https://files.pythonhosted.org/packages/ab/10/12586735d0ff42526ad78c049bf51d7428618c8b5c467e72508c694119df/ruff-0.15.7-py3-none-macosx_11_0_arm64.whl", hash = "sha256:7fbc2448094262552146cbe1b9643a92f66559d3761f1ad0656d4991491af49e", size = 10269302, upload-time = "2026-03-19T16:26:26.183Z" }, + { url = "https://files.pythonhosted.org/packages/eb/5d/32b5c44ccf149a26623671df49cbfbd0a0ae511ff3df9d9d2426966a8d57/ruff-0.15.7-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6b39329b60eba44156d138275323cc726bbfbddcec3063da57caa8a8b1d50adf", size = 10607625, upload-time = "2026-03-19T16:27:03.263Z" }, + { url = "https://files.pythonhosted.org/packages/5d/f1/f0001cabe86173aaacb6eb9bb734aa0605f9a6aa6fa7d43cb49cbc4af9c9/ruff-0.15.7-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:87768c151808505f2bfc93ae44e5f9e7c8518943e5074f76ac21558ef5627c85", size = 10324743, upload-time = "2026-03-19T16:27:09.791Z" }, + { url = "https://files.pythonhosted.org/packages/7a/87/b8a8f3d56b8d848008559e7c9d8bf367934d5367f6d932ba779456e2f73b/ruff-0.15.7-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:fb0511670002c6c529ec66c0e30641c976c8963de26a113f3a30456b702468b0", size = 11138536, upload-time = "2026-03-19T16:27:06.101Z" }, + { url = "https://files.pythonhosted.org/packages/e4/f2/4fd0d05aab0c5934b2e1464784f85ba2eab9d54bffc53fb5430d1ed8b829/ruff-0.15.7-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e0d19644f801849229db8345180a71bee5407b429dd217f853ec515e968a6912", size = 11994292, upload-time = "2026-03-19T16:26:48.718Z" }, + { url = "https://files.pythonhosted.org/packages/64/22/fc4483871e767e5e95d1622ad83dad5ebb830f762ed0420fde7dfa9d9b08/ruff-0.15.7-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4806d8e09ef5e84eb19ba833d0442f7e300b23fe3f0981cae159a248a10f0036", size = 11398981, upload-time = "2026-03-19T16:26:54.513Z" }, + { url = "https://files.pythonhosted.org/packages/b0/99/66f0343176d5eab02c3f7fcd2de7a8e0dd7a41f0d982bee56cd1c24db62b/ruff-0.15.7-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dce0896488562f09a27b9c91b1f58a097457143931f3c4d519690dea54e624c5", size = 11242422, upload-time = "2026-03-19T16:26:29.277Z" }, + { url = "https://files.pythonhosted.org/packages/5d/3a/a7060f145bfdcce4c987ea27788b30c60e2c81d6e9a65157ca8afe646328/ruff-0.15.7-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:1852ce241d2bc89e5dc823e03cff4ce73d816b5c6cdadd27dbfe7b03217d2a12", size = 11232158, upload-time = "2026-03-19T16:26:42.321Z" }, + { url = "https://files.pythonhosted.org/packages/a7/53/90fbb9e08b29c048c403558d3cdd0adf2668b02ce9d50602452e187cd4af/ruff-0.15.7-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:5f3e4b221fb4bd293f79912fc5e93a9063ebd6d0dcbd528f91b89172a9b8436c", size = 10577861, upload-time = "2026-03-19T16:26:57.459Z" }, + { url = "https://files.pythonhosted.org/packages/2f/aa/5f486226538fe4d0f0439e2da1716e1acf895e2a232b26f2459c55f8ddad/ruff-0.15.7-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:b15e48602c9c1d9bdc504b472e90b90c97dc7d46c7028011ae67f3861ceba7b4", size = 10327310, upload-time = "2026-03-19T16:26:35.909Z" }, + { url = "https://files.pythonhosted.org/packages/99/9e/271afdffb81fe7bfc8c43ba079e9d96238f674380099457a74ccb3863857/ruff-0.15.7-py3-none-musllinux_1_2_i686.whl", hash = "sha256:1b4705e0e85cedc74b0a23cf6a179dbb3df184cb227761979cc76c0440b5ab0d", size = 10840752, upload-time = "2026-03-19T16:26:45.723Z" }, + { url = "https://files.pythonhosted.org/packages/bf/29/a4ae78394f76c7759953c47884eb44de271b03a66634148d9f7d11e721bd/ruff-0.15.7-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:112c1fa316a558bb34319282c1200a8bf0495f1b735aeb78bfcb2991e6087580", size = 11336961, upload-time = "2026-03-19T16:26:39.076Z" }, + { url = "https://files.pythonhosted.org/packages/26/6b/8786ba5736562220d588a2f6653e6c17e90c59ced34a2d7b512ef8956103/ruff-0.15.7-py3-none-win32.whl", hash = "sha256:6d39e2d3505b082323352f733599f28169d12e891f7dd407f2d4f54b4c2886de", size = 10582538, upload-time = "2026-03-19T16:26:15.992Z" }, + { url = "https://files.pythonhosted.org/packages/2b/e9/346d4d3fffc6871125e877dae8d9a1966b254fbd92a50f8561078b88b099/ruff-0.15.7-py3-none-win_amd64.whl", hash = "sha256:4d53d712ddebcd7dace1bc395367aec12c057aacfe9adbb6d832302575f4d3a1", size = 11755839, upload-time = "2026-03-19T16:26:19.897Z" }, + { url = "https://files.pythonhosted.org/packages/8f/e8/726643a3ea68c727da31570bde48c7a10f1aa60eddd628d94078fec586ff/ruff-0.15.7-py3-none-win_arm64.whl", hash = "sha256:18e8d73f1c3fdf27931497972250340f92e8c861722161a9caeb89a58ead6ed2", size = 11023304, upload-time = "2026-03-19T16:26:51.669Z" }, ] [[package]] @@ -454,26 +454,26 @@ wheels = [ [[package]] name = "ty" -version = "0.0.23" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/75/ba/d3c998ff4cf6b5d75b39356db55fe1b7caceecc522b9586174e6a5dee6f7/ty-0.0.23.tar.gz", hash = "sha256:5fb05db58f202af366f80ef70f806e48f5237807fe424ec787c9f289e3f3a4ef", size = 5341461, upload-time = "2026-03-13T12:34:23.125Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/f4/21/aab32603dfdfacd4819e52fa8c6074e7bd578218a5142729452fc6a62db6/ty-0.0.23-py3-none-linux_armv6l.whl", hash = "sha256:e810eef1a5f1cfc0731a58af8d2f334906a96835829767aed00026f1334a8dd7", size = 10329096, upload-time = "2026-03-13T12:34:09.432Z" }, - { url = "https://files.pythonhosted.org/packages/9f/a9/dd3287a82dce3df546ec560296208d4905dcf06346b6e18c2f3c63523bd1/ty-0.0.23-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:e43d36bd89a151ddcad01acaeff7dcc507cb73ff164c1878d2d11549d39a061c", size = 10156631, upload-time = "2026-03-13T12:34:53.122Z" }, - { url = "https://files.pythonhosted.org/packages/0f/01/3f25909b02fac29bb0a62b2251f8d62e65d697781ffa4cf6b47a4c075c85/ty-0.0.23-py3-none-macosx_11_0_arm64.whl", hash = "sha256:bd6a340969577b4645f231572c4e46012acba2d10d4c0c6570fe1ab74e76ae00", size = 9653211, upload-time = "2026-03-13T12:34:15.049Z" }, - { url = "https://files.pythonhosted.org/packages/d5/60/bfc0479572a6f4b90501c869635faf8d84c8c68ffc5dd87d04f049affabc/ty-0.0.23-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:341441783e626eeb7b1ec2160432956aed5734932ab2d1c26f94d0c98b229937", size = 10156143, upload-time = "2026-03-13T12:34:34.468Z" }, - { url = "https://files.pythonhosted.org/packages/3a/81/8a93e923535a340f54bea20ff196f6b2787782b2f2f399bd191c4bc132d6/ty-0.0.23-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8ce1dc66c26d4167e2c78d12fa870ef5a7ec9cc344d2baaa6243297cfa88bd52", size = 10136632, upload-time = "2026-03-13T12:34:28.832Z" }, - { url = "https://files.pythonhosted.org/packages/da/cb/2ac81c850c58acc9f976814404d28389c9c1c939676e32287b9cff61381e/ty-0.0.23-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:bae1e7a294bf8528836f7617dc5c360ea2dddb63789fc9471ae6753534adca05", size = 10655025, upload-time = "2026-03-13T12:34:37.105Z" }, - { url = "https://files.pythonhosted.org/packages/b5/9b/bac771774c198c318ae699fc013d8cd99ed9caf993f661fba11238759244/ty-0.0.23-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d2b162768764d9dc177c83fb497a51532bb67cbebe57b8fa0f2668436bf53f3c", size = 11230107, upload-time = "2026-03-13T12:34:20.751Z" }, - { url = "https://files.pythonhosted.org/packages/14/09/7644fb0e297265e18243f878aca343593323b9bb19ed5278dcbc63781be0/ty-0.0.23-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d28384e48ca03b34e4e2beee0e230c39bbfb68994bb44927fec61ef3642900da", size = 10934177, upload-time = "2026-03-13T12:34:17.904Z" }, - { url = "https://files.pythonhosted.org/packages/18/14/69a25a0cad493fb6a947302471b579a03516a3b00e7bece77fdc6b4afb9b/ty-0.0.23-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:559d9a299df793cb7a7902caed5eda8a720ff69164c31c979673e928f02251ee", size = 10752487, upload-time = "2026-03-13T12:34:31.785Z" }, - { url = "https://files.pythonhosted.org/packages/9d/2a/42fc3cbccf95af0a62308ebed67e084798ab7a85ef073c9986ef18032743/ty-0.0.23-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:32a7b8a14a98e1d20a9d8d2af23637ed7efdb297ac1fa2450b8e465d05b94482", size = 10133007, upload-time = "2026-03-13T12:34:42.838Z" }, - { url = "https://files.pythonhosted.org/packages/e1/69/307833f1b52fa3670e0a1d496e43ef7df556ecde838192d3fcb9b35e360d/ty-0.0.23-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:6f803b9b9cca87af793467973b9abdd4b83e6b96d9b5e749d662cff7ead70b6d", size = 10169698, upload-time = "2026-03-13T12:34:12.351Z" }, - { url = "https://files.pythonhosted.org/packages/89/ae/5dd379ec22d0b1cba410d7af31c366fcedff191d5b867145913a64889f66/ty-0.0.23-py3-none-musllinux_1_2_i686.whl", hash = "sha256:4a0bf086ec8e2197b7ea7ebfcf4be36cb6a52b235f8be61647ef1b2d99d6ffd3", size = 10346080, upload-time = "2026-03-13T12:34:40.012Z" }, - { url = "https://files.pythonhosted.org/packages/98/c7/dfc83203d37998620bba9c4873a080c8850a784a8a46f56f8163c5b4e320/ty-0.0.23-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:252539c3fcd7aeb9b8d5c14e2040682c3e1d7ff640906d63fd2c4ce35865a4ba", size = 10848162, upload-time = "2026-03-13T12:34:45.421Z" }, - { url = "https://files.pythonhosted.org/packages/89/08/05481511cfbcc1fd834b6c67aaae090cb609a079189ddf2032139ccfc490/ty-0.0.23-py3-none-win32.whl", hash = "sha256:51b591d19eef23bbc3807aef77d38fa1f003c354e1da908aa80ea2dca0993f77", size = 9748283, upload-time = "2026-03-13T12:34:50.607Z" }, - { url = "https://files.pythonhosted.org/packages/31/2e/eaed4ff5c85e857a02415084c394e02c30476b65e158eec1938fdaa9a205/ty-0.0.23-py3-none-win_amd64.whl", hash = "sha256:1e137e955f05c501cfbb81dd2190c8fb7d01ec037c7e287024129c722a83c9ad", size = 10698355, upload-time = "2026-03-13T12:34:26.134Z" }, - { url = "https://files.pythonhosted.org/packages/91/29/b32cb7b4c7d56b9ed50117f8ad6e45834aec293e4cb14749daab4e9236d5/ty-0.0.23-py3-none-win_arm64.whl", hash = "sha256:a0399bd13fd2cd6683fd0a2d59b9355155d46546d8203e152c556ddbdeb20842", size = 10155890, upload-time = "2026-03-13T12:34:48.082Z" }, +version = "0.0.24" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7a/96/652a425030f95dc2c9548d9019e52502e17079e1daeefbc4036f1c0905b4/ty-0.0.24.tar.gz", hash = "sha256:9fe42f6b98207bdaef51f71487d6d087f2cb02555ee3939884d779b2b3cc8bfc", size = 5354286, upload-time = "2026-03-19T16:55:57.035Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/da/e5/34457ee11708e734ba81ad65723af83030e484f961e281d57d1eecf08951/ty-0.0.24-py3-none-linux_armv6l.whl", hash = "sha256:1ab4f1f61334d533a3fdf5d9772b51b1300ac5da4f3cdb0be9657a3ccb2ce3e7", size = 10394877, upload-time = "2026-03-19T16:55:54.246Z" }, + { url = "https://files.pythonhosted.org/packages/44/81/bc9a1b1a87f43db15ab64ad781a4f999734ec3b470ad042624fa875b20e6/ty-0.0.24-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:facbf2c4aaa6985229e08f8f9bf152215eb078212f22b5c2411f35386688ab42", size = 10211109, upload-time = "2026-03-19T16:55:28.554Z" }, + { url = "https://files.pythonhosted.org/packages/e4/63/cfc805adeaa61d63ba3ea71127efa7d97c40ba36d97ee7bd957341d05107/ty-0.0.24-py3-none-macosx_11_0_arm64.whl", hash = "sha256:b6d2a3b6d4470c483552a31e9b368c86f154dcc964bccb5406159dc9cd362246", size = 9694769, upload-time = "2026-03-19T16:55:34.309Z" }, + { url = "https://files.pythonhosted.org/packages/33/09/edc220726b6ec44a58900401f6b27140997ef15026b791e26b69a6e69eb5/ty-0.0.24-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0c94c25d0500939fd5f8f16ce41cbed5b20528702c1d649bf80300253813f0a2", size = 10176287, upload-time = "2026-03-19T16:55:37.17Z" }, + { url = "https://files.pythonhosted.org/packages/f8/bf/cbe2227be711e65017655d8ee4d050f4c92b113fb4dc4c3bd6a19d3a86d8/ty-0.0.24-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:89cbe7bc7df0fab02dbd8cda79b737df83f1ef7fb573b08c0ee043dc68cffb08", size = 10214832, upload-time = "2026-03-19T16:56:08.518Z" }, + { url = "https://files.pythonhosted.org/packages/af/1d/d15803ee47e9143d10e10bd81ccc14761d08758082bda402950685f0ddfe/ty-0.0.24-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:db2c5d269bcc9b764850c99f457b5018a79b3ef40ecfbc03344e65effd6cf743", size = 10709892, upload-time = "2026-03-19T16:56:05.727Z" }, + { url = "https://files.pythonhosted.org/packages/36/12/6db0d86c477147f67b9052de209421d76c3e855197b000c25fcbbe86b3a2/ty-0.0.24-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ba44512db5b97c3bbd59d93e11296e8548d0c9a3bdd1280de36d7ff22d351896", size = 11280872, upload-time = "2026-03-19T16:56:02.899Z" }, + { url = "https://files.pythonhosted.org/packages/1b/fc/155fe83a97c06d33ccc9e0f428258b32df2e08a428300c715d34757f0111/ty-0.0.24-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a52b7f589c3205512a9c50ba5b2b1e8c0698b72e51b8b9285c90420c06f1cae8", size = 11060520, upload-time = "2026-03-19T16:55:59.956Z" }, + { url = "https://files.pythonhosted.org/packages/ac/f1/32c05a1c4c3c2a95c5b7361dee03a9bf1231d4ad096b161c838b45bce5a0/ty-0.0.24-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7981df5c709c054da4ac5d7c93f8feb8f45e69e829e4461df4d5f0988fe67d04", size = 10791455, upload-time = "2026-03-19T16:55:25.728Z" }, + { url = "https://files.pythonhosted.org/packages/17/2c/53c1ea6bedfa4d4ab64d4de262d8f5e405ecbffefd364459c628c0310d33/ty-0.0.24-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:b2860151ad95a00d0f0280b8fef79900d08dcd63276b57e6e5774f2c055979c5", size = 10156708, upload-time = "2026-03-19T16:55:45.563Z" }, + { url = "https://files.pythonhosted.org/packages/45/39/7d2919cf194707169474d80720a5f3d793e983416f25e7ffcf80504c9df2/ty-0.0.24-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:5674a1146d927ab77ff198a88e0c4505134ced342a0e7d1beb4a076a728b7496", size = 10236263, upload-time = "2026-03-19T16:55:31.474Z" }, + { url = "https://files.pythonhosted.org/packages/cf/7f/48eac722f2fd12a5b7aae0effdcb75c46053f94b783d989e3ef0d7380082/ty-0.0.24-py3-none-musllinux_1_2_i686.whl", hash = "sha256:438ecbf1608a9b16dd84502f3f1b23ef2ef32bbd0ab3e0ca5a82f0e0d1cd41ea", size = 10402559, upload-time = "2026-03-19T16:55:39.602Z" }, + { url = "https://files.pythonhosted.org/packages/75/e0/8cf868b9749ce1e5166462759545964e95b02353243594062b927d8bff2a/ty-0.0.24-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:ddeed3098dd92a83964e7aa7b41e509ba3530eb539fc4cd8322ff64a09daf1f5", size = 10893684, upload-time = "2026-03-19T16:55:51.439Z" }, + { url = "https://files.pythonhosted.org/packages/17/9f/f54bf3be01d2c2ed731d10a5afa3324dc66f987a6ae0a4a6cbfa2323d080/ty-0.0.24-py3-none-win32.whl", hash = "sha256:83013fb3a4764a8f8bcc6ca11ff8bdfd8c5f719fc249241cb2b8916e80778eb1", size = 9781542, upload-time = "2026-03-19T16:56:11.588Z" }, + { url = "https://files.pythonhosted.org/packages/fb/49/c004c5cc258b10b3a145666e9a9c28ae7678bc958c8926e8078d5d769081/ty-0.0.24-py3-none-win_amd64.whl", hash = "sha256:748a60eb6912d1cf27aaab105ffadb6f4d2e458a3fcadfbd3cf26db0d8062eeb", size = 10764801, upload-time = "2026-03-19T16:55:42.752Z" }, + { url = "https://files.pythonhosted.org/packages/e2/59/006a074e185bfccf5e4c026015245ab4fcd2362b13a8d24cf37a277909a9/ty-0.0.24-py3-none-win_arm64.whl", hash = "sha256:280a3d31e86d0721947238f17030c33f0911cae851d108ea9f4e3ab12a5ed01f", size = 10194093, upload-time = "2026-03-19T16:55:48.303Z" }, ] [[package]] From c3730cadfcd77b6e12edee1f49fc0f57b1e3fe87 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C6=B0u=20Quang=20V=C5=A9?= Date: Fri, 20 Mar 2026 21:33:49 +0700 Subject: [PATCH 230/291] Update dependencies to latest versions --- app/server/chat.py | 8 ++++---- app/services/client.py | 6 ------ uv.lock | 4 ++-- 3 files changed, 6 insertions(+), 12 deletions(-) diff --git a/app/server/chat.py b/app/server/chat.py index 9df91ca..8b3094d 100644 --- a/app/server/chat.py +++ b/app/server/chat.py @@ -923,16 +923,16 @@ async def _get_available_models(pool: GeminiClientPool) -> list[ModelData]: models_data = [] seen_model_ids = set() - for m in g_config.gemini.models: - if m.model_name and m.model_name not in seen_model_ids: + for model in g_config.gemini.models: + if model.model_name and model.model_name not in seen_model_ids: models_data.append( ModelData( - id=m.model_name, + id=model.model_name, created=now, owned_by="custom", ) ) - seen_model_ids.add(m.model_name) + seen_model_ids.add(model.model_name) if strategy == "append": for client in pool.clients: diff --git a/app/services/client.py b/app/services/client.py index b9ba25e..a83e1e6 100644 --- a/app/services/client.py +++ b/app/services/client.py @@ -14,12 +14,6 @@ save_url_to_tempfile, ) -_UNSET = object() - - -def _resolve(value: Any, fallback: Any): - return fallback if value is _UNSET else value - class GeminiClientWrapper(GeminiClient): """Gemini client with helper methods.""" diff --git a/uv.lock b/uv.lock index 00eba05..7a61027 100644 --- a/uv.lock +++ b/uv.lock @@ -176,8 +176,8 @@ dev = [{ name = "gemini-fastapi", extras = ["dev"] }] [[package]] name = "gemini-webapi" -version = "0.0.post227" -source = { git = "https://github.com/luuquangvu/Gemini-API.git?rev=retrieve-dynamic-models#e53ea40be2896a882355bafae5f8d20cd373bcf9" } +version = "0.0.post231" +source = { git = "https://github.com/luuquangvu/Gemini-API.git?rev=retrieve-dynamic-models#f894820435530a9dbccca7de1080e7899c015b64" } dependencies = [ { name = "curl-cffi" }, { name = "loguru" }, From ccbd7bb3b461e4f9d119662654d099b2c85e480c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C6=B0u=20Quang=20V=C5=A9?= Date: Sat, 21 Mar 2026 20:55:01 +0700 Subject: [PATCH 231/291] Enable the debug logging level to let users see warnings about cookie issues. --- app/utils/config.py | 2 +- app/utils/logging.py | 2 +- config/config.yaml | 6 +++--- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/app/utils/config.py b/app/utils/config.py index e569fbf..f84385a 100644 --- a/app/utils/config.py +++ b/app/utils/config.py @@ -93,7 +93,7 @@ class GeminiConfig(BaseModel): ge=60, description="Interval in seconds to refresh Gemini sessions (Not less than 60s)", ) - verbose: bool = Field(False, description="Enable verbose logging for Gemini API requests") + verbose: bool = Field(True, description="Enable verbose logging for Gemini API requests") max_chars_per_request: int = Field( default=1_000_000, ge=1, diff --git a/app/utils/logging.py b/app/utils/logging.py index 87fcc7f..1a5f9fe 100644 --- a/app/utils/logging.py +++ b/app/utils/logging.py @@ -65,4 +65,4 @@ def emit(self, record: logging.LogRecord) -> None: logger.opt(depth=depth, exception=record.exc_info).log(level, record.getMessage()) # Remove all existing handlers and add our interceptor - logging.basicConfig(handlers=[InterceptHandler()], level="INFO", force=True) + logging.basicConfig(handlers=[InterceptHandler()], level="DEBUG", force=True) diff --git a/config/config.yaml b/config/config.yaml index 9321c61..fbce483 100644 --- a/config/config.yaml +++ b/config/config.yaml @@ -18,7 +18,7 @@ cors: gemini: clients: - - id: "example-id-1" # Arbitrary client ID + - id: "client-id-1" # Arbitrary client ID secure_1psid: "YOUR_SECURE_1PSID_HERE" # Gemini Secure 1PSID secure_1psidts: "YOUR_SECURE_1PSIDTS_HERE" # Gemini Secure 1PSIDTS proxy: null # Optional proxy URL (null/empty means direct connection) @@ -26,7 +26,7 @@ gemini: watchdog_timeout: 90 # Watchdog timeout in seconds (Not less than 30s) auto_refresh: true # Auto-refresh session cookies refresh_interval: 600 # Refresh interval in seconds (Not less than 60s) - verbose: false # Enable verbose logging for Gemini requests + verbose: true # Enable verbose logging for Gemini requests max_chars_per_request: 1000000 # Maximum characters Gemini Web accepts per request. Non-pro users might have a lower limit model_strategy: "append" # Strategy: 'append' (default + custom) or 'overwrite' (custom only) models: [] @@ -38,4 +38,4 @@ storage: retention_days: 14 # Number of days to retain conversations before cleanup logging: - level: "INFO" # Log level: DEBUG, INFO, WARNING, ERROR + level: "DEBUG" # Log level: DEBUG, INFO, WARNING, ERROR From cd402d8a30e053d70f5f66cbddc31c484ac3d292 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C6=B0u=20Quang=20V=C5=A9?= Date: Tue, 24 Mar 2026 09:20:41 +0700 Subject: [PATCH 232/291] Update dependencies to latest versions --- .github/workflows/lint.yaml | 5 +++-- .github/workflows/track.yml | 4 +++- pyproject.toml | 2 +- uv.lock | 18 +++++++++--------- 4 files changed, 16 insertions(+), 13 deletions(-) diff --git a/.github/workflows/lint.yaml b/.github/workflows/lint.yaml index f513a31..e3da9f0 100644 --- a/.github/workflows/lint.yaml +++ b/.github/workflows/lint.yaml @@ -2,8 +2,6 @@ name: Lint and Type Check on: push: - branches: - - main paths: - "**.py" - "pyproject.toml" @@ -33,5 +31,8 @@ jobs: - name: Run Ruff run: uv run ruff check . + - name: Run Ruff Format + run: uv run ruff format . --check + - name: Run Ty Check run: uv run ty check diff --git a/.github/workflows/track.yml b/.github/workflows/track.yml index 778ef03..9ebb9c6 100644 --- a/.github/workflows/track.yml +++ b/.github/workflows/track.yml @@ -11,8 +11,10 @@ jobs: permissions: contents: write pull-requests: write + steps: - - uses: actions/checkout@v6 + - name: Checkout repository + uses: actions/checkout@v6 - name: Install uv uses: astral-sh/setup-uv@v7 diff --git a/pyproject.toml b/pyproject.toml index 206eddc..0d36bb0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,7 +6,7 @@ readme = "README.md" requires-python = "==3.13.*" dependencies = [ "curl-cffi>=0.14.0", - "fastapi>=0.135.1", + "fastapi>=0.135.2", "gemini-webapi>=1.21.0", "httptools>=0.7.1", "lmdb>=2.1.1", diff --git a/uv.lock b/uv.lock index 7a61027..ff13204 100644 --- a/uv.lock +++ b/uv.lock @@ -110,7 +110,7 @@ wheels = [ [[package]] name = "fastapi" -version = "0.135.1" +version = "0.135.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "annotated-doc" }, @@ -119,9 +119,9 @@ dependencies = [ { name = "typing-extensions" }, { name = "typing-inspection" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/e7/7b/f8e0211e9380f7195ba3f3d40c292594fd81ba8ec4629e3854c353aaca45/fastapi-0.135.1.tar.gz", hash = "sha256:d04115b508d936d254cea545b7312ecaa58a7b3a0f84952535b4c9afae7668cd", size = 394962, upload-time = "2026-03-01T18:18:29.369Z" } +sdist = { url = "https://files.pythonhosted.org/packages/c4/73/5903c4b13beae98618d64eb9870c3fac4f605523dd0312ca5c80dadbd5b9/fastapi-0.135.2.tar.gz", hash = "sha256:88a832095359755527b7f63bb4c6bc9edb8329a026189eed83d6c1afcf419d56", size = 395833, upload-time = "2026-03-23T14:12:41.697Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e4/72/42e900510195b23a56bde950d26a51f8b723846bfcaa0286e90287f0422b/fastapi-0.135.1-py3-none-any.whl", hash = "sha256:46e2fc5745924b7c840f71ddd277382af29ce1cdb7d5eab5bf697e3fb9999c9e", size = 116999, upload-time = "2026-03-01T18:18:30.831Z" }, + { url = "https://files.pythonhosted.org/packages/8f/ea/18f6d0457f9efb2fc6fa594857f92810cadb03024975726db6546b3d6fcf/fastapi-0.135.2-py3-none-any.whl", hash = "sha256:0af0447d541867e8db2a6a25c23a8c4bd80e2394ac5529bd87501bbb9e240ca5", size = 117407, upload-time = "2026-03-23T14:12:43.284Z" }, ] [[package]] @@ -156,7 +156,7 @@ dev = [ [package.metadata] requires-dist = [ { name = "curl-cffi", specifier = ">=0.14.0" }, - { name = "fastapi", specifier = ">=0.135.1" }, + { name = "fastapi", specifier = ">=0.135.2" }, { name = "gemini-webapi", git = "https://github.com/luuquangvu/Gemini-API.git?rev=retrieve-dynamic-models" }, { name = "httptools", specifier = ">=0.7.1" }, { name = "lmdb", specifier = ">=2.1.1" }, @@ -176,8 +176,8 @@ dev = [{ name = "gemini-fastapi", extras = ["dev"] }] [[package]] name = "gemini-webapi" -version = "0.0.post231" -source = { git = "https://github.com/luuquangvu/Gemini-API.git?rev=retrieve-dynamic-models#f894820435530a9dbccca7de1080e7899c015b64" } +version = "0.0.post232" +source = { git = "https://github.com/luuquangvu/Gemini-API.git?rev=retrieve-dynamic-models#57bb345555cd947f4f7d278088d3f41946fd3caf" } dependencies = [ { name = "curl-cffi" }, { name = "loguru" }, @@ -442,14 +442,14 @@ wheels = [ [[package]] name = "starlette" -version = "0.52.1" +version = "1.0.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/c4/68/79977123bb7be889ad680d79a40f339082c1978b5cfcf62c2d8d196873ac/starlette-0.52.1.tar.gz", hash = "sha256:834edd1b0a23167694292e94f597773bc3f89f362be6effee198165a35d62933", size = 2653702, upload-time = "2026-01-18T13:34:11.062Z" } +sdist = { url = "https://files.pythonhosted.org/packages/81/69/17425771797c36cded50b7fe44e850315d039f28b15901ab44839e70b593/starlette-1.0.0.tar.gz", hash = "sha256:6a4beaf1f81bb472fd19ea9b918b50dc3a77a6f2e190a12954b25e6ed5eea149", size = 2655289, upload-time = "2026-03-22T18:29:46.779Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/81/0d/13d1d239a25cbfb19e740db83143e95c772a1fe10202dda4b76792b114dd/starlette-0.52.1-py3-none-any.whl", hash = "sha256:0029d43eb3d273bc4f83a08720b4912ea4b071087a3b48db01b7c839f7954d74", size = 74272, upload-time = "2026-01-18T13:34:09.188Z" }, + { url = "https://files.pythonhosted.org/packages/0b/c9/584bc9651441b4ba60cc4d557d8a547b5aff901af35bda3a4ee30c819b82/starlette-1.0.0-py3-none-any.whl", hash = "sha256:d3ec55e0bb321692d275455ddfd3df75fff145d009685eb40dc91fc66b03d38b", size = 72651, upload-time = "2026-03-22T18:29:45.111Z" }, ] [[package]] From 44968c90a0eff0e60232b33308c5f33ee71a27f7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C6=B0u=20Quang=20V=C5=A9?= Date: Thu, 26 Mar 2026 19:18:55 +0700 Subject: [PATCH 233/291] Update dependencies to latest versions --- pyproject.toml | 8 +++---- uv.lock | 58 +++++++++++++++++++++++++------------------------- 2 files changed, 33 insertions(+), 33 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 0d36bb0..49a424f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -22,9 +22,9 @@ Repository = "https://github.com/Nativu5/Gemini-FastAPI" [project.optional-dependencies] dev = [ - "pytest>=9.0.2", - "ruff>=0.15.7", - "ty>=0.0.24", + "pytest", + "ruff", + "ty", ] [dependency-groups] @@ -65,4 +65,4 @@ quote-style = "double" indent-style = "space" [tool.uv.sources] -gemini-webapi = { git = "https://github.com/luuquangvu/Gemini-API.git", rev = "retrieve-dynamic-models" } +gemini-webapi = { git = "https://github.com/HanaokaYuzu/Gemini-API.git" } diff --git a/uv.lock b/uv.lock index ff13204..d7ddf48 100644 --- a/uv.lock +++ b/uv.lock @@ -22,14 +22,14 @@ wheels = [ [[package]] name = "anyio" -version = "4.12.1" +version = "4.13.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "idna" }, ] -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" } +sdist = { url = "https://files.pythonhosted.org/packages/19/14/2c5dd9f512b66549ae92767a9c7b330ae88e1932ca57876909410251fe13/anyio-4.13.0.tar.gz", hash = "sha256:334b70e641fd2221c1505b3890c69882fe4a2df910cba14d97019b90b24439dc", size = 231622, upload-time = "2026-03-24T12:59:09.671Z" } wheels = [ - { 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" }, + { url = "https://files.pythonhosted.org/packages/da/42/e921fccf5015463e32a3cf6ee7f980a6ed0f395ceeaa45060b61d86486c2/anyio-4.13.0-py3-none-any.whl", hash = "sha256:08b310f9e24a9594186fd75b4f73f4a4152069e3853f1ed8bfbf58369f4ad708", size = 114353, upload-time = "2026-03-24T12:59:08.246Z" }, ] [[package]] @@ -157,15 +157,15 @@ dev = [ requires-dist = [ { name = "curl-cffi", specifier = ">=0.14.0" }, { name = "fastapi", specifier = ">=0.135.2" }, - { name = "gemini-webapi", git = "https://github.com/luuquangvu/Gemini-API.git?rev=retrieve-dynamic-models" }, + { name = "gemini-webapi", git = "https://github.com/HanaokaYuzu/Gemini-API.git" }, { name = "httptools", specifier = ">=0.7.1" }, { name = "lmdb", specifier = ">=2.1.1" }, { name = "loguru", specifier = ">=0.7.3" }, { name = "orjson", specifier = ">=3.11.7" }, { name = "pydantic-settings", extras = ["yaml"], specifier = ">=2.13.1" }, - { name = "pytest", marker = "extra == 'dev'", specifier = ">=9.0.2" }, - { name = "ruff", marker = "extra == 'dev'", specifier = ">=0.15.7" }, - { name = "ty", marker = "extra == 'dev'", specifier = ">=0.0.24" }, + { name = "pytest", marker = "extra == 'dev'" }, + { name = "ruff", marker = "extra == 'dev'" }, + { name = "ty", marker = "extra == 'dev'" }, { name = "uvicorn", specifier = ">=0.42.0" }, { name = "uvloop", marker = "sys_platform != 'win32'", specifier = ">=0.22.1" }, ] @@ -176,8 +176,8 @@ dev = [{ name = "gemini-fastapi", extras = ["dev"] }] [[package]] name = "gemini-webapi" -version = "0.0.post232" -source = { git = "https://github.com/luuquangvu/Gemini-API.git?rev=retrieve-dynamic-models#57bb345555cd947f4f7d278088d3f41946fd3caf" } +version = "1.21.0.post23" +source = { git = "https://github.com/HanaokaYuzu/Gemini-API.git#7b669588f5f8110424d3678440eb558af983991c" } dependencies = [ { name = "curl-cffi" }, { name = "loguru" }, @@ -454,26 +454,26 @@ wheels = [ [[package]] name = "ty" -version = "0.0.24" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/7a/96/652a425030f95dc2c9548d9019e52502e17079e1daeefbc4036f1c0905b4/ty-0.0.24.tar.gz", hash = "sha256:9fe42f6b98207bdaef51f71487d6d087f2cb02555ee3939884d779b2b3cc8bfc", size = 5354286, upload-time = "2026-03-19T16:55:57.035Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/da/e5/34457ee11708e734ba81ad65723af83030e484f961e281d57d1eecf08951/ty-0.0.24-py3-none-linux_armv6l.whl", hash = "sha256:1ab4f1f61334d533a3fdf5d9772b51b1300ac5da4f3cdb0be9657a3ccb2ce3e7", size = 10394877, upload-time = "2026-03-19T16:55:54.246Z" }, - { url = "https://files.pythonhosted.org/packages/44/81/bc9a1b1a87f43db15ab64ad781a4f999734ec3b470ad042624fa875b20e6/ty-0.0.24-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:facbf2c4aaa6985229e08f8f9bf152215eb078212f22b5c2411f35386688ab42", size = 10211109, upload-time = "2026-03-19T16:55:28.554Z" }, - { url = "https://files.pythonhosted.org/packages/e4/63/cfc805adeaa61d63ba3ea71127efa7d97c40ba36d97ee7bd957341d05107/ty-0.0.24-py3-none-macosx_11_0_arm64.whl", hash = "sha256:b6d2a3b6d4470c483552a31e9b368c86f154dcc964bccb5406159dc9cd362246", size = 9694769, upload-time = "2026-03-19T16:55:34.309Z" }, - { url = "https://files.pythonhosted.org/packages/33/09/edc220726b6ec44a58900401f6b27140997ef15026b791e26b69a6e69eb5/ty-0.0.24-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0c94c25d0500939fd5f8f16ce41cbed5b20528702c1d649bf80300253813f0a2", size = 10176287, upload-time = "2026-03-19T16:55:37.17Z" }, - { url = "https://files.pythonhosted.org/packages/f8/bf/cbe2227be711e65017655d8ee4d050f4c92b113fb4dc4c3bd6a19d3a86d8/ty-0.0.24-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:89cbe7bc7df0fab02dbd8cda79b737df83f1ef7fb573b08c0ee043dc68cffb08", size = 10214832, upload-time = "2026-03-19T16:56:08.518Z" }, - { url = "https://files.pythonhosted.org/packages/af/1d/d15803ee47e9143d10e10bd81ccc14761d08758082bda402950685f0ddfe/ty-0.0.24-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:db2c5d269bcc9b764850c99f457b5018a79b3ef40ecfbc03344e65effd6cf743", size = 10709892, upload-time = "2026-03-19T16:56:05.727Z" }, - { url = "https://files.pythonhosted.org/packages/36/12/6db0d86c477147f67b9052de209421d76c3e855197b000c25fcbbe86b3a2/ty-0.0.24-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ba44512db5b97c3bbd59d93e11296e8548d0c9a3bdd1280de36d7ff22d351896", size = 11280872, upload-time = "2026-03-19T16:56:02.899Z" }, - { url = "https://files.pythonhosted.org/packages/1b/fc/155fe83a97c06d33ccc9e0f428258b32df2e08a428300c715d34757f0111/ty-0.0.24-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a52b7f589c3205512a9c50ba5b2b1e8c0698b72e51b8b9285c90420c06f1cae8", size = 11060520, upload-time = "2026-03-19T16:55:59.956Z" }, - { url = "https://files.pythonhosted.org/packages/ac/f1/32c05a1c4c3c2a95c5b7361dee03a9bf1231d4ad096b161c838b45bce5a0/ty-0.0.24-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7981df5c709c054da4ac5d7c93f8feb8f45e69e829e4461df4d5f0988fe67d04", size = 10791455, upload-time = "2026-03-19T16:55:25.728Z" }, - { url = "https://files.pythonhosted.org/packages/17/2c/53c1ea6bedfa4d4ab64d4de262d8f5e405ecbffefd364459c628c0310d33/ty-0.0.24-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:b2860151ad95a00d0f0280b8fef79900d08dcd63276b57e6e5774f2c055979c5", size = 10156708, upload-time = "2026-03-19T16:55:45.563Z" }, - { url = "https://files.pythonhosted.org/packages/45/39/7d2919cf194707169474d80720a5f3d793e983416f25e7ffcf80504c9df2/ty-0.0.24-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:5674a1146d927ab77ff198a88e0c4505134ced342a0e7d1beb4a076a728b7496", size = 10236263, upload-time = "2026-03-19T16:55:31.474Z" }, - { url = "https://files.pythonhosted.org/packages/cf/7f/48eac722f2fd12a5b7aae0effdcb75c46053f94b783d989e3ef0d7380082/ty-0.0.24-py3-none-musllinux_1_2_i686.whl", hash = "sha256:438ecbf1608a9b16dd84502f3f1b23ef2ef32bbd0ab3e0ca5a82f0e0d1cd41ea", size = 10402559, upload-time = "2026-03-19T16:55:39.602Z" }, - { url = "https://files.pythonhosted.org/packages/75/e0/8cf868b9749ce1e5166462759545964e95b02353243594062b927d8bff2a/ty-0.0.24-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:ddeed3098dd92a83964e7aa7b41e509ba3530eb539fc4cd8322ff64a09daf1f5", size = 10893684, upload-time = "2026-03-19T16:55:51.439Z" }, - { url = "https://files.pythonhosted.org/packages/17/9f/f54bf3be01d2c2ed731d10a5afa3324dc66f987a6ae0a4a6cbfa2323d080/ty-0.0.24-py3-none-win32.whl", hash = "sha256:83013fb3a4764a8f8bcc6ca11ff8bdfd8c5f719fc249241cb2b8916e80778eb1", size = 9781542, upload-time = "2026-03-19T16:56:11.588Z" }, - { url = "https://files.pythonhosted.org/packages/fb/49/c004c5cc258b10b3a145666e9a9c28ae7678bc958c8926e8078d5d769081/ty-0.0.24-py3-none-win_amd64.whl", hash = "sha256:748a60eb6912d1cf27aaab105ffadb6f4d2e458a3fcadfbd3cf26db0d8062eeb", size = 10764801, upload-time = "2026-03-19T16:55:42.752Z" }, - { url = "https://files.pythonhosted.org/packages/e2/59/006a074e185bfccf5e4c026015245ab4fcd2362b13a8d24cf37a277909a9/ty-0.0.24-py3-none-win_arm64.whl", hash = "sha256:280a3d31e86d0721947238f17030c33f0911cae851d108ea9f4e3ab12a5ed01f", size = 10194093, upload-time = "2026-03-19T16:55:48.303Z" }, +version = "0.0.25" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/12/bf/3c3147c7237277b0e8a911ff89de7183408be96b31fb42b38edb666d287f/ty-0.0.25.tar.gz", hash = "sha256:8ae3891be17dfb6acab51a2df3a8f8f6c551eb60ea674c10946dc92aae8d4401", size = 5375500, upload-time = "2026-03-24T22:32:34.608Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/97/a4/6c289cbd1474285223124a4ffb55c078dbe9ae1d925d0b6a948643c7f115/ty-0.0.25-py3-none-linux_armv6l.whl", hash = "sha256:26d6d5aede5d54fb055779460f896d9c1473c6fb996716bd11cb90f027d8fee7", size = 10452747, upload-time = "2026-03-24T22:32:32.662Z" }, + { url = "https://files.pythonhosted.org/packages/00/13/74cb9de356b9ceb3f281ab048f8c4ac2207122161b0ac0066886ce129abe/ty-0.0.25-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:aedcfbc7b6b96dbc55b0da78fa02bd049373ff3d8a827f613dadd8bd17d10758", size = 10271349, upload-time = "2026-03-24T22:32:13.041Z" }, + { url = "https://files.pythonhosted.org/packages/0e/93/ffc5a20cc9e14fa9b32b0c54884864bede30d144ce2ae013805bce0c86d0/ty-0.0.25-py3-none-macosx_11_0_arm64.whl", hash = "sha256:0a8fb3c1e28f73618941811e2568dca195178a1a6314651d4ee97086a4497253", size = 9730308, upload-time = "2026-03-24T22:32:19.24Z" }, + { url = "https://files.pythonhosted.org/packages/6d/78/52e05ef32a5f172fce70633a4e19d8e04364271a4322ae12382c7344b0de/ty-0.0.25-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:814870b7f347b5d0276304cddb98a0958f08de183bf159abc920ebe321247ad4", size = 10247664, upload-time = "2026-03-24T22:32:08.669Z" }, + { url = "https://files.pythonhosted.org/packages/c2/64/0d0a47ed0aa1d634c666c2cc15d3b0af4b95d0fd3dbb796032bd493f3433/ty-0.0.25-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:781150e23825dc110cd5e1f50ca3d61664f7a5db5b4a55d5dbf7d3b1e246b917", size = 10261961, upload-time = "2026-03-24T22:32:43.935Z" }, + { url = "https://files.pythonhosted.org/packages/3e/ba/4666b96f0499465efb97c244554107c541d74a1add393e62276b3de9b54f/ty-0.0.25-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ffc81ff2a0143911321251dc81d1c259fa5cdc56d043019a733c845d55409e2a", size = 10746076, upload-time = "2026-03-24T22:32:26.37Z" }, + { url = "https://files.pythonhosted.org/packages/e7/ed/aa958ccbcd85cc206600e48fbf0a1c27aef54b4b90112d9a73f69ed0c739/ty-0.0.25-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f03c5c5b5c10355ea030cbe3cd93b2e759b9492c66688288ea03a68086069f2e", size = 11287331, upload-time = "2026-03-24T22:32:21.607Z" }, + { url = "https://files.pythonhosted.org/packages/26/e4/f4a004e1952e6042f5bfeeb7d09cffb379270ef009d9f8568471863e86e6/ty-0.0.25-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7fc1ef49cd6262eb9223ccf6e258ac899aaa53e7dc2151ba65a2c9fa248dfa75", size = 11028804, upload-time = "2026-03-24T22:32:39.088Z" }, + { url = "https://files.pythonhosted.org/packages/56/32/5c15bb8ea20ed54d43c734f253a2a5da95d41474caecf4ef3682df9f68f5/ty-0.0.25-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7ad98da1393161096235a387cc36abecd31861060c68416761eccdb7c1bc326b", size = 10845246, upload-time = "2026-03-24T22:32:41.33Z" }, + { url = "https://files.pythonhosted.org/packages/6f/fe/4ddd83e810c8682fcfada0d1c9d38936a34a024d32d7736075c1e53a038e/ty-0.0.25-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:2d4336aa5381eb4eab107c3dec75fe22943a648ef6646f5a8431ef1c8cdabb66", size = 10233515, upload-time = "2026-03-24T22:32:17.012Z" }, + { url = "https://files.pythonhosted.org/packages/ad/db/9fe54f6fb952e5b218f2e661e64ed656512edf2046cfbb9c159558e255db/ty-0.0.25-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:e10ed39564227de2b7bd89398250b65daaedbef15a25cef8eee70078f5d9e0b2", size = 10275289, upload-time = "2026-03-24T22:32:28.21Z" }, + { url = "https://files.pythonhosted.org/packages/b1/e0/090d7b33791b42bc7ec29463ac6a634738e16b289e027608ebe542682773/ty-0.0.25-py3-none-musllinux_1_2_i686.whl", hash = "sha256:aca04e9ed9b61c706064a1c0b71a247c3f92f373d0222103f3bc54b649421796", size = 10461195, upload-time = "2026-03-24T22:32:24.252Z" }, + { url = "https://files.pythonhosted.org/packages/42/31/5bf12bce01b80b72a7a4e627380779b41510e730f6000862a1d078e423f7/ty-0.0.25-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:18a5443e4ef339c1bd8c57fc13112c22080617ea582bfc22b497d82d65361325", size = 10931471, upload-time = "2026-03-24T22:32:14.985Z" }, + { url = "https://files.pythonhosted.org/packages/6a/5e/ab60c11f8a6dd2a0ae96daac83458ef2e9be1ae70481d1ad9c59d3eaf20f/ty-0.0.25-py3-none-win32.whl", hash = "sha256:a685b9a611b69195b5a557e05dbb7ebcd12815f6c32fb27fdf15edeb1fa33d8f", size = 9835974, upload-time = "2026-03-24T22:32:36.86Z" }, + { url = "https://files.pythonhosted.org/packages/41/55/625acc2ef34646268bc2baa8fdd6e22fb47cd5965e2acd3be92c687fb6b0/ty-0.0.25-py3-none-win_amd64.whl", hash = "sha256:0d4d37a1f1ab7f2669c941c38c65144ff223eb51ececd7ccfc0d623afbc0f729", size = 10815449, upload-time = "2026-03-24T22:32:11.031Z" }, + { url = "https://files.pythonhosted.org/packages/82/c7/0147bfb543df97740b45b222c54ff79ef20fa57f14b9d2c1dab3cd7d3faa/ty-0.0.25-py3-none-win_arm64.whl", hash = "sha256:d80b8cd965cbacbfd887ac2d985f5b6da09b7aa3569371e2894e0b30b26b89cd", size = 10225494, upload-time = "2026-03-24T22:32:30.611Z" }, ] [[package]] From cf0dffaa75553942c90f059922041cfccfe03345 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C6=B0u=20Quang=20V=C5=A9?= Date: Thu, 2 Apr 2026 12:56:41 +0700 Subject: [PATCH 234/291] Update dependencies to latest versions --- app/server/middleware.py | 2 +- app/services/lmdb.py | 7 +- pyproject.toml | 6 +- scripts/dump_lmdb.py | 5 +- uv.lock | 154 +++++++++++++++++++-------------------- 5 files changed, 88 insertions(+), 86 deletions(-) diff --git a/app/server/middleware.py b/app/server/middleware.py index 07840be..a0593b4 100644 --- a/app/server/middleware.py +++ b/app/server/middleware.py @@ -113,7 +113,7 @@ def add_cors_middleware(app: FastAPI): if g_config.cors.enabled: cors = g_config.cors app.add_middleware( - CORSMiddleware, # type: ignore + CORSMiddleware, allow_origins=cors.allow_origins, allow_credentials=cors.allow_credentials, allow_methods=cors.allow_methods, diff --git a/app/services/lmdb.py b/app/services/lmdb.py index 2f45193..3284b8d 100644 --- a/app/services/lmdb.py +++ b/app/services/lmdb.py @@ -200,10 +200,11 @@ def _get_transaction(self, write: bool = False) -> Generator[Transaction]: raise @staticmethod - def _decode_index_value(data: bytes) -> list[str]: + def _decode_index_value(data: bytes | memoryview) -> list[str]: """Decode index value, handling both legacy single-string and new list-of-strings formats.""" if not data: return [] + data = bytes(data) if data.startswith(b"["): try: val = orjson.loads(data) @@ -452,7 +453,7 @@ def keys(self, prefix: str = "", limit: int | None = None) -> list[str]: count = 0 for key, _ in cursor: - key_str = key.decode("utf-8") + key_str = bytes(key).decode("utf-8") # Skip internal index mappings if key_str.startswith(self.HASH_LOOKUP_PREFIX) or key_str.startswith( self.FUZZY_LOOKUP_PREFIX @@ -484,7 +485,7 @@ def cleanup_expired(self, retention_days: int | None = None) -> int: with self._get_transaction(write=False) as txn: cursor = txn.cursor() for key_bytes, value_bytes in cursor: - key_str = key_bytes.decode("utf-8") + key_str = bytes(key_bytes).decode("utf-8") if key_str.startswith(self.HASH_LOOKUP_PREFIX) or key_str.startswith( self.FUZZY_LOOKUP_PREFIX ): diff --git a/pyproject.toml b/pyproject.toml index 49a424f..6113b27 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,12 +6,12 @@ readme = "README.md" requires-python = "==3.13.*" dependencies = [ "curl-cffi>=0.14.0", - "fastapi>=0.135.2", + "fastapi>=0.135.3", "gemini-webapi>=1.21.0", "httptools>=0.7.1", - "lmdb>=2.1.1", + "lmdb>=2.2.0", "loguru>=0.7.3", - "orjson>=3.11.7", + "orjson>=3.11.8", "pydantic-settings[yaml]>=2.13.1", "uvicorn>=0.42.0", "uvloop>=0.22.1; sys_platform != 'win32'", diff --git a/scripts/dump_lmdb.py b/scripts/dump_lmdb.py index 15ce5df..3ef4805 100644 --- a/scripts/dump_lmdb.py +++ b/scripts/dump_lmdb.py @@ -8,8 +8,9 @@ from lmdb import Transaction -def _decode_value(value: bytes) -> Any: +def _decode_value(value: bytes | memoryview) -> Any: """Decode a value from LMDB to Python data.""" + value = bytes(value) try: return orjson.loads(value) except orjson.JSONDecodeError: @@ -20,7 +21,7 @@ def _dump_all(txn: Transaction) -> list[dict[str, Any]]: """Return all records from the database.""" result: list[dict[str, Any]] = [] for key, value in txn.cursor(): - result.append({"key": key.decode("utf-8"), "value": _decode_value(value)}) + result.append({"key": bytes(key).decode("utf-8"), "value": _decode_value(value)}) return result diff --git a/uv.lock b/uv.lock index d7ddf48..7dac18c 100644 --- a/uv.lock +++ b/uv.lock @@ -110,7 +110,7 @@ wheels = [ [[package]] name = "fastapi" -version = "0.135.2" +version = "0.135.3" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "annotated-doc" }, @@ -119,9 +119,9 @@ dependencies = [ { name = "typing-extensions" }, { name = "typing-inspection" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/c4/73/5903c4b13beae98618d64eb9870c3fac4f605523dd0312ca5c80dadbd5b9/fastapi-0.135.2.tar.gz", hash = "sha256:88a832095359755527b7f63bb4c6bc9edb8329a026189eed83d6c1afcf419d56", size = 395833, upload-time = "2026-03-23T14:12:41.697Z" } +sdist = { url = "https://files.pythonhosted.org/packages/f7/e6/7adb4c5fa231e82c35b8f5741a9f2d055f520c29af5546fd70d3e8e1cd2e/fastapi-0.135.3.tar.gz", hash = "sha256:bd6d7caf1a2bdd8d676843cdcd2287729572a1ef524fc4d65c17ae002a1be654", size = 396524, upload-time = "2026-04-01T16:23:58.188Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/8f/ea/18f6d0457f9efb2fc6fa594857f92810cadb03024975726db6546b3d6fcf/fastapi-0.135.2-py3-none-any.whl", hash = "sha256:0af0447d541867e8db2a6a25c23a8c4bd80e2394ac5529bd87501bbb9e240ca5", size = 117407, upload-time = "2026-03-23T14:12:43.284Z" }, + { url = "https://files.pythonhosted.org/packages/84/a4/5caa2de7f917a04ada20018eccf60d6cc6145b0199d55ca3711b0fc08312/fastapi-0.135.3-py3-none-any.whl", hash = "sha256:9b0f590c813acd13d0ab43dd8494138eb58e484bfac405db1f3187cfc5810d98", size = 117734, upload-time = "2026-04-01T16:23:59.328Z" }, ] [[package]] @@ -156,12 +156,12 @@ dev = [ [package.metadata] requires-dist = [ { name = "curl-cffi", specifier = ">=0.14.0" }, - { name = "fastapi", specifier = ">=0.135.2" }, + { name = "fastapi", specifier = ">=0.135.3" }, { name = "gemini-webapi", git = "https://github.com/HanaokaYuzu/Gemini-API.git" }, { name = "httptools", specifier = ">=0.7.1" }, - { name = "lmdb", specifier = ">=2.1.1" }, + { name = "lmdb", specifier = ">=2.2.0" }, { name = "loguru", specifier = ">=0.7.3" }, - { name = "orjson", specifier = ">=3.11.7" }, + { name = "orjson", specifier = ">=3.11.8" }, { name = "pydantic-settings", extras = ["yaml"], specifier = ">=2.13.1" }, { name = "pytest", marker = "extra == 'dev'" }, { name = "ruff", marker = "extra == 'dev'" }, @@ -176,8 +176,8 @@ dev = [{ name = "gemini-fastapi", extras = ["dev"] }] [[package]] name = "gemini-webapi" -version = "1.21.0.post23" -source = { git = "https://github.com/HanaokaYuzu/Gemini-API.git#7b669588f5f8110424d3678440eb558af983991c" } +version = "1.21.0.post32" +source = { git = "https://github.com/HanaokaYuzu/Gemini-API.git#6675735f294ecf91e1e101dbb37a7e2ec2eef597" } dependencies = [ { name = "curl-cffi" }, { name = "loguru" }, @@ -229,16 +229,16 @@ wheels = [ [[package]] name = "lmdb" -version = "2.1.1" +version = "2.2.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/5a/4e/d78a06af228216102fcb4b703f4b6a2f565978224d5623e20e02d90aeed3/lmdb-2.1.1.tar.gz", hash = "sha256:0317062326ae2f66e3bbde6400907651ea58c27a250097511a28d13c467fa5f1", size = 913160, upload-time = "2026-03-19T13:56:26.139Z" } +sdist = { url = "https://files.pythonhosted.org/packages/21/44/d94934efaf8f887b6959f131fde740fcaa831edfd13eb5425574637cddd5/lmdb-2.2.0.tar.gz", hash = "sha256:53020e20305c043ea6e68089bc242d744fba6073cdb268332299ba6dda2886d4", size = 933189, upload-time = "2026-03-30T01:26:19.049Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/af/64/0ad40c3d06f89d7e5b6894537a0bd2829b592ef191f61b704157ad641f33/lmdb-2.1.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f43f7a7907fa726597d235dd83febe7ec47b45b78952b848f0bdf50b58fe1bf1", size = 109123, upload-time = "2026-03-19T13:55:58.522Z" }, - { url = "https://files.pythonhosted.org/packages/a6/62/86257b169acc61282110166581167f099c08bbb76aea7bc4da0c0b64c8b2/lmdb-2.1.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:24382db5cd139866d222471f4fb14fb511b16bf1ed0686bdf7fc808bf07ed8a4", size = 107803, upload-time = "2026-03-19T13:55:59.907Z" }, - { url = "https://files.pythonhosted.org/packages/26/2d/8889fa81eb232dd5fec10f3178e22f3ae4f385c46be6124e29709f3bfdbb/lmdb-2.1.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:76e8c14a76bb7695c1778181dce9c352fb565b5e113c74981ec692d5d6820efb", size = 324703, upload-time = "2026-03-19T13:56:01.309Z" }, - { url = "https://files.pythonhosted.org/packages/dc/d9/ec2e2370d35214e12abd1c9dada369c460e694f0c6fe385a200a2a25eaf3/lmdb-2.1.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7700999c4fa7762577d4b3deedd48f6c25ce396dfb17f61dd48f50dcf99f78d6", size = 328101, upload-time = "2026-03-19T13:56:02.806Z" }, - { url = "https://files.pythonhosted.org/packages/24/c7/e65ca28f46479e92dfc7250dab5259ae6eaa0e5075db47f52a4a1462adb1/lmdb-2.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:74e4442102423e185347108cc67933411ec13e41866f57f6e9868c6ef5642b88", size = 104800, upload-time = "2026-03-19T13:56:04.173Z" }, - { url = "https://files.pythonhosted.org/packages/33/51/e8f12e4b7a0ef82b42d8a37201db99f8dd7d26113a6b0cbf5c441692e2ad/lmdb-2.1.1-cp313-cp313-win_arm64.whl", hash = "sha256:26b0412a38fffd8587becde3c2b0ee606b8906ae0ee5060b0ed6d44610703dec", size = 99048, upload-time = "2026-03-19T13:56:05.499Z" }, + { url = "https://files.pythonhosted.org/packages/64/43/543af71e8fa4c56623bb89c358121ab806426f26685f11539fe5452deffa/lmdb-2.2.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:36e0cbe6b7d59f6e19b448942c5f9e91674f596a802743258f82e926a9a09632", size = 113550, upload-time = "2026-03-30T01:25:55.727Z" }, + { url = "https://files.pythonhosted.org/packages/22/2c/4702d36c0073737554b20d1d62e879a066df963482f8e514866588ddd82d/lmdb-2.2.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e5d7a9dfd279a5884806fd478244961e4483cc6d7eb769caed1d7019a8608c20", size = 112135, upload-time = "2026-03-30T01:25:56.809Z" }, + { url = "https://files.pythonhosted.org/packages/2f/43/d015fea326ed0a634107f29740b002170a462b6d2481e509105c685520f5/lmdb-2.2.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d0dbe7902b2cdb60bf6c893f307ef2b2a5039afd22f029515b86183f05ab1353", size = 332108, upload-time = "2026-03-30T01:25:57.907Z" }, + { url = "https://files.pythonhosted.org/packages/bb/c9/503e7f173994b514936badcbcb7fa9f89a07a3cfe596c6fb95b1b91b8d70/lmdb-2.2.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9c576cdb163ae61a7ef6eecbc20a6025a4abe085491c1dc0c667d726f4926b53", size = 336017, upload-time = "2026-03-30T01:25:59.234Z" }, + { url = "https://files.pythonhosted.org/packages/3e/94/b3b064acfd2f8acf5aaa53fff2c43963dbc1932ba8b8df4e27d75bf6a34a/lmdb-2.2.0-cp313-cp313-win_amd64.whl", hash = "sha256:746eebcd4c0aeaf0eb2f897028929d270c5bc80ef4918500eec16db6f26f3fcc", size = 109574, upload-time = "2026-03-30T01:26:00.324Z" }, + { url = "https://files.pythonhosted.org/packages/b9/10/dc7488d1effc339cd9470f9d22ec0fd7052a3d4fdfae87765ecd41cb2e59/lmdb-2.2.0-cp313-cp313-win_arm64.whl", hash = "sha256:006153aac9fb0415a5f3e8ac88789e5730dba3dd0743cd84c95e3951ff68bc3a", size = 103810, upload-time = "2026-03-30T01:26:01.559Z" }, ] [[package]] @@ -256,25 +256,25 @@ wheels = [ [[package]] name = "orjson" -version = "3.11.7" +version = "3.11.8" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/53/45/b268004f745ede84e5798b48ee12b05129d19235d0e15267aa57dcdb400b/orjson-3.11.7.tar.gz", hash = "sha256:9b1a67243945819ce55d24a30b59d6a168e86220452d2c96f4d1f093e71c0c49", size = 6144992, upload-time = "2026-02-02T15:38:49.29Z" } +sdist = { url = "https://files.pythonhosted.org/packages/9d/1b/2024d06792d0779f9dbc51531b61c24f76c75b9f4ce05e6f3377a1814cea/orjson-3.11.8.tar.gz", hash = "sha256:96163d9cdc5a202703e9ad1b9ae757d5f0ca62f4fa0cc93d1f27b0e180cc404e", size = 5603832, upload-time = "2026-03-31T16:16:27.878Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/89/25/6e0e52cac5aab51d7b6dcd257e855e1dec1c2060f6b28566c509b4665f62/orjson-3.11.7-cp313-cp313-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:1d98b30cc1313d52d4af17d9c3d307b08389752ec5f2e5febdfada70b0f8c733", size = 228390, upload-time = "2026-02-02T15:38:06.8Z" }, - { url = "https://files.pythonhosted.org/packages/a5/29/a77f48d2fc8a05bbc529e5ff481fb43d914f9e383ea2469d4f3d51df3d00/orjson-3.11.7-cp313-cp313-macosx_15_0_arm64.whl", hash = "sha256:d897e81f8d0cbd2abb82226d1860ad2e1ab3ff16d7b08c96ca00df9d45409ef4", size = 125189, upload-time = "2026-02-02T15:38:08.181Z" }, - { url = "https://files.pythonhosted.org/packages/89/25/0a16e0729a0e6a1504f9d1a13cdd365f030068aab64cec6958396b9969d7/orjson-3.11.7-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:814be4b49b228cfc0b3c565acf642dd7d13538f966e3ccde61f4f55be3e20785", size = 128106, upload-time = "2026-02-02T15:38:09.41Z" }, - { url = "https://files.pythonhosted.org/packages/66/da/a2e505469d60666a05ab373f1a6322eb671cb2ba3a0ccfc7d4bc97196787/orjson-3.11.7-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d06e5c5fed5caedd2e540d62e5b1c25e8c82431b9e577c33537e5fa4aa909539", size = 123363, upload-time = "2026-02-02T15:38:10.73Z" }, - { url = "https://files.pythonhosted.org/packages/23/bf/ed73f88396ea35c71b38961734ea4a4746f7ca0768bf28fd551d37e48dd0/orjson-3.11.7-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:31c80ce534ac4ea3739c5ee751270646cbc46e45aea7576a38ffec040b4029a1", size = 129007, upload-time = "2026-02-02T15:38:12.138Z" }, - { url = "https://files.pythonhosted.org/packages/73/3c/b05d80716f0225fc9008fbf8ab22841dcc268a626aa550561743714ce3bf/orjson-3.11.7-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f50979824bde13d32b4320eedd513431c921102796d86be3eee0b58e58a3ecd1", size = 141667, upload-time = "2026-02-02T15:38:13.398Z" }, - { url = "https://files.pythonhosted.org/packages/61/e8/0be9b0addd9bf86abfc938e97441dcd0375d494594b1c8ad10fe57479617/orjson-3.11.7-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9e54f3808e2b6b945078c41aa8d9b5834b28c50843846e97807e5adb75fa9705", size = 130832, upload-time = "2026-02-02T15:38:14.698Z" }, - { url = "https://files.pythonhosted.org/packages/c9/ec/c68e3b9021a31d9ec15a94931db1410136af862955854ed5dd7e7e4f5bff/orjson-3.11.7-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a12b80df61aab7b98b490fe9e4879925ba666fccdfcd175252ce4d9035865ace", size = 133373, upload-time = "2026-02-02T15:38:16.109Z" }, - { url = "https://files.pythonhosted.org/packages/d2/45/f3466739aaafa570cc8e77c6dbb853c48bf56e3b43738020e2661e08b0ac/orjson-3.11.7-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:996b65230271f1a97026fd0e6a753f51fbc0c335d2ad0c6201f711b0da32693b", size = 138307, upload-time = "2026-02-02T15:38:17.453Z" }, - { url = "https://files.pythonhosted.org/packages/e1/84/9f7f02288da1ffb31405c1be07657afd1eecbcb4b64ee2817b6fe0f785fa/orjson-3.11.7-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:ab49d4b2a6a1d415ddb9f37a21e02e0d5dbfe10b7870b21bf779fc21e9156157", size = 408695, upload-time = "2026-02-02T15:38:18.831Z" }, - { url = "https://files.pythonhosted.org/packages/18/07/9dd2f0c0104f1a0295ffbe912bc8d63307a539b900dd9e2c48ef7810d971/orjson-3.11.7-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:390a1dce0c055ddf8adb6aa94a73b45a4a7d7177b5c584b8d1c1947f2ba60fb3", size = 144099, upload-time = "2026-02-02T15:38:20.28Z" }, - { url = "https://files.pythonhosted.org/packages/a5/66/857a8e4a3292e1f7b1b202883bcdeb43a91566cf59a93f97c53b44bd6801/orjson-3.11.7-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1eb80451a9c351a71dfaf5b7ccc13ad065405217726b59fdbeadbcc544f9d223", size = 134806, upload-time = "2026-02-02T15:38:22.186Z" }, - { url = "https://files.pythonhosted.org/packages/0a/5b/6ebcf3defc1aab3a338ca777214966851e92efb1f30dc7fc8285216e6d1b/orjson-3.11.7-cp313-cp313-win32.whl", hash = "sha256:7477aa6a6ec6139c5cb1cc7b214643592169a5494d200397c7fc95d740d5fcf3", size = 127914, upload-time = "2026-02-02T15:38:23.511Z" }, - { url = "https://files.pythonhosted.org/packages/00/04/c6f72daca5092e3117840a1b1e88dfc809cc1470cf0734890d0366b684a1/orjson-3.11.7-cp313-cp313-win_amd64.whl", hash = "sha256:b9f95dcdea9d4f805daa9ddf02617a89e484c6985fa03055459f90e87d7a0757", size = 124986, upload-time = "2026-02-02T15:38:24.836Z" }, - { url = "https://files.pythonhosted.org/packages/03/ba/077a0f6f1085d6b806937246860fafbd5b17f3919c70ee3f3d8d9c713f38/orjson-3.11.7-cp313-cp313-win_arm64.whl", hash = "sha256:800988273a014a0541483dc81021247d7eacb0c845a9d1a34a422bc718f41539", size = 126045, upload-time = "2026-02-02T15:38:26.216Z" }, + { url = "https://files.pythonhosted.org/packages/66/7f/95fba509bb2305fab0073558f1e8c3a2ec4b2afe58ed9fcb7d3b8beafe94/orjson-3.11.8-cp313-cp313-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:3f23426851d98478c8970da5991f84784a76682213cd50eb73a1da56b95239dc", size = 229180, upload-time = "2026-03-31T16:15:36.426Z" }, + { url = "https://files.pythonhosted.org/packages/f6/9d/b237215c743ca073697d759b5503abd2cb8a0d7b9c9e21f524bcf176ab66/orjson-3.11.8-cp313-cp313-macosx_15_0_arm64.whl", hash = "sha256:ebaed4cef74a045b83e23537b52ef19a367c7e3f536751e355a2a394f8648559", size = 128754, upload-time = "2026-03-31T16:15:38.049Z" }, + { url = "https://files.pythonhosted.org/packages/42/3d/27d65b6d11e63f133781425f132807aef793ed25075fec686fc8e46dd528/orjson-3.11.8-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:97c8f5d3b62380b70c36ffacb2a356b7c6becec86099b177f73851ba095ef623", size = 131877, upload-time = "2026-03-31T16:15:39.484Z" }, + { url = "https://files.pythonhosted.org/packages/dd/cc/faee30cd8f00421999e40ef0eba7332e3a625ce91a58200a2f52c7fef235/orjson-3.11.8-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:436c4922968a619fb7fef1ccd4b8b3a76c13b67d607073914d675026e911a65c", size = 130361, upload-time = "2026-03-31T16:15:41.274Z" }, + { url = "https://files.pythonhosted.org/packages/5c/bb/a6c55896197f97b6d4b4e7c7fd77e7235517c34f5d6ad5aadd43c54c6d7c/orjson-3.11.8-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1ab359aff0436d80bfe8a23b46b5fea69f1e18aaf1760a709b4787f1318b317f", size = 135521, upload-time = "2026-03-31T16:15:42.758Z" }, + { url = "https://files.pythonhosted.org/packages/9c/7c/ca3a3525aa32ff636ebb1778e77e3587b016ab2edb1b618b36ba96f8f2c0/orjson-3.11.8-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f89b6d0b3a8d81e1929d3ab3d92bbc225688bd80a770c49432543928fe09ac55", size = 146862, upload-time = "2026-03-31T16:15:44.341Z" }, + { url = "https://files.pythonhosted.org/packages/3c/0c/18a9d7f18b5edd37344d1fd5be17e94dc652c67826ab749c6e5948a78112/orjson-3.11.8-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:29c009e7a2ca9ad0ed1376ce20dd692146a5d9fe4310848904b6b4fee5c5c137", size = 132847, upload-time = "2026-03-31T16:15:46.368Z" }, + { url = "https://files.pythonhosted.org/packages/23/91/7e722f352ad67ca573cee44de2a58fb810d0f4eb4e33276c6a557979fd8a/orjson-3.11.8-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:705b895b781b3e395c067129d8551655642dfe9437273211d5404e87ac752b53", size = 133637, upload-time = "2026-03-31T16:15:48.123Z" }, + { url = "https://files.pythonhosted.org/packages/af/04/32845ce13ac5bd1046ddb02ac9432ba856cc35f6d74dde95864fe0ad5523/orjson-3.11.8-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:88006eda83858a9fdf73985ce3804e885c2befb2f506c9a3723cdeb5a2880e3e", size = 141906, upload-time = "2026-03-31T16:15:49.626Z" }, + { url = "https://files.pythonhosted.org/packages/02/5e/c551387ddf2d7106d9039369862245c85738b828844d13b99ccb8d61fd06/orjson-3.11.8-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:55120759e61309af7fcf9e961c6f6af3dde5921cdb3ee863ef63fd9db126cae6", size = 423722, upload-time = "2026-03-31T16:15:51.176Z" }, + { url = "https://files.pythonhosted.org/packages/00/a3/ecfe62434096f8a794d4976728cb59bcfc4a643977f21c2040545d37eb4c/orjson-3.11.8-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:98bdc6cb889d19bed01de46e67574a2eab61f5cc6b768ed50e8ac68e9d6ffab6", size = 147801, upload-time = "2026-03-31T16:15:52.939Z" }, + { url = "https://files.pythonhosted.org/packages/18/6d/0dce10b9f6643fdc59d99333871a38fa5a769d8e2fc34a18e5d2bfdee900/orjson-3.11.8-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:708c95f925a43ab9f34625e45dcdadf09ec8a6e7b664a938f2f8d5650f6c090b", size = 136460, upload-time = "2026-03-31T16:15:54.431Z" }, + { url = "https://files.pythonhosted.org/packages/01/d6/6dde4f31842d87099238f1f07b459d24edc1a774d20687187443ab044191/orjson-3.11.8-cp313-cp313-win32.whl", hash = "sha256:01c4e5a6695dc09098f2e6468a251bc4671c50922d4d745aff1a0a33a0cf5b8d", size = 131956, upload-time = "2026-03-31T16:15:56.081Z" }, + { url = "https://files.pythonhosted.org/packages/c1/f9/4e494a56e013db957fb77186b818b916d4695b8fa2aa612364974160e91b/orjson-3.11.8-cp313-cp313-win_amd64.whl", hash = "sha256:c154a35dd1330707450bb4d4e7dd1f17fa6f42267a40c1e8a1daa5e13719b4b8", size = 127410, upload-time = "2026-03-31T16:15:57.54Z" }, + { url = "https://files.pythonhosted.org/packages/57/7f/803203d00d6edb6e9e7eef421d4e1adbb5ea973e40b3533f3cfd9aeb374e/orjson-3.11.8-cp313-cp313-win_arm64.whl", hash = "sha256:4861bde57f4d253ab041e374f44023460e60e71efaa121f3c5f0ed457c3a701e", size = 127338, upload-time = "2026-03-31T16:15:59.106Z" }, ] [[package]] @@ -365,11 +365,11 @@ yaml = [ [[package]] name = "pygments" -version = "2.19.2" +version = "2.20.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" } +sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" }, + { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, ] [[package]] @@ -417,27 +417,27 @@ wheels = [ [[package]] name = "ruff" -version = "0.15.7" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/a1/22/9e4f66ee588588dc6c9af6a994e12d26e19efbe874d1a909d09a6dac7a59/ruff-0.15.7.tar.gz", hash = "sha256:04f1ae61fc20fe0b148617c324d9d009b5f63412c0b16474f3d5f1a1a665f7ac", size = 4601277, upload-time = "2026-03-19T16:26:22.605Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/41/2f/0b08ced94412af091807b6119ca03755d651d3d93a242682bf020189db94/ruff-0.15.7-py3-none-linux_armv6l.whl", hash = "sha256:a81cc5b6910fb7dfc7c32d20652e50fa05963f6e13ead3c5915c41ac5d16668e", size = 10489037, upload-time = "2026-03-19T16:26:32.47Z" }, - { url = "https://files.pythonhosted.org/packages/91/4a/82e0fa632e5c8b1eba5ee86ecd929e8ff327bbdbfb3c6ac5d81631bef605/ruff-0.15.7-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:722d165bd52403f3bdabc0ce9e41fc47070ac56d7a91b4e0d097b516a53a3477", size = 10955433, upload-time = "2026-03-19T16:27:00.205Z" }, - { url = "https://files.pythonhosted.org/packages/ab/10/12586735d0ff42526ad78c049bf51d7428618c8b5c467e72508c694119df/ruff-0.15.7-py3-none-macosx_11_0_arm64.whl", hash = "sha256:7fbc2448094262552146cbe1b9643a92f66559d3761f1ad0656d4991491af49e", size = 10269302, upload-time = "2026-03-19T16:26:26.183Z" }, - { url = "https://files.pythonhosted.org/packages/eb/5d/32b5c44ccf149a26623671df49cbfbd0a0ae511ff3df9d9d2426966a8d57/ruff-0.15.7-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6b39329b60eba44156d138275323cc726bbfbddcec3063da57caa8a8b1d50adf", size = 10607625, upload-time = "2026-03-19T16:27:03.263Z" }, - { url = "https://files.pythonhosted.org/packages/5d/f1/f0001cabe86173aaacb6eb9bb734aa0605f9a6aa6fa7d43cb49cbc4af9c9/ruff-0.15.7-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:87768c151808505f2bfc93ae44e5f9e7c8518943e5074f76ac21558ef5627c85", size = 10324743, upload-time = "2026-03-19T16:27:09.791Z" }, - { url = "https://files.pythonhosted.org/packages/7a/87/b8a8f3d56b8d848008559e7c9d8bf367934d5367f6d932ba779456e2f73b/ruff-0.15.7-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:fb0511670002c6c529ec66c0e30641c976c8963de26a113f3a30456b702468b0", size = 11138536, upload-time = "2026-03-19T16:27:06.101Z" }, - { url = "https://files.pythonhosted.org/packages/e4/f2/4fd0d05aab0c5934b2e1464784f85ba2eab9d54bffc53fb5430d1ed8b829/ruff-0.15.7-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e0d19644f801849229db8345180a71bee5407b429dd217f853ec515e968a6912", size = 11994292, upload-time = "2026-03-19T16:26:48.718Z" }, - { url = "https://files.pythonhosted.org/packages/64/22/fc4483871e767e5e95d1622ad83dad5ebb830f762ed0420fde7dfa9d9b08/ruff-0.15.7-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4806d8e09ef5e84eb19ba833d0442f7e300b23fe3f0981cae159a248a10f0036", size = 11398981, upload-time = "2026-03-19T16:26:54.513Z" }, - { url = "https://files.pythonhosted.org/packages/b0/99/66f0343176d5eab02c3f7fcd2de7a8e0dd7a41f0d982bee56cd1c24db62b/ruff-0.15.7-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dce0896488562f09a27b9c91b1f58a097457143931f3c4d519690dea54e624c5", size = 11242422, upload-time = "2026-03-19T16:26:29.277Z" }, - { url = "https://files.pythonhosted.org/packages/5d/3a/a7060f145bfdcce4c987ea27788b30c60e2c81d6e9a65157ca8afe646328/ruff-0.15.7-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:1852ce241d2bc89e5dc823e03cff4ce73d816b5c6cdadd27dbfe7b03217d2a12", size = 11232158, upload-time = "2026-03-19T16:26:42.321Z" }, - { url = "https://files.pythonhosted.org/packages/a7/53/90fbb9e08b29c048c403558d3cdd0adf2668b02ce9d50602452e187cd4af/ruff-0.15.7-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:5f3e4b221fb4bd293f79912fc5e93a9063ebd6d0dcbd528f91b89172a9b8436c", size = 10577861, upload-time = "2026-03-19T16:26:57.459Z" }, - { url = "https://files.pythonhosted.org/packages/2f/aa/5f486226538fe4d0f0439e2da1716e1acf895e2a232b26f2459c55f8ddad/ruff-0.15.7-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:b15e48602c9c1d9bdc504b472e90b90c97dc7d46c7028011ae67f3861ceba7b4", size = 10327310, upload-time = "2026-03-19T16:26:35.909Z" }, - { url = "https://files.pythonhosted.org/packages/99/9e/271afdffb81fe7bfc8c43ba079e9d96238f674380099457a74ccb3863857/ruff-0.15.7-py3-none-musllinux_1_2_i686.whl", hash = "sha256:1b4705e0e85cedc74b0a23cf6a179dbb3df184cb227761979cc76c0440b5ab0d", size = 10840752, upload-time = "2026-03-19T16:26:45.723Z" }, - { url = "https://files.pythonhosted.org/packages/bf/29/a4ae78394f76c7759953c47884eb44de271b03a66634148d9f7d11e721bd/ruff-0.15.7-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:112c1fa316a558bb34319282c1200a8bf0495f1b735aeb78bfcb2991e6087580", size = 11336961, upload-time = "2026-03-19T16:26:39.076Z" }, - { url = "https://files.pythonhosted.org/packages/26/6b/8786ba5736562220d588a2f6653e6c17e90c59ced34a2d7b512ef8956103/ruff-0.15.7-py3-none-win32.whl", hash = "sha256:6d39e2d3505b082323352f733599f28169d12e891f7dd407f2d4f54b4c2886de", size = 10582538, upload-time = "2026-03-19T16:26:15.992Z" }, - { url = "https://files.pythonhosted.org/packages/2b/e9/346d4d3fffc6871125e877dae8d9a1966b254fbd92a50f8561078b88b099/ruff-0.15.7-py3-none-win_amd64.whl", hash = "sha256:4d53d712ddebcd7dace1bc395367aec12c057aacfe9adbb6d832302575f4d3a1", size = 11755839, upload-time = "2026-03-19T16:26:19.897Z" }, - { url = "https://files.pythonhosted.org/packages/8f/e8/726643a3ea68c727da31570bde48c7a10f1aa60eddd628d94078fec586ff/ruff-0.15.7-py3-none-win_arm64.whl", hash = "sha256:18e8d73f1c3fdf27931497972250340f92e8c861722161a9caeb89a58ead6ed2", size = 11023304, upload-time = "2026-03-19T16:26:51.669Z" }, +version = "0.15.8" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/14/b0/73cf7550861e2b4824950b8b52eebdcc5adc792a00c514406556c5b80817/ruff-0.15.8.tar.gz", hash = "sha256:995f11f63597ee362130d1d5a327a87cb6f3f5eae3094c620bcc632329a4d26e", size = 4610921, upload-time = "2026-03-26T18:39:38.675Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4a/92/c445b0cd6da6e7ae51e954939cb69f97e008dbe750cfca89b8cedc081be7/ruff-0.15.8-py3-none-linux_armv6l.whl", hash = "sha256:cbe05adeba76d58162762d6b239c9056f1a15a55bd4b346cfd21e26cd6ad7bc7", size = 10527394, upload-time = "2026-03-26T18:39:41.566Z" }, + { url = "https://files.pythonhosted.org/packages/eb/92/f1c662784d149ad1414cae450b082cf736430c12ca78367f20f5ed569d65/ruff-0.15.8-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:d3e3d0b6ba8dca1b7ef9ab80a28e840a20070c4b62e56d675c24f366ef330570", size = 10905693, upload-time = "2026-03-26T18:39:30.364Z" }, + { url = "https://files.pythonhosted.org/packages/ca/f2/7a631a8af6d88bcef997eb1bf87cc3da158294c57044aafd3e17030613de/ruff-0.15.8-py3-none-macosx_11_0_arm64.whl", hash = "sha256:6ee3ae5c65a42f273f126686353f2e08ff29927b7b7e203b711514370d500de3", size = 10323044, upload-time = "2026-03-26T18:39:33.37Z" }, + { url = "https://files.pythonhosted.org/packages/67/18/1bf38e20914a05e72ef3b9569b1d5c70a7ef26cd188d69e9ca8ef588d5bf/ruff-0.15.8-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fdce027ada77baa448077ccc6ebb2fa9c3c62fd110d8659d601cf2f475858d94", size = 10629135, upload-time = "2026-03-26T18:39:44.142Z" }, + { url = "https://files.pythonhosted.org/packages/d2/e9/138c150ff9af60556121623d41aba18b7b57d95ac032e177b6a53789d279/ruff-0.15.8-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:12e617fc01a95e5821648a6df341d80456bd627bfab8a829f7cfc26a14a4b4a3", size = 10348041, upload-time = "2026-03-26T18:39:52.178Z" }, + { url = "https://files.pythonhosted.org/packages/02/f1/5bfb9298d9c323f842c5ddeb85f1f10ef51516ac7a34ba446c9347d898df/ruff-0.15.8-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:432701303b26416d22ba696c39f2c6f12499b89093b61360abc34bcc9bf07762", size = 11121987, upload-time = "2026-03-26T18:39:55.195Z" }, + { url = "https://files.pythonhosted.org/packages/10/11/6da2e538704e753c04e8d86b1fc55712fdbdcc266af1a1ece7a51fff0d10/ruff-0.15.8-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d910ae974b7a06a33a057cb87d2a10792a3b2b3b35e33d2699fdf63ec8f6b17a", size = 11951057, upload-time = "2026-03-26T18:39:19.18Z" }, + { url = "https://files.pythonhosted.org/packages/83/f0/c9208c5fd5101bf87002fed774ff25a96eea313d305f1e5d5744698dc314/ruff-0.15.8-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2033f963c43949d51e6fdccd3946633c6b37c484f5f98c3035f49c27395a8ab8", size = 11464613, upload-time = "2026-03-26T18:40:06.301Z" }, + { url = "https://files.pythonhosted.org/packages/f8/22/d7f2fabdba4fae9f3b570e5605d5eb4500dcb7b770d3217dca4428484b17/ruff-0.15.8-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0f29b989a55572fb885b77464cf24af05500806ab4edf9a0fd8977f9759d85b1", size = 11257557, upload-time = "2026-03-26T18:39:57.972Z" }, + { url = "https://files.pythonhosted.org/packages/71/8c/382a9620038cf6906446b23ce8632ab8c0811b8f9d3e764f58bedd0c9a6f/ruff-0.15.8-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:ac51d486bf457cdc985a412fb1801b2dfd1bd8838372fc55de64b1510eff4bec", size = 11169440, upload-time = "2026-03-26T18:39:22.205Z" }, + { url = "https://files.pythonhosted.org/packages/4d/0d/0994c802a7eaaf99380085e4e40c845f8e32a562e20a38ec06174b52ef24/ruff-0.15.8-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:c9861eb959edab053c10ad62c278835ee69ca527b6dcd72b47d5c1e5648964f6", size = 10605963, upload-time = "2026-03-26T18:39:46.682Z" }, + { url = "https://files.pythonhosted.org/packages/19/aa/d624b86f5b0aad7cef6bbf9cd47a6a02dfdc4f72c92a337d724e39c9d14b/ruff-0.15.8-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:8d9a5b8ea13f26ae90838afc33f91b547e61b794865374f114f349e9036835fb", size = 10357484, upload-time = "2026-03-26T18:39:49.176Z" }, + { url = "https://files.pythonhosted.org/packages/35/c3/e0b7835d23001f7d999f3895c6b569927c4d39912286897f625736e1fd04/ruff-0.15.8-py3-none-musllinux_1_2_i686.whl", hash = "sha256:c2a33a529fb3cbc23a7124b5c6ff121e4d6228029cba374777bd7649cc8598b8", size = 10830426, upload-time = "2026-03-26T18:40:03.702Z" }, + { url = "https://files.pythonhosted.org/packages/f0/51/ab20b322f637b369383adc341d761eaaa0f0203d6b9a7421cd6e783d81b9/ruff-0.15.8-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:75e5cd06b1cf3f47a3996cfc999226b19aa92e7cce682dcd62f80d7035f98f49", size = 11345125, upload-time = "2026-03-26T18:39:27.799Z" }, + { url = "https://files.pythonhosted.org/packages/37/e6/90b2b33419f59d0f2c4c8a48a4b74b460709a557e8e0064cf33ad894f983/ruff-0.15.8-py3-none-win32.whl", hash = "sha256:bc1f0a51254ba21767bfa9a8b5013ca8149dcf38092e6a9eb704d876de94dc34", size = 10571959, upload-time = "2026-03-26T18:39:36.117Z" }, + { url = "https://files.pythonhosted.org/packages/1f/a2/ef467cb77099062317154c63f234b8a7baf7cb690b99af760c5b68b9ee7f/ruff-0.15.8-py3-none-win_amd64.whl", hash = "sha256:04f79eff02a72db209d47d665ba7ebcad609d8918a134f86cb13dd132159fc89", size = 11743893, upload-time = "2026-03-26T18:39:25.01Z" }, + { url = "https://files.pythonhosted.org/packages/15/e2/77be4fff062fa78d9b2a4dea85d14785dac5f1d0c1fb58ed52331f0ebe28/ruff-0.15.8-py3-none-win_arm64.whl", hash = "sha256:cf891fa8e3bb430c0e7fac93851a5978fc99c8fa2c053b57b118972866f8e5f2", size = 11048175, upload-time = "2026-03-26T18:40:01.06Z" }, ] [[package]] @@ -454,26 +454,26 @@ wheels = [ [[package]] name = "ty" -version = "0.0.25" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/12/bf/3c3147c7237277b0e8a911ff89de7183408be96b31fb42b38edb666d287f/ty-0.0.25.tar.gz", hash = "sha256:8ae3891be17dfb6acab51a2df3a8f8f6c551eb60ea674c10946dc92aae8d4401", size = 5375500, upload-time = "2026-03-24T22:32:34.608Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/97/a4/6c289cbd1474285223124a4ffb55c078dbe9ae1d925d0b6a948643c7f115/ty-0.0.25-py3-none-linux_armv6l.whl", hash = "sha256:26d6d5aede5d54fb055779460f896d9c1473c6fb996716bd11cb90f027d8fee7", size = 10452747, upload-time = "2026-03-24T22:32:32.662Z" }, - { url = "https://files.pythonhosted.org/packages/00/13/74cb9de356b9ceb3f281ab048f8c4ac2207122161b0ac0066886ce129abe/ty-0.0.25-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:aedcfbc7b6b96dbc55b0da78fa02bd049373ff3d8a827f613dadd8bd17d10758", size = 10271349, upload-time = "2026-03-24T22:32:13.041Z" }, - { url = "https://files.pythonhosted.org/packages/0e/93/ffc5a20cc9e14fa9b32b0c54884864bede30d144ce2ae013805bce0c86d0/ty-0.0.25-py3-none-macosx_11_0_arm64.whl", hash = "sha256:0a8fb3c1e28f73618941811e2568dca195178a1a6314651d4ee97086a4497253", size = 9730308, upload-time = "2026-03-24T22:32:19.24Z" }, - { url = "https://files.pythonhosted.org/packages/6d/78/52e05ef32a5f172fce70633a4e19d8e04364271a4322ae12382c7344b0de/ty-0.0.25-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:814870b7f347b5d0276304cddb98a0958f08de183bf159abc920ebe321247ad4", size = 10247664, upload-time = "2026-03-24T22:32:08.669Z" }, - { url = "https://files.pythonhosted.org/packages/c2/64/0d0a47ed0aa1d634c666c2cc15d3b0af4b95d0fd3dbb796032bd493f3433/ty-0.0.25-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:781150e23825dc110cd5e1f50ca3d61664f7a5db5b4a55d5dbf7d3b1e246b917", size = 10261961, upload-time = "2026-03-24T22:32:43.935Z" }, - { url = "https://files.pythonhosted.org/packages/3e/ba/4666b96f0499465efb97c244554107c541d74a1add393e62276b3de9b54f/ty-0.0.25-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ffc81ff2a0143911321251dc81d1c259fa5cdc56d043019a733c845d55409e2a", size = 10746076, upload-time = "2026-03-24T22:32:26.37Z" }, - { url = "https://files.pythonhosted.org/packages/e7/ed/aa958ccbcd85cc206600e48fbf0a1c27aef54b4b90112d9a73f69ed0c739/ty-0.0.25-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f03c5c5b5c10355ea030cbe3cd93b2e759b9492c66688288ea03a68086069f2e", size = 11287331, upload-time = "2026-03-24T22:32:21.607Z" }, - { url = "https://files.pythonhosted.org/packages/26/e4/f4a004e1952e6042f5bfeeb7d09cffb379270ef009d9f8568471863e86e6/ty-0.0.25-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7fc1ef49cd6262eb9223ccf6e258ac899aaa53e7dc2151ba65a2c9fa248dfa75", size = 11028804, upload-time = "2026-03-24T22:32:39.088Z" }, - { url = "https://files.pythonhosted.org/packages/56/32/5c15bb8ea20ed54d43c734f253a2a5da95d41474caecf4ef3682df9f68f5/ty-0.0.25-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7ad98da1393161096235a387cc36abecd31861060c68416761eccdb7c1bc326b", size = 10845246, upload-time = "2026-03-24T22:32:41.33Z" }, - { url = "https://files.pythonhosted.org/packages/6f/fe/4ddd83e810c8682fcfada0d1c9d38936a34a024d32d7736075c1e53a038e/ty-0.0.25-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:2d4336aa5381eb4eab107c3dec75fe22943a648ef6646f5a8431ef1c8cdabb66", size = 10233515, upload-time = "2026-03-24T22:32:17.012Z" }, - { url = "https://files.pythonhosted.org/packages/ad/db/9fe54f6fb952e5b218f2e661e64ed656512edf2046cfbb9c159558e255db/ty-0.0.25-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:e10ed39564227de2b7bd89398250b65daaedbef15a25cef8eee70078f5d9e0b2", size = 10275289, upload-time = "2026-03-24T22:32:28.21Z" }, - { url = "https://files.pythonhosted.org/packages/b1/e0/090d7b33791b42bc7ec29463ac6a634738e16b289e027608ebe542682773/ty-0.0.25-py3-none-musllinux_1_2_i686.whl", hash = "sha256:aca04e9ed9b61c706064a1c0b71a247c3f92f373d0222103f3bc54b649421796", size = 10461195, upload-time = "2026-03-24T22:32:24.252Z" }, - { url = "https://files.pythonhosted.org/packages/42/31/5bf12bce01b80b72a7a4e627380779b41510e730f6000862a1d078e423f7/ty-0.0.25-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:18a5443e4ef339c1bd8c57fc13112c22080617ea582bfc22b497d82d65361325", size = 10931471, upload-time = "2026-03-24T22:32:14.985Z" }, - { url = "https://files.pythonhosted.org/packages/6a/5e/ab60c11f8a6dd2a0ae96daac83458ef2e9be1ae70481d1ad9c59d3eaf20f/ty-0.0.25-py3-none-win32.whl", hash = "sha256:a685b9a611b69195b5a557e05dbb7ebcd12815f6c32fb27fdf15edeb1fa33d8f", size = 9835974, upload-time = "2026-03-24T22:32:36.86Z" }, - { url = "https://files.pythonhosted.org/packages/41/55/625acc2ef34646268bc2baa8fdd6e22fb47cd5965e2acd3be92c687fb6b0/ty-0.0.25-py3-none-win_amd64.whl", hash = "sha256:0d4d37a1f1ab7f2669c941c38c65144ff223eb51ececd7ccfc0d623afbc0f729", size = 10815449, upload-time = "2026-03-24T22:32:11.031Z" }, - { url = "https://files.pythonhosted.org/packages/82/c7/0147bfb543df97740b45b222c54ff79ef20fa57f14b9d2c1dab3cd7d3faa/ty-0.0.25-py3-none-win_arm64.whl", hash = "sha256:d80b8cd965cbacbfd887ac2d985f5b6da09b7aa3569371e2894e0b30b26b89cd", size = 10225494, upload-time = "2026-03-24T22:32:30.611Z" }, +version = "0.0.27" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f4/de/e5cf1f151cf52fe1189e42d03d90909d7d1354fdc0c1847cbb63a0baa3da/ty-0.0.27.tar.gz", hash = "sha256:d7a8de3421d92420b40c94fe7e7d4816037560621903964dd035cf9bd0204a73", size = 5424130, upload-time = "2026-03-31T19:07:20.806Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fa/20/2a9ea661758bd67f2bfd54ce9daacb5a26c56c5f8b49fbd9a43b365a8a7d/ty-0.0.27-py3-none-linux_armv6l.whl", hash = "sha256:eb14456b8611c9e8287aa9b633f4d2a0d9f3082a31796969e0b50bdda8930281", size = 10571211, upload-time = "2026-03-31T19:07:23.28Z" }, + { url = "https://files.pythonhosted.org/packages/da/b2/8887a51f705d075ddbe78ae7f0d4755ef48d0a90235f67aee289e9cee950/ty-0.0.27-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:02e662184703db7586118df611cf24a000d35dae38d950053d1dd7b6736fd2c4", size = 10427576, upload-time = "2026-03-31T19:07:15.499Z" }, + { url = "https://files.pythonhosted.org/packages/1d/c3/79d88163f508fb709ce19bc0b0a66c7c64b53d372d4caa56172c3d9b3ae8/ty-0.0.27-py3-none-macosx_11_0_arm64.whl", hash = "sha256:be5fc2899441f7f8f7ef40f9ffd006075a5ff6b06c44e8d2aa30e1b900c12f51", size = 9870359, upload-time = "2026-03-31T19:07:36.852Z" }, + { url = "https://files.pythonhosted.org/packages/dc/4d/ed1b0db0e1e46b5ed4976bbfe0d1825faf003b4e3774ef28c785ed73e4bb/ty-0.0.27-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:30231e652b14742a76b64755e54bf0cb1cd4c128bcaf625222e0ca92a2094887", size = 10380488, upload-time = "2026-03-31T19:07:31.268Z" }, + { url = "https://files.pythonhosted.org/packages/b1/f2/20372f6d510b01570028433064880adec2f8abe68bf0c4603be61a560bef/ty-0.0.27-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5a119b1168f64261b3205a37e40b5b6c4aac8fd58e4587988f4e4b22c3c79847", size = 10390248, upload-time = "2026-03-31T19:07:28.345Z" }, + { url = "https://files.pythonhosted.org/packages/45/4b/46b31a7311306be1a560f7f20fdc37b5bf718787f60626cd265d9b637554/ty-0.0.27-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e38f4e187b6975d2cbebf0f1eb1221f8f64f6e509bad14d7bb2a91afc97e4956", size = 10878479, upload-time = "2026-03-31T19:07:39.393Z" }, + { url = "https://files.pythonhosted.org/packages/42/ba/5231a2a1fb1cebe053a25de8fded95e1a30a1e77d3628a9e58487297bafc/ty-0.0.27-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a07b1a8fbb23844f6d22091275430d9ac617175f34aa99159b268193de210389", size = 11461232, upload-time = "2026-03-31T19:07:02.518Z" }, + { url = "https://files.pythonhosted.org/packages/c3/37/558abab3e1f6670493524f61280b4dfcc3219555f13889223e733381dfab/ty-0.0.27-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d3ec4033031f240836bb0337274bac5c49dde312c7c6d7575451ed719bf8ffa3", size = 11133002, upload-time = "2026-03-31T19:07:18.371Z" }, + { url = "https://files.pythonhosted.org/packages/32/38/188c14a57f52160407ce62c6abb556011718fd0bcbe1dca690529ce84c46/ty-0.0.27-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:924a8849afd500d260bf5b7296165a05b7424fbb6b19113f30f3b999d682873f", size = 10986624, upload-time = "2026-03-31T19:07:13.066Z" }, + { url = "https://files.pythonhosted.org/packages/9f/f1/667a71393f47d2cd6ba9ed07541b8df3eb63aab1f2ee658e77d91b8362fa/ty-0.0.27-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:d8270026c07e7423a1b3a3fd065b46ed1478748f0662518b523b57744f3fa025", size = 10366721, upload-time = "2026-03-31T19:07:00.131Z" }, + { url = "https://files.pythonhosted.org/packages/8b/aa/8edafe41be898bda774249abc5be6edd733e53fb1777d59ea9331e38537d/ty-0.0.27-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:e26e9735d3bdfd95d881111ad1cf570eab8188d8c3be36d6bcaad044d38984d8", size = 10412239, upload-time = "2026-03-31T19:07:05.297Z" }, + { url = "https://files.pythonhosted.org/packages/53/ff/8bafaed4a18d38264f46bdfc427de7ea2974cf9064e4e0bdb1b6e6c724e3/ty-0.0.27-py3-none-musllinux_1_2_i686.whl", hash = "sha256:7c09cc9a699810609acc0090af8d0db68adaee6e60a7c3e05ab80cc954a83db7", size = 10573507, upload-time = "2026-03-31T19:06:57.064Z" }, + { url = "https://files.pythonhosted.org/packages/16/2e/63a8284a2fefd08ab56ecbad0fde7dd4b2d4045a31cf24c1d1fcd9643227/ty-0.0.27-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:2d3e02853bb037221a456e034b1898aaa573e6374fbb53884e33cb7513ccb85a", size = 11090233, upload-time = "2026-03-31T19:07:34.139Z" }, + { url = "https://files.pythonhosted.org/packages/14/d3/d6fa1cafdfa2b34dbfa304fc6833af8e1669fc34e24d214fa76d2a2e5a25/ty-0.0.27-py3-none-win32.whl", hash = "sha256:34e7377f2047c14dbbb7bf5322e84114db7a5f2cb470db6bee63f8f3550cfc1e", size = 9984415, upload-time = "2026-03-31T19:07:07.98Z" }, + { url = "https://files.pythonhosted.org/packages/85/e6/dd4e27da9632b3472d5711ca49dbd3709dbd3e8c73f3af6db9c254235ca9/ty-0.0.27-py3-none-win_amd64.whl", hash = "sha256:3f7e4145aad8b815ed69b324c93b5b773eb864dda366ca16ab8693ff88ce6f36", size = 10961535, upload-time = "2026-03-31T19:07:10.566Z" }, + { url = "https://files.pythonhosted.org/packages/0e/1a/824b3496d66852ed7d5d68d9787711131552b68dce8835ce9410db32e618/ty-0.0.27-py3-none-win_arm64.whl", hash = "sha256:95bf8d01eb96bb2ba3ffc39faff19da595176448e80871a7b362f4d2de58476c", size = 10376689, upload-time = "2026-03-31T19:07:25.732Z" }, ] [[package]] From 2ca633929506a3abc7c4052ccbfe9186d5943431 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C6=B0u=20Quang=20V=C5=A9?= Date: Thu, 2 Apr 2026 14:38:32 +0700 Subject: [PATCH 235/291] Pinning workflow actions to a full-length commit SHA to comply with security standards. --- .github/workflows/docker.yaml | 12 ++++++------ .github/workflows/lint.yaml | 7 +++++-- .github/workflows/track.yml | 8 ++++---- 3 files changed, 15 insertions(+), 12 deletions(-) diff --git a/.github/workflows/docker.yaml b/.github/workflows/docker.yaml index 7dc537a..da8135d 100644 --- a/.github/workflows/docker.yaml +++ b/.github/workflows/docker.yaml @@ -29,16 +29,16 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@v6 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - name: Set up QEMU - uses: docker/setup-qemu-action@v4 + uses: docker/setup-qemu-action@ce360397dd3f832beb865e1373c09c0e9f86d70a # v4 - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v4 + uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4 - name: Log in to Container Registry - uses: docker/login-action@v4 + uses: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4 with: registry: ${{ env.REGISTRY }} username: ${{ github.actor }} @@ -46,7 +46,7 @@ jobs: - name: Extract metadata id: meta - uses: docker/metadata-action@v6 + uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf # v6 with: images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} tags: | @@ -58,7 +58,7 @@ jobs: type=raw,value=latest,enable={{is_default_branch}} - name: Build and push Docker image - uses: docker/build-push-action@v7 + uses: docker/build-push-action@d08e5c354a6adb9ed34480a06d141179aa583294 # v7 with: context: . push: true diff --git a/.github/workflows/lint.yaml b/.github/workflows/lint.yaml index e3da9f0..dc799c9 100644 --- a/.github/workflows/lint.yaml +++ b/.github/workflows/lint.yaml @@ -14,16 +14,19 @@ on: - "uv.lock" - ".github/workflows/lint.yaml" +permissions: + contents: read + jobs: lint: runs-on: ubuntu-latest steps: - name: Checkout repository - uses: actions/checkout@v6 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - name: Install uv - uses: astral-sh/setup-uv@v7 + uses: astral-sh/setup-uv@cec208311dfd045dd5311c1add060b2062131d57 # v8.0.0 - name: Install dependencies run: uv sync --all-groups diff --git a/.github/workflows/track.yml b/.github/workflows/track.yml index 9ebb9c6..39c4465 100644 --- a/.github/workflows/track.yml +++ b/.github/workflows/track.yml @@ -2,7 +2,7 @@ name: Update gemini-webapi on: schedule: - - cron: "0 0 * * *" # Runs every day at midnight + - cron: "0 0 * * *" workflow_dispatch: jobs: @@ -14,10 +14,10 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@v6 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - name: Install uv - uses: astral-sh/setup-uv@v7 + uses: astral-sh/setup-uv@cec208311dfd045dd5311c1add060b2062131d57 # v8.0.0 - name: Update gemini-webapi id: update @@ -56,7 +56,7 @@ jobs: - name: Create Pull Request if: steps.update.outputs.updated == 'true' - uses: peter-evans/create-pull-request@v8 + uses: peter-evans/create-pull-request@c0f553fe549906ede9cf27b5156039d195d2ece0 # v8 with: token: ${{ secrets.GITHUB_TOKEN }} commit-message: ":arrow_up: update gemini-webapi to ${{ steps.update.outputs.version }}" From b1773c287104df7fc04100f6d56e2fc98b4255e7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C6=B0u=20Quang=20V=C5=A9?= Date: Wed, 8 Apr 2026 17:50:19 +0700 Subject: [PATCH 236/291] Update dependencies to latest versions --- Dockerfile | 2 +- app/utils/helper.py | 6 +- pyproject.toml | 9 +-- uv.lock | 172 +++++++++++++++++++++++++++----------------- 4 files changed, 114 insertions(+), 75 deletions(-) diff --git a/Dockerfile b/Dockerfile index 2499479..068aa8c 100644 --- a/Dockerfile +++ b/Dockerfile @@ -10,7 +10,7 @@ WORKDIR /app SHELL ["/bin/bash", "-o", "pipefail", "-c"] RUN apt-get update && apt-get install -y --no-install-recommends \ - tini curl ca-certificates git \ + tini curl ca-certificates \ && rm -rf /var/lib/apt/lists/* ENV UV_COMPILE_BYTECODE=1 \ diff --git a/app/utils/helper.py b/app/utils/helper.py index a2d8471..781ec33 100644 --- a/app/utils/helper.py +++ b/app/utils/helper.py @@ -11,7 +11,7 @@ from urllib.parse import urlparse import orjson -from curl_cffi.requests import AsyncSession +from curl_cffi import CurlFollow, requests from loguru import logger from app.models import AppMessage, AppToolCall, AppToolCallFunction @@ -199,7 +199,9 @@ async def save_url_to_tempfile(url: str, tempdir: Path | None = None) -> Path: elif not suffix: suffix = ".bin" else: - async with AsyncSession(impersonate="chrome", allow_redirects=True) as client: + async with requests.AsyncSession( + impersonate="chrome", allow_redirects=CurlFollow.SAFE + ) as client: resp = await client.get(url) resp.raise_for_status() data = resp.content diff --git a/pyproject.toml b/pyproject.toml index 6113b27..31740e3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,15 +5,15 @@ description = "FastAPI Server built on Gemini Web API" readme = "README.md" requires-python = "==3.13.*" dependencies = [ - "curl-cffi>=0.14.0", + "curl-cffi>=0.15.0", "fastapi>=0.135.3", - "gemini-webapi>=1.21.0", + "gemini-webapi>=2.0.0", "httptools>=0.7.1", "lmdb>=2.2.0", "loguru>=0.7.3", "orjson>=3.11.8", "pydantic-settings[yaml]>=2.13.1", - "uvicorn>=0.42.0", + "uvicorn>=0.44.0", "uvloop>=0.22.1; sys_platform != 'win32'", ] @@ -63,6 +63,3 @@ extend-immutable-calls = [ [tool.ruff.format] quote-style = "double" indent-style = "space" - -[tool.uv.sources] -gemini-webapi = { git = "https://github.com/HanaokaYuzu/Gemini-API.git" } diff --git a/uv.lock b/uv.lock index 7dac18c..82c3743 100644 --- a/uv.lock +++ b/uv.lock @@ -66,14 +66,14 @@ wheels = [ [[package]] name = "click" -version = "8.3.1" +version = "8.3.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "colorama", marker = "sys_platform == 'win32'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/3d/fa/656b739db8587d7b5dfa22e22ed02566950fbfbcdc20311993483657a5c0/click-8.3.1.tar.gz", hash = "sha256:12ff4785d337a1bb490bb7e9c2b1ee5da3112e94a8622f26a6c77f5d2fc6842a", size = 295065, upload-time = "2025-11-15T20:45:42.706Z" } +sdist = { url = "https://files.pythonhosted.org/packages/57/75/31212c6bf2503fdf920d87fee5d7a86a2e3bcf444984126f13d8e4016804/click-8.3.2.tar.gz", hash = "sha256:14162b8b3b3550a7d479eafa77dfd3c38d9dc8951f6f69c78913a8f9a7540fd5", size = 302856, upload-time = "2026-04-03T19:14:45.118Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/98/78/01c019cdb5d6498122777c1a43056ebb3ebfeef2076d9d026bfe15583b2b/click-8.3.1-py3-none-any.whl", hash = "sha256:981153a64e25f12d547d3426c367a4857371575ee7ad18df2a6183ab0545b2a6", size = 108274, upload-time = "2025-11-15T20:45:41.139Z" }, + { url = "https://files.pythonhosted.org/packages/e4/20/71885d8b97d4f3dde17b1fdb92dbd4908b00541c5a3379787137285f602e/click-8.3.2-py3-none-any.whl", hash = "sha256:1924d2c27c5653561cd2cae4548d1406039cb79b858b747cfea24924bbc1616d", size = 108379, upload-time = "2026-04-03T19:14:43.505Z" }, ] [[package]] @@ -87,25 +87,27 @@ wheels = [ [[package]] name = "curl-cffi" -version = "0.14.0" +version = "0.15.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "certifi" }, { name = "cffi" }, + { name = "rich" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/9b/c9/0067d9a25ed4592b022d4558157fcdb6e123516083700786d38091688767/curl_cffi-0.14.0.tar.gz", hash = "sha256:5ffbc82e59f05008ec08ea432f0e535418823cda44178ee518906a54f27a5f0f", size = 162633, upload-time = "2025-12-16T03:25:07.931Z" } +sdist = { url = "https://files.pythonhosted.org/packages/48/5b/89fcfebd3e5e85134147ac99e9f2b2271165fd4d71984fc65da5f17819b7/curl_cffi-0.15.0.tar.gz", hash = "sha256:ea0c67652bf6893d34ee0f82c944f37e488f6147e9421bef1771cc6545b02ded", size = 196437, upload-time = "2026-04-03T11:12:31.525Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/aa/f0/0f21e9688eaac85e705537b3a87a5588d0cefb2f09d83e83e0e8be93aa99/curl_cffi-0.14.0-cp39-abi3-macosx_14_0_arm64.whl", hash = "sha256:e35e89c6a69872f9749d6d5fda642ed4fc159619329e99d577d0104c9aad5893", size = 3087277, upload-time = "2025-12-16T03:24:49.607Z" }, - { url = "https://files.pythonhosted.org/packages/ba/a3/0419bd48fce5b145cb6a2344c6ac17efa588f5b0061f212c88e0723da026/curl_cffi-0.14.0-cp39-abi3-macosx_15_0_x86_64.whl", hash = "sha256:5945478cd28ad7dfb5c54473bcfb6743ee1d66554d57951fdf8fc0e7d8cf4e45", size = 5804650, upload-time = "2025-12-16T03:24:51.518Z" }, - { url = "https://files.pythonhosted.org/packages/e2/07/a238dd062b7841b8caa2fa8a359eb997147ff3161288f0dd46654d898b4d/curl_cffi-0.14.0-cp39-abi3-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c42e8fa3c667db9ccd2e696ee47adcd3cd5b0838d7282f3fc45f6c0ef3cfdfa7", size = 8231918, upload-time = "2025-12-16T03:24:52.862Z" }, - { url = "https://files.pythonhosted.org/packages/7c/d2/ce907c9b37b5caf76ac08db40cc4ce3d9f94c5500db68a195af3513eacbc/curl_cffi-0.14.0-cp39-abi3-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:060fe2c99c41d3cb7f894de318ddf4b0301b08dca70453d769bd4e74b36b8483", size = 8654624, upload-time = "2025-12-16T03:24:54.579Z" }, - { url = "https://files.pythonhosted.org/packages/f2/ae/6256995b18c75e6ef76b30753a5109e786813aa79088b27c8eabb1ef85c9/curl_cffi-0.14.0-cp39-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:b158c41a25388690dd0d40b5bc38d1e0f512135f17fdb8029868cbc1993d2e5b", size = 8010654, upload-time = "2025-12-16T03:24:56.507Z" }, - { url = "https://files.pythonhosted.org/packages/fb/10/ff64249e516b103cb762e0a9dca3ee0f04cf25e2a1d5d9838e0f1273d071/curl_cffi-0.14.0-cp39-abi3-manylinux_2_28_i686.whl", hash = "sha256:1439fbef3500fb723333c826adf0efb0e2e5065a703fb5eccce637a2250db34a", size = 7781969, upload-time = "2025-12-16T03:24:57.885Z" }, - { url = "https://files.pythonhosted.org/packages/51/76/d6f7bb76c2d12811aa7ff16f5e17b678abdd1b357b9a8ac56310ceccabd5/curl_cffi-0.14.0-cp39-abi3-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e7176f2c2d22b542e3cf261072a81deb018cfa7688930f95dddef215caddb469", size = 7969133, upload-time = "2025-12-16T03:24:59.261Z" }, - { url = "https://files.pythonhosted.org/packages/23/7c/cca39c0ed4e1772613d3cba13091c0e9d3b89365e84b9bf9838259a3cd8f/curl_cffi-0.14.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:03f21ade2d72978c2bb8670e9b6de5260e2755092b02d94b70b906813662998d", size = 9080167, upload-time = "2025-12-16T03:25:00.946Z" }, - { url = "https://files.pythonhosted.org/packages/75/03/a942d7119d3e8911094d157598ae0169b1c6ca1bd3f27d7991b279bcc45b/curl_cffi-0.14.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:58ebf02de64ee5c95613209ddacb014c2d2f86298d7080c0a1c12ed876ee0690", size = 9520464, upload-time = "2025-12-16T03:25:02.922Z" }, - { url = "https://files.pythonhosted.org/packages/a2/77/78900e9b0833066d2274bda75cba426fdb4cef7fbf6a4f6a6ca447607bec/curl_cffi-0.14.0-cp39-abi3-win_amd64.whl", hash = "sha256:6e503f9a103f6ae7acfb3890c843b53ec030785a22ae7682a22cc43afb94123e", size = 1677416, upload-time = "2025-12-16T03:25:04.902Z" }, - { url = "https://files.pythonhosted.org/packages/5c/7c/d2ba86b0b3e1e2830bd94163d047de122c69a8df03c5c7c36326c456ad82/curl_cffi-0.14.0-cp39-abi3-win_arm64.whl", hash = "sha256:2eed50a969201605c863c4c31269dfc3e0da52916086ac54553cfa353022425c", size = 1425067, upload-time = "2025-12-16T03:25:06.454Z" }, + { url = "https://files.pythonhosted.org/packages/5e/42/54ddd442c795f30ce5dd4e49f87ce77505958d3777cd96a91567a3975d2a/curl_cffi-0.15.0-cp310-abi3-macosx_10_9_x86_64.whl", hash = "sha256:bda66404010e9ed743b1b83c20c86f24fe21a9a6873e17479d6e67e29d8ded28", size = 2795267, upload-time = "2026-04-03T11:11:46.48Z" }, + { url = "https://files.pythonhosted.org/packages/83/2d/3915e238579b3c5a92cead5c79130c3b8d20caaba7616cc4d894650e1d6b/curl_cffi-0.15.0-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:a25620d9bf989c9c029a7d1642999c4c265abb0bad811deb2f77b0b5b2b12e5b", size = 2573544, upload-time = "2026-04-03T11:11:47.951Z" }, + { url = "https://files.pythonhosted.org/packages/2a/b3/9d2f1057749a1b07ba1989db3c1503ce8bed998310bae9aea2c43aa64f20/curl_cffi-0.15.0-cp310-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:582e570aa2586b96ed47cf4a17586b9a3c462cbe43f780487c3dc245c6ef1527", size = 10515369, upload-time = "2026-04-03T11:11:50.126Z" }, + { url = "https://files.pythonhosted.org/packages/b5/1d/6d10dded5ce3fd8157e558ebd97d09e551b77a62cdc1c31e93d0a633cee5/curl_cffi-0.15.0-cp310-abi3-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:838e48212447d9c81364b04707a5c861daf08f8320f9ecb3406a8919d1d5c3b3", size = 10160045, upload-time = "2026-04-03T11:11:52.664Z" }, + { url = "https://files.pythonhosted.org/packages/5c/12/c70b835487ace3b9ba1502631912e3440082b8ae3a162f60b59cb0b6444d/curl_cffi-0.15.0-cp310-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:2b6c847d86283b07ae69bb72c82eb8a59242277142aa35b89850f89e792a02fc", size = 11090433, upload-time = "2026-04-03T11:11:55.049Z" }, + { url = "https://files.pythonhosted.org/packages/ea/0d/78edcc4f71934225db99df68197a107386d59080742fc7bf6bb4d007924f/curl_cffi-0.15.0-cp310-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:9e5e69eee735f659287e2c84444319d68a1fa68dd37abf228943a4074864283a", size = 10479178, upload-time = "2026-04-03T11:11:57.685Z" }, + { url = "https://files.pythonhosted.org/packages/5b/84/1e101c1acb1ea2f0b4992f5c3024f596d8e21db0d53540b9d583f673c4e7/curl_cffi-0.15.0-cp310-abi3-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:aa1323950224db24f4c510d010b3affa02196ca853fb424191fa917a513d3f4b", size = 10317051, upload-time = "2026-04-03T11:12:00.295Z" }, + { url = "https://files.pythonhosted.org/packages/28/42/8ef236b22a6c23d096c85a1dc507efe37bfdfc7a2f8a4b34efb590197369/curl_cffi-0.15.0-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:41f80170ba844009273b2660da1964ec31e99e5719d16b3422ada87177e32e13", size = 11299660, upload-time = "2026-04-03T11:12:02.791Z" }, + { url = "https://files.pythonhosted.org/packages/1d/01/56aeb055d962da87a1be0d74c6c644e251c7e88129b5471dc44ac724e678/curl_cffi-0.15.0-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:1977e1e12cfb5c11352cbb74acef1bed24eb7d226dab61ca57c168c21acd4d61", size = 11945049, upload-time = "2026-04-03T11:12:05.912Z" }, + { url = "https://files.pythonhosted.org/packages/d8/8c/2abf99a38d6340d66cf0557e0c750ef3f8883dfc5d450087e01c85861343/curl_cffi-0.15.0-cp310-abi3-win_amd64.whl", hash = "sha256:5a0c1896a0d5a5ac1eb89cd24b008d2b718dd1df6fd2f75451b59ca66e49e572", size = 1661649, upload-time = "2026-04-03T11:12:07.948Z" }, + { url = "https://files.pythonhosted.org/packages/3d/39/dfd54f2240d3a9b96d77bacc62b97813b35e2aa8ecf5cd5013c683f1ba96/curl_cffi-0.15.0-cp310-abi3-win_arm64.whl", hash = "sha256:a6d57f8389273a3a1f94370473c74897467bcc36af0a17336989780c507fa43d", size = 1410741, upload-time = "2026-04-03T11:12:10.073Z" }, + { url = "https://files.pythonhosted.org/packages/19/6a/c24df8a4fc22fa84070dcd94abeba43c15e08cc09e35869565c0bad196fd/curl_cffi-0.15.0-cp313-abi3-android_24_arm64_v8a.whl", hash = "sha256:4682dc38d4336e0eb0b185374db90a760efde63cbea994b4e63f3521d44c4c92", size = 7190427, upload-time = "2026-04-03T11:12:12.142Z" }, ] [[package]] @@ -155,9 +157,9 @@ dev = [ [package.metadata] requires-dist = [ - { name = "curl-cffi", specifier = ">=0.14.0" }, + { name = "curl-cffi", specifier = ">=0.15.0" }, { name = "fastapi", specifier = ">=0.135.3" }, - { name = "gemini-webapi", git = "https://github.com/HanaokaYuzu/Gemini-API.git" }, + { name = "gemini-webapi", specifier = ">=2.0.0" }, { name = "httptools", specifier = ">=0.7.1" }, { name = "lmdb", specifier = ">=2.2.0" }, { name = "loguru", specifier = ">=0.7.3" }, @@ -166,7 +168,7 @@ requires-dist = [ { name = "pytest", marker = "extra == 'dev'" }, { name = "ruff", marker = "extra == 'dev'" }, { name = "ty", marker = "extra == 'dev'" }, - { name = "uvicorn", specifier = ">=0.42.0" }, + { name = "uvicorn", specifier = ">=0.44.0" }, { name = "uvloop", marker = "sys_platform != 'win32'", specifier = ">=0.22.1" }, ] provides-extras = ["dev"] @@ -176,14 +178,18 @@ dev = [{ name = "gemini-fastapi", extras = ["dev"] }] [[package]] name = "gemini-webapi" -version = "1.21.0.post32" -source = { git = "https://github.com/HanaokaYuzu/Gemini-API.git#6675735f294ecf91e1e101dbb37a7e2ec2eef597" } +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "curl-cffi" }, { name = "loguru" }, { name = "orjson" }, { name = "pydantic" }, ] +sdist = { url = "https://files.pythonhosted.org/packages/2c/2a/1d46470ec287f978565b000bf4fd9cc3eee8f4193ceec70094dc12a724a3/gemini_webapi-2.0.0.tar.gz", hash = "sha256:5ee9d8ad9fd4c7fdc50ebfd152c9cf1dc4c30f7e9984bae6dffed9a0cb039735", size = 300490, upload-time = "2026-04-06T21:19:16.427Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4f/9b/bae89ced30ce74ae96793c05c2eb3e92de6fe3e619f91802aba686dd8027/gemini_webapi-2.0.0-py3-none-any.whl", hash = "sha256:6f2b922a5923afbb432c3c95cb6edd66e73cf5d99c95e9e40e63912e4131aff8", size = 92815, upload-time = "2026-04-06T21:19:14.933Z" }, +] [[package]] name = "h11" @@ -254,6 +260,27 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/0c/29/0348de65b8cc732daa3e33e67806420b2ae89bdce2b04af740289c5c6c8c/loguru-0.7.3-py3-none-any.whl", hash = "sha256:31a33c10c8e1e10422bfd431aeb5d351c7cf7fa671e3c4df004162264b28220c", size = 61595, upload-time = "2024-12-06T11:20:54.538Z" }, ] +[[package]] +name = "markdown-it-py" +version = "4.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mdurl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5b/f5/4ec618ed16cc4f8fb3b701563655a69816155e79e24a17b651541804721d/markdown_it_py-4.0.0.tar.gz", hash = "sha256:cb0a2b4aa34f932c007117b194e945bd74e0ec24133ceb5bac59009cda1cb9f3", size = 73070, upload-time = "2025-08-11T12:57:52.854Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/94/54/e7d793b573f298e1c9013b8c4dade17d481164aa517d1d7148619c2cedbf/markdown_it_py-4.0.0-py3-none-any.whl", hash = "sha256:87327c59b172c5011896038353a81343b6754500a08cd7a4973bb48c6d578147", size = 87321, upload-time = "2025-08-11T12:57:51.923Z" }, +] + +[[package]] +name = "mdurl" +version = "0.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" }, +] + [[package]] name = "orjson" version = "3.11.8" @@ -374,7 +401,7 @@ wheels = [ [[package]] name = "pytest" -version = "9.0.2" +version = "9.0.3" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "colorama", marker = "sys_platform == 'win32'" }, @@ -383,9 +410,9 @@ dependencies = [ { name = "pluggy" }, { name = "pygments" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/d1/db/7ef3487e0fb0049ddb5ce41d3a49c235bf9ad299b6a25d5780a89f19230f/pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11", size = 1568901, upload-time = "2025-12-06T21:30:51.014Z" } +sdist = { url = "https://files.pythonhosted.org/packages/7d/0d/549bd94f1a0a402dc8cf64563a117c0f3765662e2e668477624baeec44d5/pytest-9.0.3.tar.gz", hash = "sha256:b86ada508af81d19edeb213c681b1d48246c1a91d304c6c81a427674c17eb91c", size = 1572165, upload-time = "2026-04-07T17:16:18.027Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801, upload-time = "2025-12-06T21:30:49.154Z" }, + { url = "https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9", size = 375249, upload-time = "2026-04-07T17:16:16.13Z" }, ] [[package]] @@ -415,29 +442,42 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" }, ] +[[package]] +name = "rich" +version = "14.3.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown-it-py" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b3/c6/f3b320c27991c46f43ee9d856302c70dc2d0fb2dba4842ff739d5f46b393/rich-14.3.3.tar.gz", hash = "sha256:b8daa0b9e4eef54dd8cf7c86c03713f53241884e814f4e2f5fb342fe520f639b", size = 230582, upload-time = "2026-02-19T17:23:12.474Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/14/25/b208c5683343959b670dc001595f2f3737e051da617f66c31f7c4fa93abc/rich-14.3.3-py3-none-any.whl", hash = "sha256:793431c1f8619afa7d3b52b2cdec859562b950ea0d4b6b505397612db8d5362d", size = 310458, upload-time = "2026-02-19T17:23:13.732Z" }, +] + [[package]] name = "ruff" -version = "0.15.8" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/14/b0/73cf7550861e2b4824950b8b52eebdcc5adc792a00c514406556c5b80817/ruff-0.15.8.tar.gz", hash = "sha256:995f11f63597ee362130d1d5a327a87cb6f3f5eae3094c620bcc632329a4d26e", size = 4610921, upload-time = "2026-03-26T18:39:38.675Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/4a/92/c445b0cd6da6e7ae51e954939cb69f97e008dbe750cfca89b8cedc081be7/ruff-0.15.8-py3-none-linux_armv6l.whl", hash = "sha256:cbe05adeba76d58162762d6b239c9056f1a15a55bd4b346cfd21e26cd6ad7bc7", size = 10527394, upload-time = "2026-03-26T18:39:41.566Z" }, - { url = "https://files.pythonhosted.org/packages/eb/92/f1c662784d149ad1414cae450b082cf736430c12ca78367f20f5ed569d65/ruff-0.15.8-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:d3e3d0b6ba8dca1b7ef9ab80a28e840a20070c4b62e56d675c24f366ef330570", size = 10905693, upload-time = "2026-03-26T18:39:30.364Z" }, - { url = "https://files.pythonhosted.org/packages/ca/f2/7a631a8af6d88bcef997eb1bf87cc3da158294c57044aafd3e17030613de/ruff-0.15.8-py3-none-macosx_11_0_arm64.whl", hash = "sha256:6ee3ae5c65a42f273f126686353f2e08ff29927b7b7e203b711514370d500de3", size = 10323044, upload-time = "2026-03-26T18:39:33.37Z" }, - { url = "https://files.pythonhosted.org/packages/67/18/1bf38e20914a05e72ef3b9569b1d5c70a7ef26cd188d69e9ca8ef588d5bf/ruff-0.15.8-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fdce027ada77baa448077ccc6ebb2fa9c3c62fd110d8659d601cf2f475858d94", size = 10629135, upload-time = "2026-03-26T18:39:44.142Z" }, - { url = "https://files.pythonhosted.org/packages/d2/e9/138c150ff9af60556121623d41aba18b7b57d95ac032e177b6a53789d279/ruff-0.15.8-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:12e617fc01a95e5821648a6df341d80456bd627bfab8a829f7cfc26a14a4b4a3", size = 10348041, upload-time = "2026-03-26T18:39:52.178Z" }, - { url = "https://files.pythonhosted.org/packages/02/f1/5bfb9298d9c323f842c5ddeb85f1f10ef51516ac7a34ba446c9347d898df/ruff-0.15.8-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:432701303b26416d22ba696c39f2c6f12499b89093b61360abc34bcc9bf07762", size = 11121987, upload-time = "2026-03-26T18:39:55.195Z" }, - { url = "https://files.pythonhosted.org/packages/10/11/6da2e538704e753c04e8d86b1fc55712fdbdcc266af1a1ece7a51fff0d10/ruff-0.15.8-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d910ae974b7a06a33a057cb87d2a10792a3b2b3b35e33d2699fdf63ec8f6b17a", size = 11951057, upload-time = "2026-03-26T18:39:19.18Z" }, - { url = "https://files.pythonhosted.org/packages/83/f0/c9208c5fd5101bf87002fed774ff25a96eea313d305f1e5d5744698dc314/ruff-0.15.8-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2033f963c43949d51e6fdccd3946633c6b37c484f5f98c3035f49c27395a8ab8", size = 11464613, upload-time = "2026-03-26T18:40:06.301Z" }, - { url = "https://files.pythonhosted.org/packages/f8/22/d7f2fabdba4fae9f3b570e5605d5eb4500dcb7b770d3217dca4428484b17/ruff-0.15.8-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0f29b989a55572fb885b77464cf24af05500806ab4edf9a0fd8977f9759d85b1", size = 11257557, upload-time = "2026-03-26T18:39:57.972Z" }, - { url = "https://files.pythonhosted.org/packages/71/8c/382a9620038cf6906446b23ce8632ab8c0811b8f9d3e764f58bedd0c9a6f/ruff-0.15.8-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:ac51d486bf457cdc985a412fb1801b2dfd1bd8838372fc55de64b1510eff4bec", size = 11169440, upload-time = "2026-03-26T18:39:22.205Z" }, - { url = "https://files.pythonhosted.org/packages/4d/0d/0994c802a7eaaf99380085e4e40c845f8e32a562e20a38ec06174b52ef24/ruff-0.15.8-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:c9861eb959edab053c10ad62c278835ee69ca527b6dcd72b47d5c1e5648964f6", size = 10605963, upload-time = "2026-03-26T18:39:46.682Z" }, - { url = "https://files.pythonhosted.org/packages/19/aa/d624b86f5b0aad7cef6bbf9cd47a6a02dfdc4f72c92a337d724e39c9d14b/ruff-0.15.8-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:8d9a5b8ea13f26ae90838afc33f91b547e61b794865374f114f349e9036835fb", size = 10357484, upload-time = "2026-03-26T18:39:49.176Z" }, - { url = "https://files.pythonhosted.org/packages/35/c3/e0b7835d23001f7d999f3895c6b569927c4d39912286897f625736e1fd04/ruff-0.15.8-py3-none-musllinux_1_2_i686.whl", hash = "sha256:c2a33a529fb3cbc23a7124b5c6ff121e4d6228029cba374777bd7649cc8598b8", size = 10830426, upload-time = "2026-03-26T18:40:03.702Z" }, - { url = "https://files.pythonhosted.org/packages/f0/51/ab20b322f637b369383adc341d761eaaa0f0203d6b9a7421cd6e783d81b9/ruff-0.15.8-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:75e5cd06b1cf3f47a3996cfc999226b19aa92e7cce682dcd62f80d7035f98f49", size = 11345125, upload-time = "2026-03-26T18:39:27.799Z" }, - { url = "https://files.pythonhosted.org/packages/37/e6/90b2b33419f59d0f2c4c8a48a4b74b460709a557e8e0064cf33ad894f983/ruff-0.15.8-py3-none-win32.whl", hash = "sha256:bc1f0a51254ba21767bfa9a8b5013ca8149dcf38092e6a9eb704d876de94dc34", size = 10571959, upload-time = "2026-03-26T18:39:36.117Z" }, - { url = "https://files.pythonhosted.org/packages/1f/a2/ef467cb77099062317154c63f234b8a7baf7cb690b99af760c5b68b9ee7f/ruff-0.15.8-py3-none-win_amd64.whl", hash = "sha256:04f79eff02a72db209d47d665ba7ebcad609d8918a134f86cb13dd132159fc89", size = 11743893, upload-time = "2026-03-26T18:39:25.01Z" }, - { url = "https://files.pythonhosted.org/packages/15/e2/77be4fff062fa78d9b2a4dea85d14785dac5f1d0c1fb58ed52331f0ebe28/ruff-0.15.8-py3-none-win_arm64.whl", hash = "sha256:cf891fa8e3bb430c0e7fac93851a5978fc99c8fa2c053b57b118972866f8e5f2", size = 11048175, upload-time = "2026-03-26T18:40:01.06Z" }, +version = "0.15.9" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e6/97/e9f1ca355108ef7194e38c812ef40ba98c7208f47b13ad78d023caa583da/ruff-0.15.9.tar.gz", hash = "sha256:29cbb1255a9797903f6dde5ba0188c707907ff44a9006eb273b5a17bfa0739a2", size = 4617361, upload-time = "2026-04-02T18:17:20.829Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/1f/9cdfd0ac4b9d1e5a6cf09bedabdf0b56306ab5e333c85c87281273e7b041/ruff-0.15.9-py3-none-linux_armv6l.whl", hash = "sha256:6efbe303983441c51975c243e26dff328aca11f94b70992f35b093c2e71801e1", size = 10511206, upload-time = "2026-04-02T18:16:41.574Z" }, + { url = "https://files.pythonhosted.org/packages/3d/f6/32bfe3e9c136b35f02e489778d94384118bb80fd92c6d92e7ccd97db12ce/ruff-0.15.9-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:4965bac6ac9ea86772f4e23587746f0b7a395eccabb823eb8bfacc3fa06069f7", size = 10923307, upload-time = "2026-04-02T18:17:08.645Z" }, + { url = "https://files.pythonhosted.org/packages/ca/25/de55f52ab5535d12e7aaba1de37a84be6179fb20bddcbe71ec091b4a3243/ruff-0.15.9-py3-none-macosx_11_0_arm64.whl", hash = "sha256:eaf05aad70ca5b5a0a4b0e080df3a6b699803916d88f006efd1f5b46302daab8", size = 10316722, upload-time = "2026-04-02T18:16:44.206Z" }, + { url = "https://files.pythonhosted.org/packages/48/11/690d75f3fd6278fe55fff7c9eb429c92d207e14b25d1cae4064a32677029/ruff-0.15.9-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9439a342adb8725f32f92732e2bafb6d5246bd7a5021101166b223d312e8fc59", size = 10623674, upload-time = "2026-04-02T18:16:50.951Z" }, + { url = "https://files.pythonhosted.org/packages/bd/ec/176f6987be248fc5404199255522f57af1b4a5a1b57727e942479fec98ad/ruff-0.15.9-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9c5e6faf9d97c8edc43877c3f406f47446fc48c40e1442d58cfcdaba2acea745", size = 10351516, upload-time = "2026-04-02T18:16:57.206Z" }, + { url = "https://files.pythonhosted.org/packages/b2/fc/51cffbd2b3f240accc380171d51446a32aa2ea43a40d4a45ada67368fbd2/ruff-0.15.9-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7b34a9766aeec27a222373d0b055722900fbc0582b24f39661aa96f3fe6ad901", size = 11150202, upload-time = "2026-04-02T18:17:06.452Z" }, + { url = "https://files.pythonhosted.org/packages/d6/d4/25292a6dfc125f6b6528fe6af31f5e996e19bf73ca8e3ce6eb7fa5b95885/ruff-0.15.9-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:89dd695bc72ae76ff484ae54b7e8b0f6b50f49046e198355e44ea656e521fef9", size = 11988891, upload-time = "2026-04-02T18:17:18.575Z" }, + { url = "https://files.pythonhosted.org/packages/13/e1/1eebcb885c10e19f969dcb93d8413dfee8172578709d7ee933640f5e7147/ruff-0.15.9-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ce187224ef1de1bd225bc9a152ac7102a6171107f026e81f317e4257052916d5", size = 11480576, upload-time = "2026-04-02T18:16:52.986Z" }, + { url = "https://files.pythonhosted.org/packages/ff/6b/a1548ac378a78332a4c3dcf4a134c2475a36d2a22ddfa272acd574140b50/ruff-0.15.9-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2b0c7c341f68adb01c488c3b7d4b49aa8ea97409eae6462d860a79cf55f431b6", size = 11254525, upload-time = "2026-04-02T18:17:02.041Z" }, + { url = "https://files.pythonhosted.org/packages/42/aa/4bb3af8e61acd9b1281db2ab77e8b2c3c5e5599bf2a29d4a942f1c62b8d6/ruff-0.15.9-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:55cc15eee27dc0eebdfcb0d185a6153420efbedc15eb1d38fe5e685657b0f840", size = 11204072, upload-time = "2026-04-02T18:17:13.581Z" }, + { url = "https://files.pythonhosted.org/packages/69/48/d550dc2aa6e423ea0bcc1d0ff0699325ffe8a811e2dba156bd80750b86dc/ruff-0.15.9-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:a6537f6eed5cda688c81073d46ffdfb962a5f29ecb6f7e770b2dc920598997ed", size = 10594998, upload-time = "2026-04-02T18:16:46.369Z" }, + { url = "https://files.pythonhosted.org/packages/63/47/321167e17f5344ed5ec6b0aa2cff64efef5f9e985af8f5622cfa6536043f/ruff-0.15.9-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:6d3fcbca7388b066139c523bda744c822258ebdcfbba7d24410c3f454cc9af71", size = 10359769, upload-time = "2026-04-02T18:17:10.994Z" }, + { url = "https://files.pythonhosted.org/packages/67/5e/074f00b9785d1d2c6f8c22a21e023d0c2c1817838cfca4c8243200a1fa87/ruff-0.15.9-py3-none-musllinux_1_2_i686.whl", hash = "sha256:058d8e99e1bfe79d8a0def0b481c56059ee6716214f7e425d8e737e412d69677", size = 10850236, upload-time = "2026-04-02T18:16:48.749Z" }, + { url = "https://files.pythonhosted.org/packages/76/37/804c4135a2a2caf042925d30d5f68181bdbd4461fd0d7739da28305df593/ruff-0.15.9-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:8e1ddb11dbd61d5983fa2d7d6370ef3eb210951e443cace19594c01c72abab4c", size = 11358343, upload-time = "2026-04-02T18:16:55.068Z" }, + { url = "https://files.pythonhosted.org/packages/88/3d/1364fcde8656962782aa9ea93c92d98682b1ecec2f184e625a965ad3b4a6/ruff-0.15.9-py3-none-win32.whl", hash = "sha256:bde6ff36eaf72b700f32b7196088970bf8fdb2b917b7accd8c371bfc0fd573ec", size = 10583382, upload-time = "2026-04-02T18:17:04.261Z" }, + { url = "https://files.pythonhosted.org/packages/4c/56/5c7084299bd2cacaa07ae63a91c6f4ba66edc08bf28f356b24f6b717c799/ruff-0.15.9-py3-none-win_amd64.whl", hash = "sha256:45a70921b80e1c10cf0b734ef09421f71b5aa11d27404edc89d7e8a69505e43d", size = 11744969, upload-time = "2026-04-02T18:16:59.611Z" }, + { url = "https://files.pythonhosted.org/packages/03/36/76704c4f312257d6dbaae3c959add2a622f63fcca9d864659ce6d8d97d3d/ruff-0.15.9-py3-none-win_arm64.whl", hash = "sha256:0694e601c028fd97dc5c6ee244675bc241aeefced7ef80cd9c6935a871078f53", size = 11005870, upload-time = "2026-04-02T18:17:15.773Z" }, ] [[package]] @@ -454,26 +494,26 @@ wheels = [ [[package]] name = "ty" -version = "0.0.27" +version = "0.0.29" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f4/de/e5cf1f151cf52fe1189e42d03d90909d7d1354fdc0c1847cbb63a0baa3da/ty-0.0.27.tar.gz", hash = "sha256:d7a8de3421d92420b40c94fe7e7d4816037560621903964dd035cf9bd0204a73", size = 5424130, upload-time = "2026-03-31T19:07:20.806Z" } +sdist = { url = "https://files.pythonhosted.org/packages/47/d5/853561de49fae38c519e905b2d8da9c531219608f1fccc47a0fc2c896980/ty-0.0.29.tar.gz", hash = "sha256:e7936cca2f691eeda631876c92809688dbbab68687c3473f526cd83b6a9228d8", size = 5469221, upload-time = "2026-04-05T15:01:21.328Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/fa/20/2a9ea661758bd67f2bfd54ce9daacb5a26c56c5f8b49fbd9a43b365a8a7d/ty-0.0.27-py3-none-linux_armv6l.whl", hash = "sha256:eb14456b8611c9e8287aa9b633f4d2a0d9f3082a31796969e0b50bdda8930281", size = 10571211, upload-time = "2026-03-31T19:07:23.28Z" }, - { url = "https://files.pythonhosted.org/packages/da/b2/8887a51f705d075ddbe78ae7f0d4755ef48d0a90235f67aee289e9cee950/ty-0.0.27-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:02e662184703db7586118df611cf24a000d35dae38d950053d1dd7b6736fd2c4", size = 10427576, upload-time = "2026-03-31T19:07:15.499Z" }, - { url = "https://files.pythonhosted.org/packages/1d/c3/79d88163f508fb709ce19bc0b0a66c7c64b53d372d4caa56172c3d9b3ae8/ty-0.0.27-py3-none-macosx_11_0_arm64.whl", hash = "sha256:be5fc2899441f7f8f7ef40f9ffd006075a5ff6b06c44e8d2aa30e1b900c12f51", size = 9870359, upload-time = "2026-03-31T19:07:36.852Z" }, - { url = "https://files.pythonhosted.org/packages/dc/4d/ed1b0db0e1e46b5ed4976bbfe0d1825faf003b4e3774ef28c785ed73e4bb/ty-0.0.27-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:30231e652b14742a76b64755e54bf0cb1cd4c128bcaf625222e0ca92a2094887", size = 10380488, upload-time = "2026-03-31T19:07:31.268Z" }, - { url = "https://files.pythonhosted.org/packages/b1/f2/20372f6d510b01570028433064880adec2f8abe68bf0c4603be61a560bef/ty-0.0.27-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5a119b1168f64261b3205a37e40b5b6c4aac8fd58e4587988f4e4b22c3c79847", size = 10390248, upload-time = "2026-03-31T19:07:28.345Z" }, - { url = "https://files.pythonhosted.org/packages/45/4b/46b31a7311306be1a560f7f20fdc37b5bf718787f60626cd265d9b637554/ty-0.0.27-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e38f4e187b6975d2cbebf0f1eb1221f8f64f6e509bad14d7bb2a91afc97e4956", size = 10878479, upload-time = "2026-03-31T19:07:39.393Z" }, - { url = "https://files.pythonhosted.org/packages/42/ba/5231a2a1fb1cebe053a25de8fded95e1a30a1e77d3628a9e58487297bafc/ty-0.0.27-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a07b1a8fbb23844f6d22091275430d9ac617175f34aa99159b268193de210389", size = 11461232, upload-time = "2026-03-31T19:07:02.518Z" }, - { url = "https://files.pythonhosted.org/packages/c3/37/558abab3e1f6670493524f61280b4dfcc3219555f13889223e733381dfab/ty-0.0.27-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d3ec4033031f240836bb0337274bac5c49dde312c7c6d7575451ed719bf8ffa3", size = 11133002, upload-time = "2026-03-31T19:07:18.371Z" }, - { url = "https://files.pythonhosted.org/packages/32/38/188c14a57f52160407ce62c6abb556011718fd0bcbe1dca690529ce84c46/ty-0.0.27-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:924a8849afd500d260bf5b7296165a05b7424fbb6b19113f30f3b999d682873f", size = 10986624, upload-time = "2026-03-31T19:07:13.066Z" }, - { url = "https://files.pythonhosted.org/packages/9f/f1/667a71393f47d2cd6ba9ed07541b8df3eb63aab1f2ee658e77d91b8362fa/ty-0.0.27-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:d8270026c07e7423a1b3a3fd065b46ed1478748f0662518b523b57744f3fa025", size = 10366721, upload-time = "2026-03-31T19:07:00.131Z" }, - { url = "https://files.pythonhosted.org/packages/8b/aa/8edafe41be898bda774249abc5be6edd733e53fb1777d59ea9331e38537d/ty-0.0.27-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:e26e9735d3bdfd95d881111ad1cf570eab8188d8c3be36d6bcaad044d38984d8", size = 10412239, upload-time = "2026-03-31T19:07:05.297Z" }, - { url = "https://files.pythonhosted.org/packages/53/ff/8bafaed4a18d38264f46bdfc427de7ea2974cf9064e4e0bdb1b6e6c724e3/ty-0.0.27-py3-none-musllinux_1_2_i686.whl", hash = "sha256:7c09cc9a699810609acc0090af8d0db68adaee6e60a7c3e05ab80cc954a83db7", size = 10573507, upload-time = "2026-03-31T19:06:57.064Z" }, - { url = "https://files.pythonhosted.org/packages/16/2e/63a8284a2fefd08ab56ecbad0fde7dd4b2d4045a31cf24c1d1fcd9643227/ty-0.0.27-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:2d3e02853bb037221a456e034b1898aaa573e6374fbb53884e33cb7513ccb85a", size = 11090233, upload-time = "2026-03-31T19:07:34.139Z" }, - { url = "https://files.pythonhosted.org/packages/14/d3/d6fa1cafdfa2b34dbfa304fc6833af8e1669fc34e24d214fa76d2a2e5a25/ty-0.0.27-py3-none-win32.whl", hash = "sha256:34e7377f2047c14dbbb7bf5322e84114db7a5f2cb470db6bee63f8f3550cfc1e", size = 9984415, upload-time = "2026-03-31T19:07:07.98Z" }, - { url = "https://files.pythonhosted.org/packages/85/e6/dd4e27da9632b3472d5711ca49dbd3709dbd3e8c73f3af6db9c254235ca9/ty-0.0.27-py3-none-win_amd64.whl", hash = "sha256:3f7e4145aad8b815ed69b324c93b5b773eb864dda366ca16ab8693ff88ce6f36", size = 10961535, upload-time = "2026-03-31T19:07:10.566Z" }, - { url = "https://files.pythonhosted.org/packages/0e/1a/824b3496d66852ed7d5d68d9787711131552b68dce8835ce9410db32e618/ty-0.0.27-py3-none-win_arm64.whl", hash = "sha256:95bf8d01eb96bb2ba3ffc39faff19da595176448e80871a7b362f4d2de58476c", size = 10376689, upload-time = "2026-03-31T19:07:25.732Z" }, + { url = "https://files.pythonhosted.org/packages/03/b7/911f9962115acfa24e3b2ec9d4992dd994c38e8769e1b1d7680bb4d28a51/ty-0.0.29-py3-none-linux_armv6l.whl", hash = "sha256:b8a40955f7660d3eaceb0d964affc81b790c0765e7052921a5f861ff8a471c30", size = 10568206, upload-time = "2026-04-05T15:01:19.165Z" }, + { url = "https://files.pythonhosted.org/packages/fe/c3/fcae2167d4c77a97269f92f11d1b43b03617f81de1283d5d05b43432110c/ty-0.0.29-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:6b6849adae15b00bbe2d3c5b078967dcb62eba37d38936b8eeb4c81a82d2e3b8", size = 10442530, upload-time = "2026-04-05T15:01:28.471Z" }, + { url = "https://files.pythonhosted.org/packages/97/33/5a6bfa240cfcb9c36046ae2459fa9ea23238d20130d8656ff5ac4d6c012a/ty-0.0.29-py3-none-macosx_11_0_arm64.whl", hash = "sha256:dcdd9b17209788152f7b7ea815eda07989152325052fe690013537cc7904ce49", size = 9915735, upload-time = "2026-04-05T15:01:10.365Z" }, + { url = "https://files.pythonhosted.org/packages/b3/1e/318f45fae232118e81a6306c30f50de42c509c412128d5bd231eab699ffb/ty-0.0.29-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9d8ed4789bae78ffaf94462c0d25589a734cab0366b86f2bbcb1bb90e1a7a169", size = 10419748, upload-time = "2026-04-05T15:01:32.375Z" }, + { url = "https://files.pythonhosted.org/packages/a9/a8/5687872e2ab5a0f7dd4fd8456eac31e9381ad4dc74961f6f29965ad4dd91/ty-0.0.29-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:91ec374b8565e0ad0900011c24641ebbef2da51adbd4fb69ff3280c8a7eceb02", size = 10394738, upload-time = "2026-04-05T15:01:06.473Z" }, + { url = "https://files.pythonhosted.org/packages/de/68/015d118097eeb95e6a44c4abce4c0a28b7b9dfb3085b7f0ee48e4f099633/ty-0.0.29-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:298a8d5faa2502d3810bbbb47a030b9455495b9921594206043c785dd61548cf", size = 10910613, upload-time = "2026-04-05T15:01:17.17Z" }, + { url = "https://files.pythonhosted.org/packages/1c/01/47ce3c6c53e0670eadbe80756b167bf80ed6681d1ba57cfde2e8065a13d1/ty-0.0.29-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3c8fba1a3524c6109d1e020d92301c79d41bf442fa8d335b9fa366239339cb70", size = 11475750, upload-time = "2026-04-05T15:01:30.461Z" }, + { url = "https://files.pythonhosted.org/packages/c4/cf/e361845b1081c9264ad5b7c963231bab03f2666865a9f2a115c4233f2137/ty-0.0.29-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4c48adf88a70d264128c39ee922ed14a947817fced1e93c08c1a89c9244edcde", size = 11190055, upload-time = "2026-04-05T15:01:12.369Z" }, + { url = "https://files.pythonhosted.org/packages/79/12/0fb0857e9a62cb11586e9a712103877bbf717f5fb570d16634408cfdefee/ty-0.0.29-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2ce0a7a0e96bc7b42518cd3a1a6a6298ef64ff40ca4614355c1aa807059b5c6f", size = 11020539, upload-time = "2026-04-05T15:01:37.022Z" }, + { url = "https://files.pythonhosted.org/packages/20/36/5a26753802083f80cd125db6c4348ad42b3c982ec36e718e0bf4c18f75e5/ty-0.0.29-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:a6ac86a05b4a3731d45365ab97780acc7b8146fa62fccb3cbe94fe6546c67a97", size = 10396399, upload-time = "2026-04-05T15:01:26.167Z" }, + { url = "https://files.pythonhosted.org/packages/00/e6/b4e75b5752239ab3ab400f19faef4dbef81d05aab5d3419fda0c062a3765/ty-0.0.29-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:6bbbf53141af0f3150bf288d716263f1a3550054e4b3551ca866d38192ba9891", size = 10421461, upload-time = "2026-04-05T15:01:08.367Z" }, + { url = "https://files.pythonhosted.org/packages/c0/21/1084b5b609f9abed62070ec0b31c283a403832a6310c8bbc208bd45ee1e6/ty-0.0.29-py3-none-musllinux_1_2_i686.whl", hash = "sha256:1c9e06b770c1d0ff5efc51e34312390db31d53fcf3088163f413030b42b74f84", size = 10599187, upload-time = "2026-04-05T15:01:23.52Z" }, + { url = "https://files.pythonhosted.org/packages/ab/a1/ce19a2ca717bbcc1ee11378aba52ef70b6ce5b87245162a729d9fdc2360f/ty-0.0.29-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:0307fe37e3f000ef1a4ae230bbaf511508a78d24a5e51b40902a21b09d5e6037", size = 11121198, upload-time = "2026-04-05T15:01:15.22Z" }, + { url = "https://files.pythonhosted.org/packages/6b/6b/f1430b279af704321566ce7ec2725d3d8258c2f815ebd93e474c64cd4543/ty-0.0.29-py3-none-win32.whl", hash = "sha256:7a2a898217960a825f8bc0087e1fdbaf379606175e98f9807187221d53a4a8ed", size = 9995331, upload-time = "2026-04-05T15:01:01.32Z" }, + { url = "https://files.pythonhosted.org/packages/d2/ef/3ef01c17785ff9a69378465c7d0faccd48a07b163554db0995e5d65a5a23/ty-0.0.29-py3-none-win_amd64.whl", hash = "sha256:fc1294200226b91615acbf34e0a9ad81caf98c081e9c6a912a31b0a7b603bc3f", size = 11023644, upload-time = "2026-04-05T15:01:04.432Z" }, + { url = "https://files.pythonhosted.org/packages/2c/55/87280a994d6a2d2647c65e12abbc997ed49835794366153c04c4d9304d76/ty-0.0.29-py3-none-win_arm64.whl", hash = "sha256:f9794bbd1bb3ce13f78c191d0c89ae4c63f52c12b6daa0c6fe220b90d019d12c", size = 10428165, upload-time = "2026-04-05T15:01:34.665Z" }, ] [[package]] @@ -499,15 +539,15 @@ wheels = [ [[package]] name = "uvicorn" -version = "0.42.0" +version = "0.44.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "click" }, { name = "h11" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/e3/ad/4a96c425be6fb67e0621e62d86c402b4a17ab2be7f7c055d9bd2f638b9e2/uvicorn-0.42.0.tar.gz", hash = "sha256:9b1f190ce15a2dd22e7758651d9b6d12df09a13d51ba5bf4fc33c383a48e1775", size = 85393, upload-time = "2026-03-16T06:19:50.077Z" } +sdist = { url = "https://files.pythonhosted.org/packages/5e/da/6eee1ff8b6cbeed47eeb5229749168e81eb4b7b999a1a15a7176e51410c9/uvicorn-0.44.0.tar.gz", hash = "sha256:6c942071b68f07e178264b9152f1f16dfac5da85880c4ce06366a96d70d4f31e", size = 86947, upload-time = "2026-04-06T09:23:22.826Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/0a/89/f8827ccff89c1586027a105e5630ff6139a64da2515e24dafe860bd9ae4d/uvicorn-0.42.0-py3-none-any.whl", hash = "sha256:96c30f5c7abe6f74ae8900a70e92b85ad6613b745d4879eb9b16ccad15645359", size = 68830, upload-time = "2026-03-16T06:19:48.325Z" }, + { url = "https://files.pythonhosted.org/packages/b7/23/a5bbd9600dd607411fa644c06ff4951bec3a4d82c4b852374024359c19c0/uvicorn-0.44.0-py3-none-any.whl", hash = "sha256:ce937c99a2cc70279556967274414c087888e8cec9f9c94644dfca11bd3ced89", size = 69425, upload-time = "2026-04-06T09:23:21.524Z" }, ] [[package]] From 19739e9127f983155ed532fca2dfb00009f46dd3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C6=B0u=20Quang=20V=C5=A9?= Date: Mon, 13 Apr 2026 22:55:49 +0700 Subject: [PATCH 237/291] Enable Guest mode --- Dockerfile | 2 +- pyproject.toml | 4 +- uv.lock | 103 +++++++++++++------------------------------------ 3 files changed, 31 insertions(+), 78 deletions(-) diff --git a/Dockerfile b/Dockerfile index 068aa8c..2499479 100644 --- a/Dockerfile +++ b/Dockerfile @@ -10,7 +10,7 @@ WORKDIR /app SHELL ["/bin/bash", "-o", "pipefail", "-c"] RUN apt-get update && apt-get install -y --no-install-recommends \ - tini curl ca-certificates \ + tini curl ca-certificates git \ && rm -rf /var/lib/apt/lists/* ENV UV_COMPILE_BYTECODE=1 \ diff --git a/pyproject.toml b/pyproject.toml index 31740e3..f27761e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -22,7 +22,6 @@ Repository = "https://github.com/Nativu5/Gemini-FastAPI" [project.optional-dependencies] dev = [ - "pytest", "ruff", "ty", ] @@ -63,3 +62,6 @@ extend-immutable-calls = [ [tool.ruff.format] quote-style = "double" indent-style = "space" + +[tool.uv.sources] +gemini-webapi = { git = "https://github.com/luuquangvu/Gemini-API.git", rev = "enable-guest-mode" } diff --git a/uv.lock b/uv.lock index 82c3743..e5aae2d 100644 --- a/uv.lock +++ b/uv.lock @@ -145,7 +145,6 @@ dependencies = [ [package.optional-dependencies] dev = [ - { name = "pytest" }, { name = "ruff" }, { name = "ty" }, ] @@ -159,13 +158,12 @@ dev = [ requires-dist = [ { name = "curl-cffi", specifier = ">=0.15.0" }, { name = "fastapi", specifier = ">=0.135.3" }, - { name = "gemini-webapi", specifier = ">=2.0.0" }, + { name = "gemini-webapi", git = "https://github.com/luuquangvu/Gemini-API.git?rev=enable-guest-mode" }, { name = "httptools", specifier = ">=0.7.1" }, { name = "lmdb", specifier = ">=2.2.0" }, { name = "loguru", specifier = ">=0.7.3" }, { name = "orjson", specifier = ">=3.11.8" }, { name = "pydantic-settings", extras = ["yaml"], specifier = ">=2.13.1" }, - { name = "pytest", marker = "extra == 'dev'" }, { name = "ruff", marker = "extra == 'dev'" }, { name = "ty", marker = "extra == 'dev'" }, { name = "uvicorn", specifier = ">=0.44.0" }, @@ -178,18 +176,14 @@ dev = [{ name = "gemini-fastapi", extras = ["dev"] }] [[package]] name = "gemini-webapi" -version = "2.0.0" -source = { registry = "https://pypi.org/simple" } +version = "0.0.post260" +source = { git = "https://github.com/luuquangvu/Gemini-API.git?rev=enable-guest-mode#3325132aedbc4261e6cbddf45b8b211ea7354978" } dependencies = [ { name = "curl-cffi" }, { name = "loguru" }, { name = "orjson" }, { name = "pydantic" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/2c/2a/1d46470ec287f978565b000bf4fd9cc3eee8f4193ceec70094dc12a724a3/gemini_webapi-2.0.0.tar.gz", hash = "sha256:5ee9d8ad9fd4c7fdc50ebfd152c9cf1dc4c30f7e9984bae6dffed9a0cb039735", size = 300490, upload-time = "2026-04-06T21:19:16.427Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/4f/9b/bae89ced30ce74ae96793c05c2eb3e92de6fe3e619f91802aba686dd8027/gemini_webapi-2.0.0-py3-none-any.whl", hash = "sha256:6f2b922a5923afbb432c3c95cb6edd66e73cf5d99c95e9e40e63912e4131aff8", size = 92815, upload-time = "2026-04-06T21:19:14.933Z" }, -] [[package]] name = "h11" @@ -224,15 +218,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" }, ] -[[package]] -name = "iniconfig" -version = "2.3.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, -] - [[package]] name = "lmdb" version = "2.2.0" @@ -304,24 +289,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/57/7f/803203d00d6edb6e9e7eef421d4e1adbb5ea973e40b3533f3cfd9aeb374e/orjson-3.11.8-cp313-cp313-win_arm64.whl", hash = "sha256:4861bde57f4d253ab041e374f44023460e60e71efaa121f3c5f0ed457c3a701e", size = 127338, upload-time = "2026-03-31T16:15:59.106Z" }, ] -[[package]] -name = "packaging" -version = "26.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/65/ee/299d360cdc32edc7d2cf530f3accf79c4fca01e96ffc950d8a52213bd8e4/packaging-26.0.tar.gz", hash = "sha256:00243ae351a257117b6a241061796684b084ed1c516a08c48a3f7e147a9d80b4", size = 143416, upload-time = "2026-01-21T20:50:39.064Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/b7/b9/c538f279a4e237a006a2c98387d081e9eb060d203d8ed34467cc0f0b9b53/packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529", size = 74366, upload-time = "2026-01-21T20:50:37.788Z" }, -] - -[[package]] -name = "pluggy" -version = "1.6.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, -] - [[package]] name = "pycparser" version = "3.0" @@ -399,22 +366,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, ] -[[package]] -name = "pytest" -version = "9.0.3" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "colorama", marker = "sys_platform == 'win32'" }, - { name = "iniconfig" }, - { name = "packaging" }, - { name = "pluggy" }, - { name = "pygments" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/7d/0d/549bd94f1a0a402dc8cf64563a117c0f3765662e2e668477624baeec44d5/pytest-9.0.3.tar.gz", hash = "sha256:b86ada508af81d19edeb213c681b1d48246c1a91d304c6c81a427674c17eb91c", size = 1572165, upload-time = "2026-04-07T17:16:18.027Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9", size = 375249, upload-time = "2026-04-07T17:16:16.13Z" }, -] - [[package]] name = "python-dotenv" version = "1.2.2" @@ -444,40 +395,40 @@ wheels = [ [[package]] name = "rich" -version = "14.3.3" +version = "15.0.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "markdown-it-py" }, { name = "pygments" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/b3/c6/f3b320c27991c46f43ee9d856302c70dc2d0fb2dba4842ff739d5f46b393/rich-14.3.3.tar.gz", hash = "sha256:b8daa0b9e4eef54dd8cf7c86c03713f53241884e814f4e2f5fb342fe520f639b", size = 230582, upload-time = "2026-02-19T17:23:12.474Z" } +sdist = { url = "https://files.pythonhosted.org/packages/c0/8f/0722ca900cc807c13a6a0c696dacf35430f72e0ec571c4275d2371fca3e9/rich-15.0.0.tar.gz", hash = "sha256:edd07a4824c6b40189fb7ac9bc4c52536e9780fbbfbddf6f1e2502c31b068c36", size = 230680, upload-time = "2026-04-12T08:24:00.75Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/14/25/b208c5683343959b670dc001595f2f3737e051da617f66c31f7c4fa93abc/rich-14.3.3-py3-none-any.whl", hash = "sha256:793431c1f8619afa7d3b52b2cdec859562b950ea0d4b6b505397612db8d5362d", size = 310458, upload-time = "2026-02-19T17:23:13.732Z" }, + { url = "https://files.pythonhosted.org/packages/82/3b/64d4899d73f91ba49a8c18a8ff3f0ea8f1c1d75481760df8c68ef5235bf5/rich-15.0.0-py3-none-any.whl", hash = "sha256:33bd4ef74232fb73fe9279a257718407f169c09b78a87ad3d296f548e27de0bb", size = 310654, upload-time = "2026-04-12T08:24:02.83Z" }, ] [[package]] name = "ruff" -version = "0.15.9" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/e6/97/e9f1ca355108ef7194e38c812ef40ba98c7208f47b13ad78d023caa583da/ruff-0.15.9.tar.gz", hash = "sha256:29cbb1255a9797903f6dde5ba0188c707907ff44a9006eb273b5a17bfa0739a2", size = 4617361, upload-time = "2026-04-02T18:17:20.829Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/0b/1f/9cdfd0ac4b9d1e5a6cf09bedabdf0b56306ab5e333c85c87281273e7b041/ruff-0.15.9-py3-none-linux_armv6l.whl", hash = "sha256:6efbe303983441c51975c243e26dff328aca11f94b70992f35b093c2e71801e1", size = 10511206, upload-time = "2026-04-02T18:16:41.574Z" }, - { url = "https://files.pythonhosted.org/packages/3d/f6/32bfe3e9c136b35f02e489778d94384118bb80fd92c6d92e7ccd97db12ce/ruff-0.15.9-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:4965bac6ac9ea86772f4e23587746f0b7a395eccabb823eb8bfacc3fa06069f7", size = 10923307, upload-time = "2026-04-02T18:17:08.645Z" }, - { url = "https://files.pythonhosted.org/packages/ca/25/de55f52ab5535d12e7aaba1de37a84be6179fb20bddcbe71ec091b4a3243/ruff-0.15.9-py3-none-macosx_11_0_arm64.whl", hash = "sha256:eaf05aad70ca5b5a0a4b0e080df3a6b699803916d88f006efd1f5b46302daab8", size = 10316722, upload-time = "2026-04-02T18:16:44.206Z" }, - { url = "https://files.pythonhosted.org/packages/48/11/690d75f3fd6278fe55fff7c9eb429c92d207e14b25d1cae4064a32677029/ruff-0.15.9-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9439a342adb8725f32f92732e2bafb6d5246bd7a5021101166b223d312e8fc59", size = 10623674, upload-time = "2026-04-02T18:16:50.951Z" }, - { url = "https://files.pythonhosted.org/packages/bd/ec/176f6987be248fc5404199255522f57af1b4a5a1b57727e942479fec98ad/ruff-0.15.9-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9c5e6faf9d97c8edc43877c3f406f47446fc48c40e1442d58cfcdaba2acea745", size = 10351516, upload-time = "2026-04-02T18:16:57.206Z" }, - { url = "https://files.pythonhosted.org/packages/b2/fc/51cffbd2b3f240accc380171d51446a32aa2ea43a40d4a45ada67368fbd2/ruff-0.15.9-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7b34a9766aeec27a222373d0b055722900fbc0582b24f39661aa96f3fe6ad901", size = 11150202, upload-time = "2026-04-02T18:17:06.452Z" }, - { url = "https://files.pythonhosted.org/packages/d6/d4/25292a6dfc125f6b6528fe6af31f5e996e19bf73ca8e3ce6eb7fa5b95885/ruff-0.15.9-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:89dd695bc72ae76ff484ae54b7e8b0f6b50f49046e198355e44ea656e521fef9", size = 11988891, upload-time = "2026-04-02T18:17:18.575Z" }, - { url = "https://files.pythonhosted.org/packages/13/e1/1eebcb885c10e19f969dcb93d8413dfee8172578709d7ee933640f5e7147/ruff-0.15.9-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ce187224ef1de1bd225bc9a152ac7102a6171107f026e81f317e4257052916d5", size = 11480576, upload-time = "2026-04-02T18:16:52.986Z" }, - { url = "https://files.pythonhosted.org/packages/ff/6b/a1548ac378a78332a4c3dcf4a134c2475a36d2a22ddfa272acd574140b50/ruff-0.15.9-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2b0c7c341f68adb01c488c3b7d4b49aa8ea97409eae6462d860a79cf55f431b6", size = 11254525, upload-time = "2026-04-02T18:17:02.041Z" }, - { url = "https://files.pythonhosted.org/packages/42/aa/4bb3af8e61acd9b1281db2ab77e8b2c3c5e5599bf2a29d4a942f1c62b8d6/ruff-0.15.9-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:55cc15eee27dc0eebdfcb0d185a6153420efbedc15eb1d38fe5e685657b0f840", size = 11204072, upload-time = "2026-04-02T18:17:13.581Z" }, - { url = "https://files.pythonhosted.org/packages/69/48/d550dc2aa6e423ea0bcc1d0ff0699325ffe8a811e2dba156bd80750b86dc/ruff-0.15.9-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:a6537f6eed5cda688c81073d46ffdfb962a5f29ecb6f7e770b2dc920598997ed", size = 10594998, upload-time = "2026-04-02T18:16:46.369Z" }, - { url = "https://files.pythonhosted.org/packages/63/47/321167e17f5344ed5ec6b0aa2cff64efef5f9e985af8f5622cfa6536043f/ruff-0.15.9-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:6d3fcbca7388b066139c523bda744c822258ebdcfbba7d24410c3f454cc9af71", size = 10359769, upload-time = "2026-04-02T18:17:10.994Z" }, - { url = "https://files.pythonhosted.org/packages/67/5e/074f00b9785d1d2c6f8c22a21e023d0c2c1817838cfca4c8243200a1fa87/ruff-0.15.9-py3-none-musllinux_1_2_i686.whl", hash = "sha256:058d8e99e1bfe79d8a0def0b481c56059ee6716214f7e425d8e737e412d69677", size = 10850236, upload-time = "2026-04-02T18:16:48.749Z" }, - { url = "https://files.pythonhosted.org/packages/76/37/804c4135a2a2caf042925d30d5f68181bdbd4461fd0d7739da28305df593/ruff-0.15.9-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:8e1ddb11dbd61d5983fa2d7d6370ef3eb210951e443cace19594c01c72abab4c", size = 11358343, upload-time = "2026-04-02T18:16:55.068Z" }, - { url = "https://files.pythonhosted.org/packages/88/3d/1364fcde8656962782aa9ea93c92d98682b1ecec2f184e625a965ad3b4a6/ruff-0.15.9-py3-none-win32.whl", hash = "sha256:bde6ff36eaf72b700f32b7196088970bf8fdb2b917b7accd8c371bfc0fd573ec", size = 10583382, upload-time = "2026-04-02T18:17:04.261Z" }, - { url = "https://files.pythonhosted.org/packages/4c/56/5c7084299bd2cacaa07ae63a91c6f4ba66edc08bf28f356b24f6b717c799/ruff-0.15.9-py3-none-win_amd64.whl", hash = "sha256:45a70921b80e1c10cf0b734ef09421f71b5aa11d27404edc89d7e8a69505e43d", size = 11744969, upload-time = "2026-04-02T18:16:59.611Z" }, - { url = "https://files.pythonhosted.org/packages/03/36/76704c4f312257d6dbaae3c959add2a622f63fcca9d864659ce6d8d97d3d/ruff-0.15.9-py3-none-win_arm64.whl", hash = "sha256:0694e601c028fd97dc5c6ee244675bc241aeefced7ef80cd9c6935a871078f53", size = 11005870, upload-time = "2026-04-02T18:17:15.773Z" }, +version = "0.15.10" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e7/d9/aa3f7d59a10ef6b14fe3431706f854dbf03c5976be614a9796d36326810c/ruff-0.15.10.tar.gz", hash = "sha256:d1f86e67ebfdef88e00faefa1552b5e510e1d35f3be7d423dc7e84e63788c94e", size = 4631728, upload-time = "2026-04-09T14:06:09.884Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/eb/00/a1c2fdc9939b2c03691edbda290afcd297f1f389196172826b03d6b6a595/ruff-0.15.10-py3-none-linux_armv6l.whl", hash = "sha256:0744e31482f8f7d0d10a11fcbf897af272fefdfcb10f5af907b18c2813ff4d5f", size = 10563362, upload-time = "2026-04-09T14:06:21.189Z" }, + { url = "https://files.pythonhosted.org/packages/5c/15/006990029aea0bebe9d33c73c3e28c80c391ebdba408d1b08496f00d422d/ruff-0.15.10-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:b1e7c16ea0ff5a53b7c2df52d947e685973049be1cdfe2b59a9c43601897b22e", size = 10951122, upload-time = "2026-04-09T14:06:02.236Z" }, + { url = "https://files.pythonhosted.org/packages/f2/c0/4ac978fe874d0618c7da647862afe697b281c2806f13ce904ad652fa87e4/ruff-0.15.10-py3-none-macosx_11_0_arm64.whl", hash = "sha256:93cc06a19e5155b4441dd72808fdf84290d84ad8a39ca3b0f994363ade4cebb1", size = 10314005, upload-time = "2026-04-09T14:06:00.026Z" }, + { url = "https://files.pythonhosted.org/packages/da/73/c209138a5c98c0d321266372fc4e33ad43d506d7e5dd817dd89b60a8548f/ruff-0.15.10-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:83e1dd04312997c99ea6965df66a14fb4f03ba978564574ffc68b0d61fd3989e", size = 10643450, upload-time = "2026-04-09T14:05:42.137Z" }, + { url = "https://files.pythonhosted.org/packages/ec/76/0deec355d8ec10709653635b1f90856735302cb8e149acfdf6f82a5feb70/ruff-0.15.10-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8154d43684e4333360fedd11aaa40b1b08a4e37d8ffa9d95fee6fa5b37b6fab1", size = 10379597, upload-time = "2026-04-09T14:05:49.984Z" }, + { url = "https://files.pythonhosted.org/packages/dc/be/86bba8fc8798c081e28a4b3bb6d143ccad3fd5f6f024f02002b8f08a9fa3/ruff-0.15.10-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8ab88715f3a6deb6bde6c227f3a123410bec7b855c3ae331b4c006189e895cef", size = 11146645, upload-time = "2026-04-09T14:06:12.246Z" }, + { url = "https://files.pythonhosted.org/packages/a8/89/140025e65911b281c57be1d385ba1d932c2366ca88ae6663685aed8d4881/ruff-0.15.10-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a768ff5969b4f44c349d48edf4ab4f91eddb27fd9d77799598e130fb628aa158", size = 12030289, upload-time = "2026-04-09T14:06:04.776Z" }, + { url = "https://files.pythonhosted.org/packages/88/de/ddacca9545a5e01332567db01d44bd8cf725f2db3b3d61a80550b48308ea/ruff-0.15.10-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0ee3ef42dab7078bda5ff6a1bcba8539e9857deb447132ad5566a038674540d0", size = 11496266, upload-time = "2026-04-09T14:05:55.485Z" }, + { url = "https://files.pythonhosted.org/packages/bc/bb/7ddb00a83760ff4a83c4e2fc231fd63937cc7317c10c82f583302e0f6586/ruff-0.15.10-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:51cb8cc943e891ba99989dd92d61e29b1d231e14811db9be6440ecf25d5c1609", size = 11256418, upload-time = "2026-04-09T14:05:57.69Z" }, + { url = "https://files.pythonhosted.org/packages/dc/8d/55de0d35aacf6cd50b6ee91ee0f291672080021896543776f4170fc5c454/ruff-0.15.10-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:e59c9bdc056a320fb9ea1700a8d591718b8faf78af065484e801258d3a76bc3f", size = 11288416, upload-time = "2026-04-09T14:05:44.695Z" }, + { url = "https://files.pythonhosted.org/packages/68/cf/9438b1a27426ec46a80e0a718093c7f958ef72f43eb3111862949ead3cc1/ruff-0.15.10-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:136c00ca2f47b0018b073f28cb5c1506642a830ea941a60354b0e8bc8076b151", size = 10621053, upload-time = "2026-04-09T14:05:52.782Z" }, + { url = "https://files.pythonhosted.org/packages/4c/50/e29be6e2c135e9cd4cb15fbade49d6a2717e009dff3766dd080fcb82e251/ruff-0.15.10-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:8b80a2f3c9c8a950d6237f2ca12b206bccff626139be9fa005f14feb881a1ae8", size = 10378302, upload-time = "2026-04-09T14:06:14.361Z" }, + { url = "https://files.pythonhosted.org/packages/18/2f/e0b36a6f99c51bb89f3a30239bc7bf97e87a37ae80aa2d6542d6e5150364/ruff-0.15.10-py3-none-musllinux_1_2_i686.whl", hash = "sha256:e3e53c588164dc025b671c9df2462429d60357ea91af7e92e9d56c565a9f1b07", size = 10850074, upload-time = "2026-04-09T14:06:16.581Z" }, + { url = "https://files.pythonhosted.org/packages/11/08/874da392558ce087a0f9b709dc6ec0d60cbc694c1c772dab8d5f31efe8cb/ruff-0.15.10-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:b0c52744cf9f143a393e284125d2576140b68264a93c6716464e129a3e9adb48", size = 11358051, upload-time = "2026-04-09T14:06:18.948Z" }, + { url = "https://files.pythonhosted.org/packages/e4/46/602938f030adfa043e67112b73821024dc79f3ab4df5474c25fa4c1d2d14/ruff-0.15.10-py3-none-win32.whl", hash = "sha256:d4272e87e801e9a27a2e8df7b21011c909d9ddd82f4f3281d269b6ba19789ca5", size = 10588964, upload-time = "2026-04-09T14:06:07.14Z" }, + { url = "https://files.pythonhosted.org/packages/25/b6/261225b875d7a13b33a6d02508c39c28450b2041bb01d0f7f1a83d569512/ruff-0.15.10-py3-none-win_amd64.whl", hash = "sha256:28cb32d53203242d403d819fd6983152489b12e4a3ae44993543d6fe62ab42ed", size = 11745044, upload-time = "2026-04-09T14:05:39.473Z" }, + { url = "https://files.pythonhosted.org/packages/58/ed/dea90a65b7d9e69888890fb14c90d7f51bf0c1e82ad800aeb0160e4bacfd/ruff-0.15.10-py3-none-win_arm64.whl", hash = "sha256:601d1610a9e1f1c2165a4f561eeaa2e2ea1e97f3287c5aa258d3dab8b57c6188", size = 11035607, upload-time = "2026-04-09T14:05:47.593Z" }, ] [[package]] From e3c029450db71b195ff36de7a8e96f26c22c623d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C6=B0u=20Quang=20V=C5=A9?= Date: Tue, 14 Apr 2026 09:59:38 +0700 Subject: [PATCH 238/291] Continue updating Guest mode --- uv.lock | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/uv.lock b/uv.lock index e5aae2d..b617831 100644 --- a/uv.lock +++ b/uv.lock @@ -176,8 +176,8 @@ dev = [{ name = "gemini-fastapi", extras = ["dev"] }] [[package]] name = "gemini-webapi" -version = "0.0.post260" -source = { git = "https://github.com/luuquangvu/Gemini-API.git?rev=enable-guest-mode#3325132aedbc4261e6cbddf45b8b211ea7354978" } +version = "0.0.post263" +source = { git = "https://github.com/luuquangvu/Gemini-API.git?rev=enable-guest-mode#af4c48569e7098465a29d521b93a15407dc49f13" } dependencies = [ { name = "curl-cffi" }, { name = "loguru" }, From 37cd39b0411bd7fdc6d06da227936e4332b3078f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C6=B0u=20Quang=20V=C5=A9?= Date: Tue, 14 Apr 2026 14:56:26 +0700 Subject: [PATCH 239/291] Get account quotas, abuse status --- uv.lock | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/uv.lock b/uv.lock index b617831..225f7a6 100644 --- a/uv.lock +++ b/uv.lock @@ -176,8 +176,8 @@ dev = [{ name = "gemini-fastapi", extras = ["dev"] }] [[package]] name = "gemini-webapi" -version = "0.0.post263" -source = { git = "https://github.com/luuquangvu/Gemini-API.git?rev=enable-guest-mode#af4c48569e7098465a29d521b93a15407dc49f13" } +version = "0.0.post264" +source = { git = "https://github.com/luuquangvu/Gemini-API.git?rev=enable-guest-mode#b3426858b0a7146643a8da9c212935dae3773351" } dependencies = [ { name = "curl-cffi" }, { name = "loguru" }, From 1a57aeb072538e79a25f257f88c055e34b18527a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C6=B0u=20Quang=20V=C5=A9?= Date: Tue, 14 Apr 2026 15:32:59 +0700 Subject: [PATCH 240/291] Update the account quotas logic to make it more display-friendly --- uv.lock | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/uv.lock b/uv.lock index 225f7a6..117dcc3 100644 --- a/uv.lock +++ b/uv.lock @@ -176,8 +176,8 @@ dev = [{ name = "gemini-fastapi", extras = ["dev"] }] [[package]] name = "gemini-webapi" -version = "0.0.post264" -source = { git = "https://github.com/luuquangvu/Gemini-API.git?rev=enable-guest-mode#b3426858b0a7146643a8da9c212935dae3773351" } +version = "0.0.post265" +source = { git = "https://github.com/luuquangvu/Gemini-API.git?rev=enable-guest-mode#69fa4e278f2d5487c4313dfd45e363da080211aa" } dependencies = [ { name = "curl-cffi" }, { name = "loguru" }, From f40e4e03a8210e7802d48949ce6c5d035cda0989 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C6=B0u=20Quang=20V=C5=A9?= Date: Tue, 14 Apr 2026 15:54:38 +0700 Subject: [PATCH 241/291] Update the account quotas logic to make it more display-friendly --- uv.lock | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/uv.lock b/uv.lock index 117dcc3..766c0f7 100644 --- a/uv.lock +++ b/uv.lock @@ -176,8 +176,8 @@ dev = [{ name = "gemini-fastapi", extras = ["dev"] }] [[package]] name = "gemini-webapi" -version = "0.0.post265" -source = { git = "https://github.com/luuquangvu/Gemini-API.git?rev=enable-guest-mode#69fa4e278f2d5487c4313dfd45e363da080211aa" } +version = "0.0.post266" +source = { git = "https://github.com/luuquangvu/Gemini-API.git?rev=enable-guest-mode#411eab1ef604febe43301b568c0209320688f5a6" } dependencies = [ { name = "curl-cffi" }, { name = "loguru" }, From 86a8ddecee702c57d442c04e1f0df7492e5e2364 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C6=B0u=20Quang=20V=C5=A9?= Date: Tue, 14 Apr 2026 16:50:39 +0700 Subject: [PATCH 242/291] Update the account quotas logic to make it more display-friendly --- uv.lock | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/uv.lock b/uv.lock index 766c0f7..687b270 100644 --- a/uv.lock +++ b/uv.lock @@ -176,8 +176,8 @@ dev = [{ name = "gemini-fastapi", extras = ["dev"] }] [[package]] name = "gemini-webapi" -version = "0.0.post266" -source = { git = "https://github.com/luuquangvu/Gemini-API.git?rev=enable-guest-mode#411eab1ef604febe43301b568c0209320688f5a6" } +version = "0.0.post267" +source = { git = "https://github.com/luuquangvu/Gemini-API.git?rev=enable-guest-mode#2999b2ec6c85f2430c89744f1a435af0f2ce6c31" } dependencies = [ { name = "curl-cffi" }, { name = "loguru" }, From df512c3039e068169ef6491f7890997ec9c6f2c5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C6=B0u=20Quang=20V=C5=A9?= Date: Tue, 14 Apr 2026 18:25:51 +0700 Subject: [PATCH 243/291] Update the account quotas logic to make it more display-friendly --- uv.lock | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/uv.lock b/uv.lock index 687b270..229e85c 100644 --- a/uv.lock +++ b/uv.lock @@ -176,8 +176,8 @@ dev = [{ name = "gemini-fastapi", extras = ["dev"] }] [[package]] name = "gemini-webapi" -version = "0.0.post267" -source = { git = "https://github.com/luuquangvu/Gemini-API.git?rev=enable-guest-mode#2999b2ec6c85f2430c89744f1a435af0f2ce6c31" } +version = "0.0.post268" +source = { git = "https://github.com/luuquangvu/Gemini-API.git?rev=enable-guest-mode#dcfda5b44cc0a1edcad628290a571b34a1ff3a3e" } dependencies = [ { name = "curl-cffi" }, { name = "loguru" }, From 506c78fd60bb7d2cf7442838caf1de71e54fffb8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C6=B0u=20Quang=20V=C5=A9?= Date: Tue, 14 Apr 2026 19:52:28 +0700 Subject: [PATCH 244/291] Add a background task to send HTTP/2 PING frames --- uv.lock | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/uv.lock b/uv.lock index 229e85c..cab7f92 100644 --- a/uv.lock +++ b/uv.lock @@ -176,8 +176,8 @@ dev = [{ name = "gemini-fastapi", extras = ["dev"] }] [[package]] name = "gemini-webapi" -version = "0.0.post268" -source = { git = "https://github.com/luuquangvu/Gemini-API.git?rev=enable-guest-mode#dcfda5b44cc0a1edcad628290a571b34a1ff3a3e" } +version = "0.0.post269" +source = { git = "https://github.com/luuquangvu/Gemini-API.git?rev=enable-guest-mode#c8168c787f5dcaef3a1acc2235d68de168d0e55a" } dependencies = [ { name = "curl-cffi" }, { name = "loguru" }, From 90aefdbad24f6c1dc1cee8f83056c84df86f37a4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C6=B0u=20Quang=20V=C5=A9?= Date: Wed, 15 Apr 2026 09:18:40 +0700 Subject: [PATCH 245/291] Explicitly use HTTP/2 and include SSRF protection --- app/utils/helper.py | 4 ++-- uv.lock | 44 ++++++++++++++++++++++---------------------- 2 files changed, 24 insertions(+), 24 deletions(-) diff --git a/app/utils/helper.py b/app/utils/helper.py index 781ec33..a6d351b 100644 --- a/app/utils/helper.py +++ b/app/utils/helper.py @@ -11,7 +11,7 @@ from urllib.parse import urlparse import orjson -from curl_cffi import CurlFollow, requests +from curl_cffi import CurlFollow, CurlHttpVersion, requests from loguru import logger from app.models import AppMessage, AppToolCall, AppToolCallFunction @@ -200,7 +200,7 @@ async def save_url_to_tempfile(url: str, tempdir: Path | None = None) -> Path: suffix = ".bin" else: async with requests.AsyncSession( - impersonate="chrome", allow_redirects=CurlFollow.SAFE + impersonate="chrome", allow_redirects=CurlFollow.SAFE, http_version=CurlHttpVersion.V2_0 ) as client: resp = await client.get(url) resp.raise_for_status() diff --git a/uv.lock b/uv.lock index cab7f92..d632800 100644 --- a/uv.lock +++ b/uv.lock @@ -176,8 +176,8 @@ dev = [{ name = "gemini-fastapi", extras = ["dev"] }] [[package]] name = "gemini-webapi" -version = "0.0.post269" -source = { git = "https://github.com/luuquangvu/Gemini-API.git?rev=enable-guest-mode#c8168c787f5dcaef3a1acc2235d68de168d0e55a" } +version = "0.0.post270" +source = { git = "https://github.com/luuquangvu/Gemini-API.git?rev=enable-guest-mode#4e2443cdd219a080b31e95e60f7600c0655a553e" } dependencies = [ { name = "curl-cffi" }, { name = "loguru" }, @@ -445,26 +445,26 @@ wheels = [ [[package]] name = "ty" -version = "0.0.29" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/47/d5/853561de49fae38c519e905b2d8da9c531219608f1fccc47a0fc2c896980/ty-0.0.29.tar.gz", hash = "sha256:e7936cca2f691eeda631876c92809688dbbab68687c3473f526cd83b6a9228d8", size = 5469221, upload-time = "2026-04-05T15:01:21.328Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/03/b7/911f9962115acfa24e3b2ec9d4992dd994c38e8769e1b1d7680bb4d28a51/ty-0.0.29-py3-none-linux_armv6l.whl", hash = "sha256:b8a40955f7660d3eaceb0d964affc81b790c0765e7052921a5f861ff8a471c30", size = 10568206, upload-time = "2026-04-05T15:01:19.165Z" }, - { url = "https://files.pythonhosted.org/packages/fe/c3/fcae2167d4c77a97269f92f11d1b43b03617f81de1283d5d05b43432110c/ty-0.0.29-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:6b6849adae15b00bbe2d3c5b078967dcb62eba37d38936b8eeb4c81a82d2e3b8", size = 10442530, upload-time = "2026-04-05T15:01:28.471Z" }, - { url = "https://files.pythonhosted.org/packages/97/33/5a6bfa240cfcb9c36046ae2459fa9ea23238d20130d8656ff5ac4d6c012a/ty-0.0.29-py3-none-macosx_11_0_arm64.whl", hash = "sha256:dcdd9b17209788152f7b7ea815eda07989152325052fe690013537cc7904ce49", size = 9915735, upload-time = "2026-04-05T15:01:10.365Z" }, - { url = "https://files.pythonhosted.org/packages/b3/1e/318f45fae232118e81a6306c30f50de42c509c412128d5bd231eab699ffb/ty-0.0.29-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9d8ed4789bae78ffaf94462c0d25589a734cab0366b86f2bbcb1bb90e1a7a169", size = 10419748, upload-time = "2026-04-05T15:01:32.375Z" }, - { url = "https://files.pythonhosted.org/packages/a9/a8/5687872e2ab5a0f7dd4fd8456eac31e9381ad4dc74961f6f29965ad4dd91/ty-0.0.29-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:91ec374b8565e0ad0900011c24641ebbef2da51adbd4fb69ff3280c8a7eceb02", size = 10394738, upload-time = "2026-04-05T15:01:06.473Z" }, - { url = "https://files.pythonhosted.org/packages/de/68/015d118097eeb95e6a44c4abce4c0a28b7b9dfb3085b7f0ee48e4f099633/ty-0.0.29-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:298a8d5faa2502d3810bbbb47a030b9455495b9921594206043c785dd61548cf", size = 10910613, upload-time = "2026-04-05T15:01:17.17Z" }, - { url = "https://files.pythonhosted.org/packages/1c/01/47ce3c6c53e0670eadbe80756b167bf80ed6681d1ba57cfde2e8065a13d1/ty-0.0.29-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3c8fba1a3524c6109d1e020d92301c79d41bf442fa8d335b9fa366239339cb70", size = 11475750, upload-time = "2026-04-05T15:01:30.461Z" }, - { url = "https://files.pythonhosted.org/packages/c4/cf/e361845b1081c9264ad5b7c963231bab03f2666865a9f2a115c4233f2137/ty-0.0.29-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4c48adf88a70d264128c39ee922ed14a947817fced1e93c08c1a89c9244edcde", size = 11190055, upload-time = "2026-04-05T15:01:12.369Z" }, - { url = "https://files.pythonhosted.org/packages/79/12/0fb0857e9a62cb11586e9a712103877bbf717f5fb570d16634408cfdefee/ty-0.0.29-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2ce0a7a0e96bc7b42518cd3a1a6a6298ef64ff40ca4614355c1aa807059b5c6f", size = 11020539, upload-time = "2026-04-05T15:01:37.022Z" }, - { url = "https://files.pythonhosted.org/packages/20/36/5a26753802083f80cd125db6c4348ad42b3c982ec36e718e0bf4c18f75e5/ty-0.0.29-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:a6ac86a05b4a3731d45365ab97780acc7b8146fa62fccb3cbe94fe6546c67a97", size = 10396399, upload-time = "2026-04-05T15:01:26.167Z" }, - { url = "https://files.pythonhosted.org/packages/00/e6/b4e75b5752239ab3ab400f19faef4dbef81d05aab5d3419fda0c062a3765/ty-0.0.29-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:6bbbf53141af0f3150bf288d716263f1a3550054e4b3551ca866d38192ba9891", size = 10421461, upload-time = "2026-04-05T15:01:08.367Z" }, - { url = "https://files.pythonhosted.org/packages/c0/21/1084b5b609f9abed62070ec0b31c283a403832a6310c8bbc208bd45ee1e6/ty-0.0.29-py3-none-musllinux_1_2_i686.whl", hash = "sha256:1c9e06b770c1d0ff5efc51e34312390db31d53fcf3088163f413030b42b74f84", size = 10599187, upload-time = "2026-04-05T15:01:23.52Z" }, - { url = "https://files.pythonhosted.org/packages/ab/a1/ce19a2ca717bbcc1ee11378aba52ef70b6ce5b87245162a729d9fdc2360f/ty-0.0.29-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:0307fe37e3f000ef1a4ae230bbaf511508a78d24a5e51b40902a21b09d5e6037", size = 11121198, upload-time = "2026-04-05T15:01:15.22Z" }, - { url = "https://files.pythonhosted.org/packages/6b/6b/f1430b279af704321566ce7ec2725d3d8258c2f815ebd93e474c64cd4543/ty-0.0.29-py3-none-win32.whl", hash = "sha256:7a2a898217960a825f8bc0087e1fdbaf379606175e98f9807187221d53a4a8ed", size = 9995331, upload-time = "2026-04-05T15:01:01.32Z" }, - { url = "https://files.pythonhosted.org/packages/d2/ef/3ef01c17785ff9a69378465c7d0faccd48a07b163554db0995e5d65a5a23/ty-0.0.29-py3-none-win_amd64.whl", hash = "sha256:fc1294200226b91615acbf34e0a9ad81caf98c081e9c6a912a31b0a7b603bc3f", size = 11023644, upload-time = "2026-04-05T15:01:04.432Z" }, - { url = "https://files.pythonhosted.org/packages/2c/55/87280a994d6a2d2647c65e12abbc997ed49835794366153c04c4d9304d76/ty-0.0.29-py3-none-win_arm64.whl", hash = "sha256:f9794bbd1bb3ce13f78c191d0c89ae4c63f52c12b6daa0c6fe220b90d019d12c", size = 10428165, upload-time = "2026-04-05T15:01:34.665Z" }, +version = "0.0.30" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/62/21/3ee32f163038ac2663c7bea47a07d06bf4cc7c09d95b96db194bda1b70cb/ty-0.0.30.tar.gz", hash = "sha256:c982207640e7d75331b81031ebfb884ab858ed26ab16d7c086ac4942e2771846", size = 5518350, upload-time = "2026-04-14T13:53:35.462Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a8/24/7aa94d02a9257ed96e64e4e99b527f28390febd8424107b4f8a70763ace9/ty-0.0.30-py3-none-linux_armv6l.whl", hash = "sha256:1be31a24a2a177571c3276854bf01b2b1a77dba6e754507089c25bb1825ce5f6", size = 10801835, upload-time = "2026-04-14T13:53:21.477Z" }, + { url = "https://files.pythonhosted.org/packages/ec/97/2410ebc85cfcdf3bbd0e5958c6cd0b88085b1a184374ecfa755f84d6c8b2/ty-0.0.30-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:019f1d0d5d5265a1e634a51fd49374df43dafae14de98c2a0d349beb8233550b", size = 10582386, upload-time = "2026-04-14T13:53:07.472Z" }, + { url = "https://files.pythonhosted.org/packages/e3/d2/a2649eb6841ebf946ac827e778b7e78b5ef63c3758bf2b9da13d927a53da/ty-0.0.30-py3-none-macosx_11_0_arm64.whl", hash = "sha256:fe3012af4d0714e7353fd3cf6d2d02d5b0f0fe6f1cb8beb2366ed9f621c2c349", size = 10031621, upload-time = "2026-04-14T13:53:01.523Z" }, + { url = "https://files.pythonhosted.org/packages/6e/8e/40a66ccd5d5d51adf0469b9fbe4f1f79f928a880b34b8a6c7c934e8a883a/ty-0.0.30-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d1e90b4ebf6310c7734344739e0950f4cede5a33b1e51a12a0c0fc8a975866ed", size = 10537511, upload-time = "2026-04-14T13:53:04.538Z" }, + { url = "https://files.pythonhosted.org/packages/25/31/5dea2987601ef1c8c58b04f2173971e7fe51f7902ab93a66d09e0f12115a/ty-0.0.30-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:fd83a0d82cbc32c2ae521e7fa101fb5fe5b566adb1364996582535700572a9ec", size = 10603406, upload-time = "2026-04-14T13:53:47.564Z" }, + { url = "https://files.pythonhosted.org/packages/b9/a4/5a7585b6b219a2edc00255af0b16a8475f88fe43c5cdbe499daecb67f100/ty-0.0.30-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:672a29271c13247096d0b2766e69cb35b1583882dd6e7b24065927e2491ffe6d", size = 11109133, upload-time = "2026-04-14T13:53:24.463Z" }, + { url = "https://files.pythonhosted.org/packages/b9/83/b402dc4bd99b6f3eb0bce04e557889a164e099976a7fc71a6b07c923241b/ty-0.0.30-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:91ff236adbb90281c05f7e160664820be50f42d3a9d8f1d0a648f006864114fa", size = 11663362, upload-time = "2026-04-14T13:53:18.505Z" }, + { url = "https://files.pythonhosted.org/packages/65/1b/8157f03acc15421083c194b11a61a78d10e3dfa7e4a0177809fc9acc3881/ty-0.0.30-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6ec99bd5d5430c52fb64038483deb070f12c7ae78ffd6d6841d31719daedf1d7", size = 11304786, upload-time = "2026-04-14T13:53:30.076Z" }, + { url = "https://files.pythonhosted.org/packages/d9/c3/f89a9a42b47da108ed758ae9d065d10bf2acc2ea88e3d200b95511096b7b/ty-0.0.30-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a4b328ee332ec6276afc863ea7cf6d8167d9dd8d9f3d1c2e738ef39932511ac4", size = 11173426, upload-time = "2026-04-14T13:53:10.262Z" }, + { url = "https://files.pythonhosted.org/packages/81/37/fa38ee0259dc49579e1871b23ab1ff27331a78460566cdc13045a237595d/ty-0.0.30-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:fd0d664d6530890a8e872accd96895410773e7a4c6d20c244fb7a5f541ff359b", size = 10517157, upload-time = "2026-04-14T13:53:15.739Z" }, + { url = "https://files.pythonhosted.org/packages/2e/79/28032481141eb6ce3274f62b9ff9b1d73d59df6b28080c8fe3c6bdef700e/ty-0.0.30-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:314004166a7a5e39e169c7da0b9e78f3315382f53db8698fd98346cee3bb0784", size = 10613222, upload-time = "2026-04-14T13:53:13.269Z" }, + { url = "https://files.pythonhosted.org/packages/45/a0/989fca4c74095defd7d3ba5afc68a5aa4e2ca428fedfca5df526701c730b/ty-0.0.30-py3-none-musllinux_1_2_i686.whl", hash = "sha256:d969ebf9d8b08e93e638c56e6fb5a8dacd2a24f43e3519479d245ddde69f968e", size = 10789624, upload-time = "2026-04-14T13:53:42.156Z" }, + { url = "https://files.pythonhosted.org/packages/5c/74/3e74aba392ba2eeae5d86568ee282d9d6b2b6642445e3d9837c88d73c282/ty-0.0.30-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:66922c8c4381a016f90ec4b811748e7bb12da892f4c273640710da721caea7fb", size = 11260273, upload-time = "2026-04-14T13:53:44.974Z" }, + { url = "https://files.pythonhosted.org/packages/24/0e/e94a0e5e5a1850a2ba61c5efcfa594cfc2d23c026bf431cce33003d036a0/ty-0.0.30-py3-none-win32.whl", hash = "sha256:b7b2ecf80c872d7d9928b372e99233bdda7cabe639edd06b6232c3161a7dfa40", size = 10145096, upload-time = "2026-04-14T13:53:39.335Z" }, + { url = "https://files.pythonhosted.org/packages/50/d3/09c8df72ad37f7f4d9d79fe04a08bfa649d9f141d137e624fc23c7c3d7fe/ty-0.0.30-py3-none-win_amd64.whl", hash = "sha256:f29834e3d96c447f2adcf9eeb55b3f92005c91f52597c4c46d844188ec67ec72", size = 11156009, upload-time = "2026-04-14T13:53:32.847Z" }, + { url = "https://files.pythonhosted.org/packages/e6/17/a5c049c36e2fef9c593a1862f275af963b66045378f10b6908c6f10f6f4a/ty-0.0.30-py3-none-win_arm64.whl", hash = "sha256:d9be1d258dab615b447d20fa58633f0ae163af01bfa781a50457defec20642fd", size = 10552887, upload-time = "2026-04-14T13:53:27.455Z" }, ] [[package]] From 1156d22a7b8d0d01bdde66a67864b477ec8588ff Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C6=B0u=20Quang=20V=C5=A9?= Date: Wed, 15 Apr 2026 10:32:41 +0700 Subject: [PATCH 246/291] Explicitly use HTTP/2 and include SSRF protection --- uv.lock | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/uv.lock b/uv.lock index d632800..690f8b7 100644 --- a/uv.lock +++ b/uv.lock @@ -176,8 +176,8 @@ dev = [{ name = "gemini-fastapi", extras = ["dev"] }] [[package]] name = "gemini-webapi" -version = "0.0.post270" -source = { git = "https://github.com/luuquangvu/Gemini-API.git?rev=enable-guest-mode#4e2443cdd219a080b31e95e60f7600c0655a553e" } +version = "0.0.post271" +source = { git = "https://github.com/luuquangvu/Gemini-API.git?rev=enable-guest-mode#18879bc8e8734348faf1e3fa250070c247d2fdca" } dependencies = [ { name = "curl-cffi" }, { name = "loguru" }, From e9c7ca7e7b65da92dc434a51c0c26fcb2480b903 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C6=B0u=20Quang=20V=C5=A9?= Date: Wed, 15 Apr 2026 12:26:02 +0700 Subject: [PATCH 247/291] Include the `impersonate` parameter Optimize the codebase by applying Sourcery suggestions --- README.md | 23 ++++++++++ README.zh.md | 23 ++++++++++ app/server/chat.py | 71 ++++++++++++----------------- app/server/middleware.py | 8 ++-- app/services/client.py | 35 ++++++++------- app/services/lmdb.py | 96 ++++++++++++++++++---------------------- app/services/pool.py | 2 +- app/utils/config.py | 29 +++++++++--- app/utils/helper.py | 31 +++++-------- app/utils/logging.py | 2 +- config/config.yaml | 3 +- scripts/dump_lmdb.py | 6 ++- 12 files changed, 182 insertions(+), 147 deletions(-) diff --git a/README.md b/README.md index 9bc463e..1715c74 100644 --- a/README.md +++ b/README.md @@ -57,6 +57,7 @@ gemini: secure_1psid: "YOUR_SECURE_1PSID_HERE" secure_1psidts: "YOUR_SECURE_1PSIDTS_HERE" proxy: null # Optional proxy URL (null/empty keeps direct connection) + impersonate: null # Optional browser impersonation target (null uses default "chrome") ``` > [!NOTE] @@ -180,6 +181,9 @@ export CONFIG_GEMINI__CLIENTS__0__SECURE_1PSIDTS="your-secure-1psidts" # Override optional proxy settings for client 0 export CONFIG_GEMINI__CLIENTS__0__PROXY="socks5://127.0.0.1:1080" +# Override browser impersonation for client 0 +export CONFIG_GEMINI__CLIENTS__0__IMPERSONATE="chrome" + # Override conversation storage size limit export CONFIG_STORAGE__MAX_SIZE=268435456 # 256 MB @@ -217,6 +221,25 @@ To use Gemini-FastAPI, you need to extract your Gemini session cookies: Each client entry can be configured with a different proxy to work around rate limits. Omit the `proxy` field or set it to `null` or an empty string to keep a direct connection. +### Browser Impersonation + +Each client can optionally set an `impersonate` value to control the TLS/HTTP fingerprint used by `curl_cffi`. This is useful when Google blocks requests from a specific browser profile. + +- Set to `null` (default) to use the library's default (`"chrome"`, which maps to the latest Chrome version). +- Set to any value supported by [`curl_cffi`'s `BrowserTypeLiteral`](https://github.com/lexiforest/curl_cffi), for example: `chrome`, `safari`, `safari_ios`, `firefox`, etc. +- The value is validated at startup; an invalid value will prevent the server from starting. + +```yaml +gemini: + clients: + - id: "client-a" + impersonate: "chrome" # Use latest Chrome fingerprint (default) + - id: "client-b" + impersonate: "firefox" # Use Firefox fingerprint + - id: "client-c" + impersonate: null # Use library default +``` + ### Custom Models You can define custom models in `config/config.yaml` or via environment variables. diff --git a/README.zh.md b/README.zh.md index fb85a48..ea4a042 100644 --- a/README.zh.md +++ b/README.zh.md @@ -57,6 +57,7 @@ gemini: secure_1psid: "YOUR_SECURE_1PSID_HERE" secure_1psidts: "YOUR_SECURE_1PSIDTS_HERE" proxy: null # 可选代理 URL (null/空值则保持直连) + impersonate: null # 可选浏览器指纹模拟 (null 则使用默认值 "chrome") ``` > [!NOTE] @@ -180,6 +181,9 @@ export CONFIG_GEMINI__CLIENTS__0__SECURE_1PSIDTS="your-secure-1psidts" # 覆盖 Client 0 的代理设置 export CONFIG_GEMINI__CLIENTS__0__PROXY="socks5://127.0.0.1:1080" +# 覆盖 Client 0 的浏览器指纹模拟 +export CONFIG_GEMINI__CLIENTS__0__IMPERSONATE="chrome" + # 覆盖对话存储大小限制 export CONFIG_STORAGE__MAX_SIZE=268435456 # 256 MB @@ -213,6 +217,25 @@ export CONFIG_STORAGE__MAX_SIZE=268435456 # 256 MB 每个客户端条目可以配置不同的代理,从而规避速率限制。省略 `proxy` 字段或将其设置为 `null` 或空字符串以保持直连。 +### 浏览器指纹模拟 + +每个客户端可以通过 `impersonate` 参数设置 `curl_cffi` 使用的 TLS/HTTP 指纹。当 Google 屏蔽某种浏览器指纹时,切换为其他浏览器会有帮助。 + +- 设置为 `null`(默认)则使用库的默认值(`"chrome"`,即最新 Chrome 版本)。 +- 可设为 [`curl_cffi` 的 `BrowserTypeLiteral`](https://github.com/lexiforest/curl_cffi) 支持的任意值,例如:`chrome`、`safari`、`safari_ios`、`firefox` 等。 +- 启动时会校验该值;无效值会阻止服务启动。 + +```yaml +gemini: + clients: + - id: "client-a" + impersonate: "chrome" # 使用最新 Chrome 指纹(默认) + - id: "client-b" + impersonate: "firefox" # 使用 Firefox 指纹 + - id: "client-c" + impersonate: null # 使用库默认值 +``` + ### 自定义模型 你可以在 `config/config.yaml` 中或通过环境变量定义自定义模型。 diff --git a/app/server/chat.py b/app/server/chat.py index 8b3094d..b1215b7 100644 --- a/app/server/chat.py +++ b/app/server/chat.py @@ -169,9 +169,9 @@ async def _media_to_local_file( continue data = original_path.read_bytes() - suffix = original_path.suffix - if not suffix: - suffix = default_extensions.get(mtype) or (".mp4" if "video" in mtype else ".mp3") + suffix = original_path.suffix or ( + default_extensions.get(mtype) or (".mp4" if "video" in mtype else ".mp3") + ) random_name = f"media_{uuid.uuid4().hex}{suffix}" new_path = temp_dir / random_name @@ -557,8 +557,7 @@ def _build_tool_prompt( schema_text = orjson.dumps(function.parameters, option=orjson.OPT_SORT_KEYS).decode( "utf-8" ) - lines.append("Arguments JSON schema:") - lines.append(schema_text) + lines.extend(("Arguments JSON schema:", schema_text)) else: lines.append("Arguments JSON schema: {}") @@ -661,18 +660,15 @@ def _prepare_messages_for_model( instructions: list[str] = [] tool_prompt_injected = False - if inject_system_defaults: - if tools: - tool_prompt = _build_tool_prompt(tools, tool_choice) - if tool_prompt: - instructions.append(tool_prompt) - tool_prompt_injected = True - - 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 and tools and (tool_prompt := _build_tool_prompt(tools, tool_choice)): + instructions.append(tool_prompt) + tool_prompt_injected = True + + 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 instructions: if tools and tool_choice != "none" and not tool_prompt_injected: @@ -721,16 +717,13 @@ def _convert_responses_to_app_messages( reasoning_parts: list[str] = [] for part in content: if part.type in ("input_text", "output_text"): - text_value = getattr(part, "text", "") or "" - if text_value: + if text_value := getattr(part, "text", "") or "": converted.append(AppContentItem(type="text", text=text_value)) elif part.type == "reasoning_text": - text_value = getattr(part, "text", "") or "" - if text_value: + if text_value := getattr(part, "text", "") or "": reasoning_parts.append(text_value) elif part.type == "input_image": - image_url = getattr(part, "image_url", None) - if image_url: + if image_url := getattr(part, "image_url", None): converted.append(AppContentItem(type="image_url", url=image_url)) elif part.type == "input_file": file_url = getattr(part, "file_url", None) @@ -841,8 +834,8 @@ def _convert_responses_to_app_messages( merged_tools.extend(msg.tool_calls) last_msg.reasoning_content = "\n\n".join(reasoning_parts) if reasoning_parts else None - last_msg.content = merged_content if merged_content else None - last_msg.tool_calls = merged_tools if merged_tools else None + last_msg.content = merged_content or None + last_msg.tool_calls = merged_tools or None else: compacted_messages.append(msg) @@ -878,12 +871,10 @@ def _convert_instructions_to_app_messages( converted: list[AppContentItem] = [] for part in content: if part.type in ("input_text", "output_text"): - text_value = getattr(part, "text", "") or "" - if text_value: + if text_value := getattr(part, "text", "") or "": converted.append(AppContentItem(type="text", text=text_value)) elif part.type == "input_image": - image_url = getattr(part, "image_url", None) - if image_url: + if image_url := getattr(part, "image_url", None): converted.append(AppContentItem(type="image_url", url=image_url)) elif part.type == "input_file": file_url = getattr(part, "file_url", None) @@ -939,10 +930,9 @@ async def _get_available_models(pool: GeminiClientPool) -> list[ModelData]: if not client.running(): continue - client_models = client.list_models() - if client_models: + if client_models := client.list_models(): for model in client_models: - model_id = model.model_name if model.model_name else model.model_id + model_id = model.model_name or model.model_id if model_id and model_id not in seen_model_ids: models_data.append( ModelData( @@ -1057,14 +1047,13 @@ def process(self, chunk: str) -> str: while self.buffer: if self.state == "IN_TAG_HEADER": nl_idx = self.buffer.find("\n") - if nl_idx != -1: - self.current_role = self.buffer[:nl_idx].strip().lower() - self.buffer = self.buffer[nl_idx + 1 :] - self.stack[-1] = "IN_BLOCK" - continue - else: + if nl_idx == -1: break + self.current_role = self.buffer[:nl_idx].strip().lower() + self.buffer = self.buffer[nl_idx + 1 :] + self.stack[-1] = "IN_BLOCK" + continue if self.state == "POST_BLOCK": stripped = self.buffer.lstrip() if not stripped: @@ -1074,8 +1063,7 @@ def process(self, chunk: str) -> str: match = STREAM_MASTER_RE.search(self.buffer) if not match: - tail_match = STREAM_TAIL_RE.search(self.buffer) - if tail_match: + if tail_match := STREAM_TAIL_RE.search(self.buffer): yield_len = len(self.buffer) - len(tail_match.group(0)) if yield_len > 0: if self._is_outputting(): @@ -1124,8 +1112,7 @@ def flush(self) -> str: res = "" if self._is_outputting(): res = self.buffer - tail_match = STREAM_TAIL_RE.search(res) - if tail_match: + if tail_match := STREAM_TAIL_RE.search(res): res = res[: -len(tail_match.group(0))] self.buffer = "" diff --git a/app/server/middleware.py b/app/server/middleware.py index a0593b4..4b1341f 100644 --- a/app/server/middleware.py +++ b/app/server/middleware.py @@ -35,12 +35,10 @@ def get_media_token(filename: str) -> str: def verify_media_token(filename: str, token: str | None) -> bool: """Verify the provided token against the filename.""" - expected = get_media_token(filename) - if not expected: + if expected := get_media_token(filename): + return hmac.compare_digest(token, expected) if token else False + else: return True # No auth required - if not token: - return False - return hmac.compare_digest(token, expected) def cleanup_expired_media(retention_days: int) -> int: diff --git a/app/services/client.py b/app/services/client.py index a83e1e6..5e23dca 100644 --- a/app/services/client.py +++ b/app/services/client.py @@ -19,6 +19,7 @@ class GeminiClientWrapper(GeminiClient): """Gemini client with helper methods.""" def __init__(self, client_id: str, **kwargs): + self._cfg_impersonate: str | None = kwargs.pop("impersonate", None) super().__init__(**kwargs) self.id = client_id @@ -27,14 +28,17 @@ async def init(self, *args: Any, **kwargs: Any) -> None: Inject default configuration values from global settings. """ config = g_config.gemini + init_kwargs: dict[str, Any] = { + "timeout": config.timeout, + "watchdog_timeout": config.watchdog_timeout, + "auto_refresh": config.auto_refresh, + "refresh_interval": config.refresh_interval, + "verbose": config.verbose, + } + if self._cfg_impersonate is not None: + init_kwargs["impersonate"] = self._cfg_impersonate try: - await super().init( - timeout=config.timeout, - watchdog_timeout=config.watchdog_timeout, - auto_refresh=config.auto_refresh, - refresh_interval=config.refresh_interval, - verbose=config.verbose, - ) + await super().init(**init_kwargs) except Exception: logger.exception(f"Failed to initialize GeminiClient {self.id}") raise @@ -66,20 +70,17 @@ async def process_message( if item_text or message.role == "tool": text_fragments.append(item_text) elif item.type == "image_url": - item_media_url = getattr(item, "url", None) - if not item_media_url: + if item_media_url := getattr(item, "url", None): + files.append(await save_url_to_tempfile(item_media_url, tempdir)) + else: raise ValueError(f"{item.type} cannot be empty") - files.append(await save_url_to_tempfile(item_media_url, tempdir)) elif item.type == "file": - file_data = getattr(item, "file_data", None) - if file_data: - filename = getattr(item, "filename", "") or "" - files.append(await save_file_to_tempfile(file_data, filename, tempdir)) - else: + if not (file_data := getattr(item, "file_data", None)): raise ValueError("File must contain 'file_data'") + filename = getattr(item, "filename", "") or "" + files.append(await save_file_to_tempfile(file_data, filename, tempdir)) elif item.type == "input_audio": - file_data = getattr(item, "file_data", None) - if file_data: + if file_data := getattr(item, "file_data", None): files.append(await save_file_to_tempfile(file_data, "audio.wav", tempdir)) else: raise ValueError("input_audio must contain 'file_data' key") diff --git a/app/services/lmdb.py b/app/services/lmdb.py index 3284b8d..1b8f8fc 100644 --- a/app/services/lmdb.py +++ b/app/services/lmdb.py @@ -1,7 +1,7 @@ import hashlib import string from collections.abc import Generator -from contextlib import contextmanager +from contextlib import contextmanager, suppress from datetime import datetime, timedelta from pathlib import Path from typing import Any @@ -31,9 +31,7 @@ def _fuzzy_normalize(text: str | None) -> str | None: Lowercase and remove all whitespace and punctuation. Used as a fallback for complex/malformed contents matching. """ - if text is None: - return None - return text.lower().translate(_VOLATILE_TRANS_TABLE) + return None if text is None else text.lower().translate(_VOLATILE_TRANS_TABLE) def _normalize_text(text: str | None, fuzzy: bool = False) -> str | None: @@ -45,11 +43,7 @@ def _normalize_text(text: str | None, fuzzy: bool = False) -> str | None: text = unescape_text(text) text = remove_tool_call_blocks(text) - if fuzzy: - return _fuzzy_normalize(text) - - # Always strip to ensure trailing newlines/spaces don't break exact matches - return text.strip() if text.strip() else None + return _fuzzy_normalize(text) if fuzzy else text.strip() or None def _hash_message(message: AppMessage, fuzzy: bool = False) -> str: @@ -71,8 +65,7 @@ def _hash_message(message: AppMessage, fuzzy: bool = False) -> str: text_parts = [] for item in content: if item.type == "text" and item.text: - normalized_part = _normalize_text(item.text, fuzzy=fuzzy) - if normalized_part: + if normalized_part := _normalize_text(item.text, fuzzy=fuzzy): text_parts.append(normalized_part) elif item.type != "text" and item.url: text_parts.append(f"[{item.type}:{item.url}]") @@ -206,12 +199,10 @@ def _decode_index_value(data: bytes | memoryview) -> list[str]: return [] data = bytes(data) if data.startswith(b"["): - try: + with suppress(orjson.JSONDecodeError): val = orjson.loads(data) if isinstance(val, list): return [str(v) for v in val] - except orjson.JSONDecodeError: - pass try: return [data.decode("utf-8")] except UnicodeDecodeError: @@ -309,15 +300,7 @@ def get(self, key: str) -> ConversationInStore | None: """ try: with self._get_transaction(write=False) as txn: - data = txn.get(key.encode("utf-8"), default=None) - if not data: - return None - - storage_data = orjson.loads(data) - conv = ConversationInStore.model_validate(storage_data) - - logger.debug(f"Retrieved {len(conv.messages)} messages with key: {key[:12]}") - return conv + return self._get_messages_from_database(txn, key) except (Error, orjson.JSONDecodeError) as e: logger.error(f"Failed to retrieve/parse messages with key {key[:12]}: {e}") return None @@ -325,6 +308,18 @@ def get(self, key: str) -> ConversationInStore | None: logger.error(f"Unexpected error retrieving messages with key {key[:12]}: {e}") return None + @staticmethod + def _get_messages_from_database(txn, key): + data = txn.get(key.encode("utf-8"), default=None) + if not data: + return None + + storage_data = orjson.loads(data) + conv = ConversationInStore.model_validate(storage_data) + + logger.debug(f"Retrieved {len(conv.messages)} messages with key: {key[:12]}") + return conv + def find(self, model: str, messages: list[AppMessage]) -> ConversationInStore | None: """ Search conversation data by message list. @@ -387,15 +382,10 @@ def _find_by_message_list( if len(conv.messages) != target_len: continue - match_found = True - for i in range(target_len): - if ( - _hash_message(conv.messages[i], fuzzy=fuzzy) - != target_hashes[i] - ): - match_found = False - break - + match_found = all( + _hash_message(conv.messages[i], fuzzy=fuzzy) == target_hashes[i] + for i in range(target_len) + ) if match_found: return conv except Error as e: @@ -421,28 +411,29 @@ def delete(self, key: str) -> ConversationInStore | None: """Delete conversation model by key.""" try: with self._get_transaction(write=True) as txn: - data = txn.get(key.encode("utf-8")) - if not data: - return None - - storage_data = orjson.loads(data) - conv = ConversationInStore.model_validate(storage_data) - message_hash = _hash_conversation(conv.client_id, conv.model, conv.messages) - fuzzy_hash = _hash_conversation( - conv.client_id, conv.model, conv.messages, fuzzy=True - ) - - txn.delete(key.encode("utf-8")) - - self._remove_from_index(txn, self.HASH_LOOKUP_PREFIX, message_hash, key) - self._remove_from_index(txn, self.FUZZY_LOOKUP_PREFIX, fuzzy_hash, key) - - logger.debug(f"Deleted messages with key: {key[:12]}") - return conv + return self._delete_messages_from_database(txn, key) except (Error, orjson.JSONDecodeError) as e: logger.error(f"Failed to delete messages with key {key[:12]}: {e}") return None + def _delete_messages_from_database(self, txn, key): + data = txn.get(key.encode("utf-8")) + if not data: + return None + + storage_data = orjson.loads(data) + conv = ConversationInStore.model_validate(storage_data) + message_hash = _hash_conversation(conv.client_id, conv.model, conv.messages) + fuzzy_hash = _hash_conversation(conv.client_id, conv.model, conv.messages, fuzzy=True) + + txn.delete(key.encode("utf-8")) + + self._remove_from_index(txn, self.HASH_LOOKUP_PREFIX, message_hash, key) + self._remove_from_index(txn, self.FUZZY_LOOKUP_PREFIX, fuzzy_hash, key) + + logger.debug(f"Deleted messages with key: {key[:12]}") + return conv + def keys(self, prefix: str = "", limit: int | None = None) -> list[str]: """List all keys in the store, optionally filtered by prefix.""" keys = [] @@ -519,8 +510,9 @@ def cleanup_expired(self, retention_days: int | None = None) -> int: if not txn.delete(key_bytes): continue - message_hash = _hash_conversation(conv.client_id, conv.model, conv.messages) - if message_hash: + if message_hash := _hash_conversation( + conv.client_id, conv.model, conv.messages + ): self._remove_from_index(txn, self.HASH_LOOKUP_PREFIX, message_hash, key_str) fuzzy_hash = _hash_conversation( conv.client_id, conv.model, conv.messages, fuzzy=True diff --git a/app/services/pool.py b/app/services/pool.py index 847e79a..7d7143c 100644 --- a/app/services/pool.py +++ b/app/services/pool.py @@ -46,7 +46,7 @@ async def init(self) -> None: logger.info(f"Staggering next initialization by {delay:.2f}s") await asyncio.sleep(delay) - success_count = sum(1 for client in self._clients if client.running()) + success_count = sum(bool(client.running()) for client in self._clients) if success_count == 0: raise RuntimeError("Failed to initialize any Gemini clients") diff --git a/app/utils/config.py b/app/utils/config.py index f84385a..3a3f658 100644 --- a/app/utils/config.py +++ b/app/utils/config.py @@ -1,9 +1,10 @@ import ast import os import sys -from typing import Any, Literal, cast +from typing import Any, Literal, cast, get_args import orjson +from curl_cffi import BrowserTypeLiteral from loguru import logger from pydantic import BaseModel, Field, ValidationError, field_validator from pydantic_settings import ( @@ -42,15 +43,33 @@ class GeminiClientSettings(BaseModel): secure_1psid: str | None = Field(default=None, description="Gemini Secure 1PSID") secure_1psidts: str | None = Field(default=None, description="Gemini Secure 1PSIDTS") proxy: str | None = Field(default=None, description="Proxy URL for this Gemini client") + impersonate: str | None = Field( + default=None, + description="Browser impersonation target for curl_cffi (e.g. 'chrome'). None uses library default", + ) - @field_validator("proxy", mode="before") + @field_validator("proxy", "impersonate", mode="before") @classmethod - def _blank_proxy_to_none(cls, value: str | None) -> str | None: + def _blank_string_to_none(cls, value: str | None) -> str | None: + """Normalize empty or whitespace-only strings to None.""" if value is None: return None stripped = value.strip() return stripped or None + @field_validator("impersonate") + @classmethod + def _validate_impersonate(cls, value: str | None) -> str | None: + """Validate that impersonate is a supported curl_cffi BrowserTypeLiteral value.""" + if value is None: + return None + allowed = get_args(BrowserTypeLiteral) + if value not in allowed: + raise ValueError( + f"impersonate={value!r} is not supported. Allowed values: {', '.join(allowed)}" + ) + return value + class GeminiModelConfig(BaseModel): """Configuration for a custom Gemini model.""" @@ -86,7 +105,7 @@ class GeminiConfig(BaseModel): description="Strategy for loading models: 'append' merges custom with default, 'overwrite' uses only custom", ) timeout: int = Field(default=450, ge=30, description="Init timeout in seconds") - watchdog_timeout: int = Field(default=90, ge=30, description="Watchdog timeout in seconds") + watchdog_timeout: int = Field(default=120, ge=30, description="Watchdog timeout in seconds") auto_refresh: bool = Field(True, description="Enable auto-refresh for Gemini sessions") refresh_interval: int = Field( default=600, @@ -280,7 +299,7 @@ def _merge_clients_with_env( f"Client index {idx} in env is out of range (current count: {len(result_clients)}). " "Client indices must be contiguous starting from 0." ) - return result_clients if result_clients else base_clients + return result_clients or base_clients def extract_gemini_models_env() -> dict[int, dict[str, Any]]: diff --git a/app/utils/helper.py b/app/utils/helper.py index a6d351b..44d9c22 100644 --- a/app/utils/helper.py +++ b/app/utils/helper.py @@ -101,9 +101,7 @@ _START_PATTERNS["HINT"] = rf"\n?{TOOL_HINT_START_ESC}:?\s*" _master_parts = [f"(?P<{name}_START>{pattern})" for name, pattern in _START_PATTERNS.items()] -_master_parts.append(f"(?P{_PROTOCOL_ENDS})") -_master_parts.append(f"(?P{_TAG_END})") - +_master_parts.extend((f"(?P{_PROTOCOL_ENDS})", f"(?P{_TAG_END})")) if TOOL_HINT_START_ESC and TOOL_HINT_END_ESC: _master_parts.append(f"(?P{TOOL_HINT_END_ESC}\n?)") @@ -120,7 +118,7 @@ def add_tag(role: str, content: str, unclose: bool = False) -> str: logger.warning(f"Unknown role: {role}, returning content without tags") return content - return f"<|im_start|>{role}\n{content}" + ("\n<|im_end|>" if not unclose else "") + return f"<|im_start|>{role}\n{content}" + ("" if unclose else "\n<|im_end|>") def normalize_llm_text(s: str) -> str: @@ -140,9 +138,7 @@ def normalize_llm_text(s: str) -> str: def unescape_text(s: str) -> str: """Remove CommonMark backslash escapes from LLM-generated text.""" - if not s: - return "" - return COMMONMARK_UNESCAPE_RE.sub(r"\1", s) + return COMMONMARK_UNESCAPE_RE.sub(r"\1", s) if s else "" def _strip_param_fences(s: str) -> str: @@ -168,9 +164,7 @@ def _strip_param_fences(s: str) -> str: def estimate_tokens(text: str | None) -> int: """Estimate the number of tokens heuristically based on character count.""" - if not text: - return 0 - return int(len(text) / 3) + return len(text) // 3 if text else 0 async def save_file_to_tempfile( @@ -193,11 +187,9 @@ async def save_url_to_tempfile(url: str, tempdir: Path | None = None) -> Path: metadata_part = url.split(",")[0] mime_type = metadata_part.split(":")[1].split(";")[0] data = base64.b64decode(url.split(",")[1]) - suffix = mimetypes.guess_extension(mime_type) - if not suffix and "/" in mime_type: - suffix = f".{mime_type.split('/')[1]}" - elif not suffix: - suffix = ".bin" + suffix = mimetypes.guess_extension(mime_type) or ( + f".{mime_type.split('/')[1]}" if "/" in mime_type else ".bin" + ) else: async with requests.AsyncSession( impersonate="chrome", allow_redirects=CurlFollow.SAFE, http_version=CurlHttpVersion.V2_0 @@ -205,8 +197,7 @@ async def save_url_to_tempfile(url: str, tempdir: Path | None = None) -> Path: resp = await client.get(url) resp.raise_for_status() data = resp.content - content_type = resp.headers.get("content-type") - if content_type: + if content_type := resp.headers.get("content-type"): suffix = mimetypes.guess_extension(content_type.split(";")[0].strip()) if not suffix: suffix = Path(urlparse(url).path).suffix or ".bin" @@ -374,7 +365,7 @@ def extract_image_dimensions(data: bytes) -> tuple[int | None, int | None]: except struct.error: return None, None - if len(data) >= 4 and data[0:2] == b"\xff\xd8": + if len(data) >= 4 and data[:2] == b"\xff\xd8": idx = 2 length = len(data) sof_markers = {0xC0, 0xC1, 0xC2, 0xC3, 0xC5, 0xC6, 0xC7, 0xC9, 0xCA, 0xCB, 0xCD, 0xCE, 0xCF} @@ -414,6 +405,4 @@ def detect_image_extension(data: bytes) -> str | None: return ".jpg" if data.startswith(b"GIF8"): return ".gif" - if data.startswith(b"RIFF") and data[8:12] == b"WEBP": - return ".webp" - return None + return ".webp" if data.startswith(b"RIFF") and data[8:12] == b"WEBP" else None diff --git a/app/utils/logging.py b/app/utils/logging.py index 1a5f9fe..79b2727 100644 --- a/app/utils/logging.py +++ b/app/utils/logging.py @@ -57,7 +57,7 @@ def emit(self, record: logging.LogRecord) -> None: filename = frame.f_code.co_filename is_logging = filename == logging.__file__ is_frozen = "importlib" in filename and "_bootstrap" in filename - if depth > 0 and not (is_logging or is_frozen): + if depth > 0 and not is_logging and not is_frozen: break frame = frame.f_back depth += 1 diff --git a/config/config.yaml b/config/config.yaml index fbce483..b9c85b1 100644 --- a/config/config.yaml +++ b/config/config.yaml @@ -22,8 +22,9 @@ gemini: secure_1psid: "YOUR_SECURE_1PSID_HERE" # Gemini Secure 1PSID secure_1psidts: "YOUR_SECURE_1PSIDTS_HERE" # Gemini Secure 1PSIDTS proxy: null # Optional proxy URL (null/empty means direct connection) + impersonate: null # Optional browser impersonation target (null uses library default "chrome") timeout: 450 # Init timeout in seconds (Not less than 30s) - watchdog_timeout: 90 # Watchdog timeout in seconds (Not less than 30s) + watchdog_timeout: 120 # Watchdog timeout in seconds (Not less than 30s) auto_refresh: true # Auto-refresh session cookies refresh_interval: 600 # Refresh interval in seconds (Not less than 60s) verbose: true # Enable verbose logging for Gemini requests diff --git a/scripts/dump_lmdb.py b/scripts/dump_lmdb.py index 3ef4805..b9400a4 100644 --- a/scripts/dump_lmdb.py +++ b/scripts/dump_lmdb.py @@ -20,8 +20,10 @@ def _decode_value(value: bytes | memoryview) -> Any: def _dump_all(txn: Transaction) -> list[dict[str, Any]]: """Return all records from the database.""" result: list[dict[str, Any]] = [] - for key, value in txn.cursor(): - result.append({"key": bytes(key).decode("utf-8"), "value": _decode_value(value)}) + result.extend( + {"key": bytes(key).decode("utf-8"), "value": _decode_value(value)} + for key, value in txn.cursor() + ) return result From d6aef65446f7a75423cff756c7435e37a26cc6b6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C6=B0u=20Quang=20V=C5=A9?= Date: Thu, 16 Apr 2026 09:27:07 +0700 Subject: [PATCH 248/291] Periodically check for dead clients in the pool and revive them based on exponential backoff --- app/main.py | 50 +++++++++++++++++++++++++++++++++--------- app/services/client.py | 31 ++++++++++++++++++++++++++ app/services/pool.py | 23 +++++++++++++++++++ app/utils/config.py | 5 +++++ config/config.yaml | 1 + 5 files changed, 100 insertions(+), 10 deletions(-) diff --git a/app/main.py b/app/main.py index 47e3956..5149b08 100644 --- a/app/main.py +++ b/app/main.py @@ -13,6 +13,7 @@ cleanup_expired_media, ) from .services import GeminiClientPool, LMDBConversationStore +from .utils import g_config RETENTION_CLEANUP_INTERVAL_SECONDS = 6 * 60 * 60 # Check every 6 hours @@ -48,6 +49,31 @@ async def _run_retention_cleanup(stop_event: asyncio.Event) -> None: logger.info("LMDB retention cleanup task stopped.") +async def _run_pool_watchdog(stop_event: asyncio.Event) -> None: + """ + Periodically check for dead clients in the pool and revive them. + """ + pool = GeminiClientPool() + interval = g_config.gemini.pool_watchdog_interval + logger.info(f"Starting Gemini pool watchdog task (interval={interval} seconds).") + + while not stop_event.is_set(): + try: + await pool.revive_dead_clients() + except Exception: + logger.exception("Gemini pool watchdog task encountered an error.") + + try: + await asyncio.wait_for( + stop_event.wait(), + timeout=interval, + ) + except TimeoutError: + continue + + logger.info("Gemini pool watchdog task stopped.") + + @asynccontextmanager async def lifespan(app: FastAPI): cleanup_stop_event = asyncio.Event() @@ -60,14 +86,18 @@ async def lifespan(app: FastAPI): raise cleanup_task = asyncio.create_task(_run_retention_cleanup(cleanup_stop_event)) - # Give the cleanup task a chance to start and surface immediate failures. + watchdog_task = asyncio.create_task(_run_pool_watchdog(cleanup_stop_event)) + + # Give the tasks a chance to start and surface immediate failures. await asyncio.sleep(0) - if cleanup_task.done(): - try: - cleanup_task.result() - except Exception: - logger.exception("LMDB retention cleanup task failed to start.") - raise + + for task, name in [(cleanup_task, "LMDB retention cleanup"), (watchdog_task, "Pool watchdog")]: + if task.done(): + try: + task.result() + except Exception: + logger.exception(f"{name} task failed to start.") + raise logger.info(f"Gemini clients initialized: {[c.id for c in pool.clients]}.") logger.info("Gemini API Server ready to serve requests.") @@ -82,12 +112,12 @@ async def lifespan(app: FastAPI): logger.exception("Failed to close Gemini client pool gracefully.") try: - await cleanup_task + await asyncio.gather(cleanup_task, watchdog_task) except asyncio.CancelledError: - logger.debug("LMDB retention cleanup task cancelled during shutdown.") + logger.debug("Background tasks cancelled during shutdown.") except Exception: logger.exception( - "LMDB retention cleanup task terminated with an unexpected error during shutdown." + "One or more background tasks terminated with an unexpected error during shutdown." ) diff --git a/app/services/client.py b/app/services/client.py index 5e23dca..c08643a 100644 --- a/app/services/client.py +++ b/app/services/client.py @@ -1,4 +1,5 @@ import io +import time from pathlib import Path from typing import Any @@ -22,6 +23,8 @@ def __init__(self, client_id: str, **kwargs): self._cfg_impersonate: str | None = kwargs.pop("impersonate", None) super().__init__(**kwargs) self.id = client_id + self._failure_count: int = 0 + self._last_failure_time: float = 0.0 async def init(self, *args: Any, **kwargs: Any) -> None: """ @@ -46,6 +49,34 @@ async def init(self, *args: Any, **kwargs: Any) -> None: def running(self) -> bool: return self._running + def record_success(self) -> None: + """Reset failure tracking on successful connection.""" + self._failure_count = 0 + self._last_failure_time = 0.0 + + def record_failure(self) -> None: + """Increment failure count and update timestamp on connection error.""" + self._failure_count += 1 + self._last_failure_time = time.time() + + def can_revive(self, now: float, base_delay: int, max_delay: int) -> bool: + """ + Check if the client is eligible for revival based on exponential backoff. + Delay = min(max_delay, base_delay * (2 ** (failure_count - 1))) + """ + if self._failure_count == 0: + return True + + delay = min(max_delay, base_delay * (2 ** (self._failure_count - 1))) + eligible = now >= (self._last_failure_time + delay) + if not eligible: + remaining = int((self._last_failure_time + delay) - now) + logger.debug( + f"Client {self.id} backoff active: {self._failure_count} failures, " + f"retrying in {remaining}s" + ) + return eligible + @staticmethod async def process_message( message: AppMessage, diff --git a/app/services/pool.py b/app/services/pool.py index 7d7143c..3cefc15 100644 --- a/app/services/pool.py +++ b/app/services/pool.py @@ -1,5 +1,6 @@ import asyncio import random +import time from collections import deque from loguru import logger @@ -38,7 +39,9 @@ async def init(self) -> None: for i, client in enumerate(clients_to_init): try: await client.init() + client.record_success() except Exception: + client.record_failure() logger.error(f"Failed to initialize client {client.id}") if i < len(clients_to_init) - 1: @@ -88,9 +91,11 @@ async def _ensure_client_ready(self, client: GeminiClientWrapper) -> bool: try: await client.init() + client.record_success() logger.info(f"Restarted Gemini client {client.id} after it stopped.") return True except Exception: + client.record_failure() logger.exception(f"Failed to restart Gemini client {client.id}") return False @@ -99,6 +104,24 @@ def clients(self) -> list[GeminiClientWrapper]: """Return managed clients.""" return self._clients + async def revive_dead_clients(self) -> None: + """Check all clients and attempt to restart any that are not running with staggering and backoff.""" + now = time.time() + base_delay = g_config.gemini.pool_watchdog_interval + max_delay = 3600 # 1 hour + + dead_clients = [ + c for c in self._clients if not c.running() and c.can_revive(now, base_delay, max_delay) + ] + for i, client in enumerate(dead_clients): + logger.info(f"Watchdog detected dead client {client.id}; attempting revival.") + await self._ensure_client_ready(client) + + if i < len(dead_clients) - 1: + delay = random.uniform(5, 30) + logger.info(f"Staggering next revival by {delay:.2f}s") + await asyncio.sleep(delay) + async def close(self) -> None: """Close all clients in the pool.""" if not self._clients: diff --git a/app/utils/config.py b/app/utils/config.py index 3a3f658..46a8496 100644 --- a/app/utils/config.py +++ b/app/utils/config.py @@ -113,6 +113,11 @@ class GeminiConfig(BaseModel): description="Interval in seconds to refresh Gemini sessions (Not less than 60s)", ) verbose: bool = Field(True, description="Enable verbose logging for Gemini API requests") + pool_watchdog_interval: int = Field( + default=60, + ge=10, + description="Interval in seconds for the client pool watchdog to check and revive dead clients", + ) max_chars_per_request: int = Field( default=1_000_000, ge=1, diff --git a/config/config.yaml b/config/config.yaml index b9c85b1..e41c37c 100644 --- a/config/config.yaml +++ b/config/config.yaml @@ -28,6 +28,7 @@ gemini: auto_refresh: true # Auto-refresh session cookies refresh_interval: 600 # Refresh interval in seconds (Not less than 60s) verbose: true # Enable verbose logging for Gemini requests + pool_watchdog_interval: 60 # Interval in seconds to check and revive dead clients (Not less than 10s) max_chars_per_request: 1000000 # Maximum characters Gemini Web accepts per request. Non-pro users might have a lower limit model_strategy: "append" # Strategy: 'append' (default + custom) or 'overwrite' (custom only) models: [] From b8cd546112fa5fcb04977efe9c7a13cfba35ef38 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C6=B0u=20Quang=20V=C5=A9?= Date: Sat, 25 Apr 2026 22:27:07 +0700 Subject: [PATCH 249/291] Explicitly use HTTP/3 with fallback and update libraries --- app/utils/helper.py | 2 +- pyproject.toml | 6 +-- uv.lock | 124 ++++++++++++++++++++++---------------------- 3 files changed, 66 insertions(+), 66 deletions(-) diff --git a/app/utils/helper.py b/app/utils/helper.py index 44d9c22..ea4d18f 100644 --- a/app/utils/helper.py +++ b/app/utils/helper.py @@ -192,7 +192,7 @@ async def save_url_to_tempfile(url: str, tempdir: Path | None = None) -> Path: ) else: async with requests.AsyncSession( - impersonate="chrome", allow_redirects=CurlFollow.SAFE, http_version=CurlHttpVersion.V2_0 + impersonate="chrome", allow_redirects=CurlFollow.SAFE, http_version=CurlHttpVersion.V3 ) as client: resp = await client.get(url) resp.raise_for_status() diff --git a/pyproject.toml b/pyproject.toml index f27761e..d64b9ba 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,14 +6,14 @@ readme = "README.md" requires-python = "==3.13.*" dependencies = [ "curl-cffi>=0.15.0", - "fastapi>=0.135.3", + "fastapi>=0.136.1", "gemini-webapi>=2.0.0", "httptools>=0.7.1", "lmdb>=2.2.0", "loguru>=0.7.3", "orjson>=3.11.8", - "pydantic-settings[yaml]>=2.13.1", - "uvicorn>=0.44.0", + "pydantic-settings[yaml]>=2.14.0", + "uvicorn>=0.46.0", "uvloop>=0.22.1; sys_platform != 'win32'", ] diff --git a/uv.lock b/uv.lock index 690f8b7..88ad776 100644 --- a/uv.lock +++ b/uv.lock @@ -34,11 +34,11 @@ wheels = [ [[package]] name = "certifi" -version = "2026.2.25" +version = "2026.4.22" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/af/2d/7bf41579a8986e348fa033a31cdd0e4121114f6bce2457e8876010b092dd/certifi-2026.2.25.tar.gz", hash = "sha256:e887ab5cee78ea814d3472169153c2d12cd43b14bd03329a39a9c6e2e80bfba7", size = 155029, upload-time = "2026-02-25T02:54:17.342Z" } +sdist = { url = "https://files.pythonhosted.org/packages/25/ee/6caf7a40c36a1220410afe15a1cc64993a1f864871f698c0f93acb72842a/certifi-2026.4.22.tar.gz", hash = "sha256:8d455352a37b71bf76a79caa83a3d6c25afee4a385d632127b6afb3963f1c580", size = 137077, upload-time = "2026-04-22T11:26:11.191Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/9a/3c/c17fb3ca2d9c3acff52e30b309f538586f9f5b9c9cf454f3845fc9af4881/certifi-2026.2.25-py3-none-any.whl", hash = "sha256:027692e4402ad994f1c42e52a4997a9763c646b73e4096e4d5d6db8af1d6f0fa", size = 153684, upload-time = "2026-02-25T02:54:15.766Z" }, + { url = "https://files.pythonhosted.org/packages/22/30/7cd8fdcdfbc5b869528b079bfb76dcdf6056b1a2097a662e5e8c04f42965/certifi-2026.4.22-py3-none-any.whl", hash = "sha256:3cb2210c8f88ba2318d29b0388d1023c8492ff72ecdde4ebdaddbb13a31b1c4a", size = 135707, upload-time = "2026-04-22T11:26:09.372Z" }, ] [[package]] @@ -66,14 +66,14 @@ wheels = [ [[package]] name = "click" -version = "8.3.2" +version = "8.3.3" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "colorama", marker = "sys_platform == 'win32'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/57/75/31212c6bf2503fdf920d87fee5d7a86a2e3bcf444984126f13d8e4016804/click-8.3.2.tar.gz", hash = "sha256:14162b8b3b3550a7d479eafa77dfd3c38d9dc8951f6f69c78913a8f9a7540fd5", size = 302856, upload-time = "2026-04-03T19:14:45.118Z" } +sdist = { url = "https://files.pythonhosted.org/packages/bb/63/f9e1ea081ce35720d8b92acde70daaedace594dc93b693c869e0d5910718/click-8.3.3.tar.gz", hash = "sha256:398329ad4837b2ff7cbe1dd166a4c0f8900c3ca3a218de04466f38f6497f18a2", size = 328061, upload-time = "2026-04-22T15:11:27.506Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e4/20/71885d8b97d4f3dde17b1fdb92dbd4908b00541c5a3379787137285f602e/click-8.3.2-py3-none-any.whl", hash = "sha256:1924d2c27c5653561cd2cae4548d1406039cb79b858b747cfea24924bbc1616d", size = 108379, upload-time = "2026-04-03T19:14:43.505Z" }, + { url = "https://files.pythonhosted.org/packages/ae/44/c1221527f6a71a01ec6fbad7fa78f1d50dfa02217385cf0fa3eec7087d59/click-8.3.3-py3-none-any.whl", hash = "sha256:a2bf429bb3033c89fa4936ffb35d5cb471e3719e1f3c8a7c3fff0b8314305613", size = 110502, upload-time = "2026-04-22T15:11:25.044Z" }, ] [[package]] @@ -112,7 +112,7 @@ wheels = [ [[package]] name = "fastapi" -version = "0.135.3" +version = "0.136.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "annotated-doc" }, @@ -121,9 +121,9 @@ dependencies = [ { name = "typing-extensions" }, { name = "typing-inspection" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/f7/e6/7adb4c5fa231e82c35b8f5741a9f2d055f520c29af5546fd70d3e8e1cd2e/fastapi-0.135.3.tar.gz", hash = "sha256:bd6d7caf1a2bdd8d676843cdcd2287729572a1ef524fc4d65c17ae002a1be654", size = 396524, upload-time = "2026-04-01T16:23:58.188Z" } +sdist = { url = "https://files.pythonhosted.org/packages/5d/45/c130091c2dfa061bbfe3150f2a5091ef1adf149f2a8d2ae769ecaf6e99a2/fastapi-0.136.1.tar.gz", hash = "sha256:7af665ad7acfa0a3baf8983d393b6b471b9da10ede59c60045f49fbc89a0fa7f", size = 397448, upload-time = "2026-04-23T16:49:44.046Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/84/a4/5caa2de7f917a04ada20018eccf60d6cc6145b0199d55ca3711b0fc08312/fastapi-0.135.3-py3-none-any.whl", hash = "sha256:9b0f590c813acd13d0ab43dd8494138eb58e484bfac405db1f3187cfc5810d98", size = 117734, upload-time = "2026-04-01T16:23:59.328Z" }, + { url = "https://files.pythonhosted.org/packages/5a/ff/2e4eca3ade2c22fe1dea7043b8ee9dabe47753349eb1b56a202de8af6349/fastapi-0.136.1-py3-none-any.whl", hash = "sha256:a6e9d7eeada96c93a4d69cb03836b44fa34e2854accb7244a1ece36cd4781c3f", size = 117683, upload-time = "2026-04-23T16:49:42.437Z" }, ] [[package]] @@ -157,16 +157,16 @@ dev = [ [package.metadata] requires-dist = [ { name = "curl-cffi", specifier = ">=0.15.0" }, - { name = "fastapi", specifier = ">=0.135.3" }, + { name = "fastapi", specifier = ">=0.136.1" }, { name = "gemini-webapi", git = "https://github.com/luuquangvu/Gemini-API.git?rev=enable-guest-mode" }, { name = "httptools", specifier = ">=0.7.1" }, { name = "lmdb", specifier = ">=2.2.0" }, { name = "loguru", specifier = ">=0.7.3" }, { name = "orjson", specifier = ">=3.11.8" }, - { name = "pydantic-settings", extras = ["yaml"], specifier = ">=2.13.1" }, + { name = "pydantic-settings", extras = ["yaml"], specifier = ">=2.14.0" }, { name = "ruff", marker = "extra == 'dev'" }, { name = "ty", marker = "extra == 'dev'" }, - { name = "uvicorn", specifier = ">=0.44.0" }, + { name = "uvicorn", specifier = ">=0.46.0" }, { name = "uvloop", marker = "sys_platform != 'win32'", specifier = ">=0.22.1" }, ] provides-extras = ["dev"] @@ -176,8 +176,8 @@ dev = [{ name = "gemini-fastapi", extras = ["dev"] }] [[package]] name = "gemini-webapi" -version = "0.0.post271" -source = { git = "https://github.com/luuquangvu/Gemini-API.git?rev=enable-guest-mode#18879bc8e8734348faf1e3fa250070c247d2fdca" } +version = "0.0.post272" +source = { git = "https://github.com/luuquangvu/Gemini-API.git?rev=enable-guest-mode#c9ac4bf411f35f3e24886d3b07a9547413d57434" } dependencies = [ { name = "curl-cffi" }, { name = "loguru" }, @@ -211,11 +211,11 @@ wheels = [ [[package]] name = "idna" -version = "3.11" +version = "3.13" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582, upload-time = "2025-10-12T14:55:20.501Z" } +sdist = { url = "https://files.pythonhosted.org/packages/ce/cc/762dfb036166873f0059f3b7de4565e1b5bc3d6f28a414c13da27e442f99/idna-3.13.tar.gz", hash = "sha256:585ea8fe5d69b9181ec1afba340451fba6ba764af97026f92a91d4eef164a242", size = 194210, upload-time = "2026-04-22T16:42:42.314Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" }, + { url = "https://files.pythonhosted.org/packages/5d/13/ad7d7ca3808a898b4612b6fe93cde56b53f3034dcde235acb1f0e1df24c6/idna-3.13-py3-none-any.whl", hash = "sha256:892ea0cde124a99ce773decba204c5552b69c3c67ffd5f232eb7696135bc8bb3", size = 68629, upload-time = "2026-04-22T16:42:40.909Z" }, ] [[package]] @@ -340,16 +340,16 @@ wheels = [ [[package]] name = "pydantic-settings" -version = "2.13.1" +version = "2.14.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pydantic" }, { name = "python-dotenv" }, { name = "typing-inspection" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/52/6d/fffca34caecc4a3f97bda81b2098da5e8ab7efc9a66e819074a11955d87e/pydantic_settings-2.13.1.tar.gz", hash = "sha256:b4c11847b15237fb0171e1462bf540e294affb9b86db4d9aa5c01730bdbe4025", size = 223826, upload-time = "2026-02-19T13:45:08.055Z" } +sdist = { url = "https://files.pythonhosted.org/packages/42/98/c8345dccdc31de4228c039a98f6467a941e39558da41c1744fbe29fa5666/pydantic_settings-2.14.0.tar.gz", hash = "sha256:24285fd4b0e0c06507dd9fdfd331ee23794305352aaec8fc4eb92d4047aeb67d", size = 235709, upload-time = "2026-04-20T13:37:40.293Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/00/4b/ccc026168948fec4f7555b9164c724cf4125eac006e176541483d2c959be/pydantic_settings-2.13.1-py3-none-any.whl", hash = "sha256:d56fd801823dbeae7f0975e1f8c8e25c258eb75d278ea7abb5d9cebb01b56237", size = 58929, upload-time = "2026-02-19T13:45:06.034Z" }, + { url = "https://files.pythonhosted.org/packages/01/dd/bebff3040138f00ae8a102d426b27349b9a49acc310fcae7f92112d867e3/pydantic_settings-2.14.0-py3-none-any.whl", hash = "sha256:fc8d5d692eb7092e43c8647c1c35a3ecd00e040fcf02ed86f4cb5458ca62182e", size = 60940, upload-time = "2026-04-20T13:37:38.586Z" }, ] [package.optional-dependencies] @@ -408,27 +408,27 @@ wheels = [ [[package]] name = "ruff" -version = "0.15.10" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/e7/d9/aa3f7d59a10ef6b14fe3431706f854dbf03c5976be614a9796d36326810c/ruff-0.15.10.tar.gz", hash = "sha256:d1f86e67ebfdef88e00faefa1552b5e510e1d35f3be7d423dc7e84e63788c94e", size = 4631728, upload-time = "2026-04-09T14:06:09.884Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/eb/00/a1c2fdc9939b2c03691edbda290afcd297f1f389196172826b03d6b6a595/ruff-0.15.10-py3-none-linux_armv6l.whl", hash = "sha256:0744e31482f8f7d0d10a11fcbf897af272fefdfcb10f5af907b18c2813ff4d5f", size = 10563362, upload-time = "2026-04-09T14:06:21.189Z" }, - { url = "https://files.pythonhosted.org/packages/5c/15/006990029aea0bebe9d33c73c3e28c80c391ebdba408d1b08496f00d422d/ruff-0.15.10-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:b1e7c16ea0ff5a53b7c2df52d947e685973049be1cdfe2b59a9c43601897b22e", size = 10951122, upload-time = "2026-04-09T14:06:02.236Z" }, - { url = "https://files.pythonhosted.org/packages/f2/c0/4ac978fe874d0618c7da647862afe697b281c2806f13ce904ad652fa87e4/ruff-0.15.10-py3-none-macosx_11_0_arm64.whl", hash = "sha256:93cc06a19e5155b4441dd72808fdf84290d84ad8a39ca3b0f994363ade4cebb1", size = 10314005, upload-time = "2026-04-09T14:06:00.026Z" }, - { url = "https://files.pythonhosted.org/packages/da/73/c209138a5c98c0d321266372fc4e33ad43d506d7e5dd817dd89b60a8548f/ruff-0.15.10-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:83e1dd04312997c99ea6965df66a14fb4f03ba978564574ffc68b0d61fd3989e", size = 10643450, upload-time = "2026-04-09T14:05:42.137Z" }, - { url = "https://files.pythonhosted.org/packages/ec/76/0deec355d8ec10709653635b1f90856735302cb8e149acfdf6f82a5feb70/ruff-0.15.10-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8154d43684e4333360fedd11aaa40b1b08a4e37d8ffa9d95fee6fa5b37b6fab1", size = 10379597, upload-time = "2026-04-09T14:05:49.984Z" }, - { url = "https://files.pythonhosted.org/packages/dc/be/86bba8fc8798c081e28a4b3bb6d143ccad3fd5f6f024f02002b8f08a9fa3/ruff-0.15.10-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8ab88715f3a6deb6bde6c227f3a123410bec7b855c3ae331b4c006189e895cef", size = 11146645, upload-time = "2026-04-09T14:06:12.246Z" }, - { url = "https://files.pythonhosted.org/packages/a8/89/140025e65911b281c57be1d385ba1d932c2366ca88ae6663685aed8d4881/ruff-0.15.10-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a768ff5969b4f44c349d48edf4ab4f91eddb27fd9d77799598e130fb628aa158", size = 12030289, upload-time = "2026-04-09T14:06:04.776Z" }, - { url = "https://files.pythonhosted.org/packages/88/de/ddacca9545a5e01332567db01d44bd8cf725f2db3b3d61a80550b48308ea/ruff-0.15.10-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0ee3ef42dab7078bda5ff6a1bcba8539e9857deb447132ad5566a038674540d0", size = 11496266, upload-time = "2026-04-09T14:05:55.485Z" }, - { url = "https://files.pythonhosted.org/packages/bc/bb/7ddb00a83760ff4a83c4e2fc231fd63937cc7317c10c82f583302e0f6586/ruff-0.15.10-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:51cb8cc943e891ba99989dd92d61e29b1d231e14811db9be6440ecf25d5c1609", size = 11256418, upload-time = "2026-04-09T14:05:57.69Z" }, - { url = "https://files.pythonhosted.org/packages/dc/8d/55de0d35aacf6cd50b6ee91ee0f291672080021896543776f4170fc5c454/ruff-0.15.10-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:e59c9bdc056a320fb9ea1700a8d591718b8faf78af065484e801258d3a76bc3f", size = 11288416, upload-time = "2026-04-09T14:05:44.695Z" }, - { url = "https://files.pythonhosted.org/packages/68/cf/9438b1a27426ec46a80e0a718093c7f958ef72f43eb3111862949ead3cc1/ruff-0.15.10-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:136c00ca2f47b0018b073f28cb5c1506642a830ea941a60354b0e8bc8076b151", size = 10621053, upload-time = "2026-04-09T14:05:52.782Z" }, - { url = "https://files.pythonhosted.org/packages/4c/50/e29be6e2c135e9cd4cb15fbade49d6a2717e009dff3766dd080fcb82e251/ruff-0.15.10-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:8b80a2f3c9c8a950d6237f2ca12b206bccff626139be9fa005f14feb881a1ae8", size = 10378302, upload-time = "2026-04-09T14:06:14.361Z" }, - { url = "https://files.pythonhosted.org/packages/18/2f/e0b36a6f99c51bb89f3a30239bc7bf97e87a37ae80aa2d6542d6e5150364/ruff-0.15.10-py3-none-musllinux_1_2_i686.whl", hash = "sha256:e3e53c588164dc025b671c9df2462429d60357ea91af7e92e9d56c565a9f1b07", size = 10850074, upload-time = "2026-04-09T14:06:16.581Z" }, - { url = "https://files.pythonhosted.org/packages/11/08/874da392558ce087a0f9b709dc6ec0d60cbc694c1c772dab8d5f31efe8cb/ruff-0.15.10-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:b0c52744cf9f143a393e284125d2576140b68264a93c6716464e129a3e9adb48", size = 11358051, upload-time = "2026-04-09T14:06:18.948Z" }, - { url = "https://files.pythonhosted.org/packages/e4/46/602938f030adfa043e67112b73821024dc79f3ab4df5474c25fa4c1d2d14/ruff-0.15.10-py3-none-win32.whl", hash = "sha256:d4272e87e801e9a27a2e8df7b21011c909d9ddd82f4f3281d269b6ba19789ca5", size = 10588964, upload-time = "2026-04-09T14:06:07.14Z" }, - { url = "https://files.pythonhosted.org/packages/25/b6/261225b875d7a13b33a6d02508c39c28450b2041bb01d0f7f1a83d569512/ruff-0.15.10-py3-none-win_amd64.whl", hash = "sha256:28cb32d53203242d403d819fd6983152489b12e4a3ae44993543d6fe62ab42ed", size = 11745044, upload-time = "2026-04-09T14:05:39.473Z" }, - { url = "https://files.pythonhosted.org/packages/58/ed/dea90a65b7d9e69888890fb14c90d7f51bf0c1e82ad800aeb0160e4bacfd/ruff-0.15.10-py3-none-win_arm64.whl", hash = "sha256:601d1610a9e1f1c2165a4f561eeaa2e2ea1e97f3287c5aa258d3dab8b57c6188", size = 11035607, upload-time = "2026-04-09T14:05:47.593Z" }, +version = "0.15.12" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/99/43/3291f1cc9106f4c63bdce7a8d0df5047fe8422a75b091c16b5e9355e0b11/ruff-0.15.12.tar.gz", hash = "sha256:ecea26adb26b4232c0c2ca19ccbc0083a68344180bba2a600605538ce51a40a6", size = 4643852, upload-time = "2026-04-24T18:17:14.305Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c3/6e/e78ffb61d4686f3d96ba3df2c801161843746dcbcbb17a1e927d4829312b/ruff-0.15.12-py3-none-linux_armv6l.whl", hash = "sha256:f86f176e188e94d6bdbc09f09bfd9dc729059ad93d0e7390b5a73efe19f8861c", size = 10640713, upload-time = "2026-04-24T18:17:22.841Z" }, + { url = "https://files.pythonhosted.org/packages/ae/08/a317bc231fb9e7b93e4ef3089501e51922ff88d6936ce5cf870c4fe55419/ruff-0.15.12-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:e3bcd123364c3770b8e1b7baaf343cc99a35f197c5c6e8af79015c666c423a6c", size = 11069267, upload-time = "2026-04-24T18:17:30.105Z" }, + { url = "https://files.pythonhosted.org/packages/aa/a4/f828e9718d3dce1f5f11c39c4f65afd32783c8b2aebb2e3d259e492c47bd/ruff-0.15.12-py3-none-macosx_11_0_arm64.whl", hash = "sha256:fe87510d000220aa1ed530d4448a7c696a0cae1213e5ec30e5874287b66557b5", size = 10397182, upload-time = "2026-04-24T18:17:07.177Z" }, + { url = "https://files.pythonhosted.org/packages/71/e0/3310fc6d1b5e1fdea22bf3b1b807c7e187b581021b0d7d4514cccdb5fb71/ruff-0.15.12-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:84a1630093121375a3e2a95b4a6dc7b59e2b4ee76216e32d81aae550a832d002", size = 10758012, upload-time = "2026-04-24T18:16:55.759Z" }, + { url = "https://files.pythonhosted.org/packages/11/c1/a606911aee04c324ddaa883ae418f3569792fd3c4a10c50e0dd0a2311e1e/ruff-0.15.12-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:fb129f40f114f089ebe0ca56c0d251cf2061b17651d464bb6478dc01e69f11f5", size = 10447479, upload-time = "2026-04-24T18:16:51.677Z" }, + { url = "https://files.pythonhosted.org/packages/9d/68/4201e8444f0894f21ab4aeeaee68aa4f10b51613514a20d80bd628d57e88/ruff-0.15.12-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b0c862b172d695db7598426b8af465e7e9ac00a3ea2a3630ee67eb82e366aaa6", size = 11234040, upload-time = "2026-04-24T18:17:16.529Z" }, + { url = "https://files.pythonhosted.org/packages/34/ff/8a6d6cf4ccc23fd67060874e832c18919d1557a0611ebef03fdb01fff11e/ruff-0.15.12-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2849ea9f3484c3aca43a82f484210370319e7170df4dfe4843395ddf6c57bc33", size = 12087377, upload-time = "2026-04-24T18:17:04.944Z" }, + { url = "https://files.pythonhosted.org/packages/85/f6/c669cf73f5152f623d34e69866a46d5e6185816b19fcd5b6dd8a2d299922/ruff-0.15.12-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9e77c7e51c07fe396826d5969a5b846d9cd4c402535835fb6e21ce8b28fef847", size = 11367784, upload-time = "2026-04-24T18:17:25.409Z" }, + { url = "https://files.pythonhosted.org/packages/e8/39/c61d193b8a1daaa8977f7dea9e8d8ba866e02ea7b65d32f6861693aa4c12/ruff-0.15.12-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:83b2f4f2f3b1026b5fb449b467d9264bf22067b600f7b6f41fc5958909f449d0", size = 11344088, upload-time = "2026-04-24T18:17:12.258Z" }, + { url = "https://files.pythonhosted.org/packages/c2/8d/49afab3645e31e12c590acb6d3b5b69d7aab5b81926dbaf7461f9441f37a/ruff-0.15.12-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:9ba3b8f1afd7e2e43d8943e55f249e13f9682fde09711644a6e7290eb4f3e339", size = 11271770, upload-time = "2026-04-24T18:17:02.457Z" }, + { url = "https://files.pythonhosted.org/packages/46/06/33f41fe94403e2b755481cdfb9b7ef3e4e0ed031c4581124658d935d52b4/ruff-0.15.12-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:e852ba9fdc890655e1d78f2df1499efbe0e54126bd405362154a75e2bde159c5", size = 10719355, upload-time = "2026-04-24T18:17:27.648Z" }, + { url = "https://files.pythonhosted.org/packages/0d/59/18aa4e014debbf559670e4048e39260a85c7fcee84acfd761ac01e7b8d35/ruff-0.15.12-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:dd8aed930da53780d22fc70bdf84452c843cf64f8cb4eb38984319c24c5cd5fd", size = 10462758, upload-time = "2026-04-24T18:17:32.347Z" }, + { url = "https://files.pythonhosted.org/packages/25/e7/cc9f16fd0f3b5fddcbd7ec3d6ae30c8f3fde1047f32a4093a98d633c6570/ruff-0.15.12-py3-none-musllinux_1_2_i686.whl", hash = "sha256:01da3988d225628b709493d7dc67c3b9b12c0210016b08690ef9bd27970b262b", size = 10953498, upload-time = "2026-04-24T18:17:20.674Z" }, + { url = "https://files.pythonhosted.org/packages/72/7a/a9ba7f98c7a575978698f4230c5e8cc54bbc761af34f560818f933dafa0c/ruff-0.15.12-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:9cae0f92bd5700d1213188b31cd3bdd2b315361296d10b96b8e2337d3d11f53e", size = 11447765, upload-time = "2026-04-24T18:17:09.755Z" }, + { url = "https://files.pythonhosted.org/packages/ea/f9/0ae446942c846b8266059ad8a30702a35afae55f5cdc54c5adf8d7afdc27/ruff-0.15.12-py3-none-win32.whl", hash = "sha256:d0185894e038d7043ba8fd6aee7499ece6462dc0ea9f1e260c7451807c714c20", size = 10657277, upload-time = "2026-04-24T18:17:18.591Z" }, + { url = "https://files.pythonhosted.org/packages/33/f1/9614e03e1cdcbf9437570b5400ced8a720b5db22b28d8e0f1bda429f660d/ruff-0.15.12-py3-none-win_amd64.whl", hash = "sha256:c87a162d61ab3adca47c03f7f717c68672edec7d1b5499e652331780fe74950d", size = 11837758, upload-time = "2026-04-24T18:17:00.113Z" }, + { url = "https://files.pythonhosted.org/packages/c0/98/6beb4b351e472e5f4c4613f7c35a5290b8be2497e183825310c4c3a3984b/ruff-0.15.12-py3-none-win_arm64.whl", hash = "sha256:a538f7a82d061cee7be55542aca1d86d1393d55d81d4fcc314370f4340930d4f", size = 11120821, upload-time = "2026-04-24T18:16:57.979Z" }, ] [[package]] @@ -445,26 +445,26 @@ wheels = [ [[package]] name = "ty" -version = "0.0.30" +version = "0.0.32" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/62/21/3ee32f163038ac2663c7bea47a07d06bf4cc7c09d95b96db194bda1b70cb/ty-0.0.30.tar.gz", hash = "sha256:c982207640e7d75331b81031ebfb884ab858ed26ab16d7c086ac4942e2771846", size = 5518350, upload-time = "2026-04-14T13:53:35.462Z" } +sdist = { url = "https://files.pythonhosted.org/packages/85/7e/2aa791c9ae7b8cd5024cd4122e92267f664ca954cea3def3211919fa3c1f/ty-0.0.32.tar.gz", hash = "sha256:8743174c5f920f6700a4a0c9de140109189192ba16226884cd50095b43b8a45c", size = 5522294, upload-time = "2026-04-20T19:29:01.626Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/a8/24/7aa94d02a9257ed96e64e4e99b527f28390febd8424107b4f8a70763ace9/ty-0.0.30-py3-none-linux_armv6l.whl", hash = "sha256:1be31a24a2a177571c3276854bf01b2b1a77dba6e754507089c25bb1825ce5f6", size = 10801835, upload-time = "2026-04-14T13:53:21.477Z" }, - { url = "https://files.pythonhosted.org/packages/ec/97/2410ebc85cfcdf3bbd0e5958c6cd0b88085b1a184374ecfa755f84d6c8b2/ty-0.0.30-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:019f1d0d5d5265a1e634a51fd49374df43dafae14de98c2a0d349beb8233550b", size = 10582386, upload-time = "2026-04-14T13:53:07.472Z" }, - { url = "https://files.pythonhosted.org/packages/e3/d2/a2649eb6841ebf946ac827e778b7e78b5ef63c3758bf2b9da13d927a53da/ty-0.0.30-py3-none-macosx_11_0_arm64.whl", hash = "sha256:fe3012af4d0714e7353fd3cf6d2d02d5b0f0fe6f1cb8beb2366ed9f621c2c349", size = 10031621, upload-time = "2026-04-14T13:53:01.523Z" }, - { url = "https://files.pythonhosted.org/packages/6e/8e/40a66ccd5d5d51adf0469b9fbe4f1f79f928a880b34b8a6c7c934e8a883a/ty-0.0.30-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d1e90b4ebf6310c7734344739e0950f4cede5a33b1e51a12a0c0fc8a975866ed", size = 10537511, upload-time = "2026-04-14T13:53:04.538Z" }, - { url = "https://files.pythonhosted.org/packages/25/31/5dea2987601ef1c8c58b04f2173971e7fe51f7902ab93a66d09e0f12115a/ty-0.0.30-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:fd83a0d82cbc32c2ae521e7fa101fb5fe5b566adb1364996582535700572a9ec", size = 10603406, upload-time = "2026-04-14T13:53:47.564Z" }, - { url = "https://files.pythonhosted.org/packages/b9/a4/5a7585b6b219a2edc00255af0b16a8475f88fe43c5cdbe499daecb67f100/ty-0.0.30-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:672a29271c13247096d0b2766e69cb35b1583882dd6e7b24065927e2491ffe6d", size = 11109133, upload-time = "2026-04-14T13:53:24.463Z" }, - { url = "https://files.pythonhosted.org/packages/b9/83/b402dc4bd99b6f3eb0bce04e557889a164e099976a7fc71a6b07c923241b/ty-0.0.30-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:91ff236adbb90281c05f7e160664820be50f42d3a9d8f1d0a648f006864114fa", size = 11663362, upload-time = "2026-04-14T13:53:18.505Z" }, - { url = "https://files.pythonhosted.org/packages/65/1b/8157f03acc15421083c194b11a61a78d10e3dfa7e4a0177809fc9acc3881/ty-0.0.30-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6ec99bd5d5430c52fb64038483deb070f12c7ae78ffd6d6841d31719daedf1d7", size = 11304786, upload-time = "2026-04-14T13:53:30.076Z" }, - { url = "https://files.pythonhosted.org/packages/d9/c3/f89a9a42b47da108ed758ae9d065d10bf2acc2ea88e3d200b95511096b7b/ty-0.0.30-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a4b328ee332ec6276afc863ea7cf6d8167d9dd8d9f3d1c2e738ef39932511ac4", size = 11173426, upload-time = "2026-04-14T13:53:10.262Z" }, - { url = "https://files.pythonhosted.org/packages/81/37/fa38ee0259dc49579e1871b23ab1ff27331a78460566cdc13045a237595d/ty-0.0.30-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:fd0d664d6530890a8e872accd96895410773e7a4c6d20c244fb7a5f541ff359b", size = 10517157, upload-time = "2026-04-14T13:53:15.739Z" }, - { url = "https://files.pythonhosted.org/packages/2e/79/28032481141eb6ce3274f62b9ff9b1d73d59df6b28080c8fe3c6bdef700e/ty-0.0.30-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:314004166a7a5e39e169c7da0b9e78f3315382f53db8698fd98346cee3bb0784", size = 10613222, upload-time = "2026-04-14T13:53:13.269Z" }, - { url = "https://files.pythonhosted.org/packages/45/a0/989fca4c74095defd7d3ba5afc68a5aa4e2ca428fedfca5df526701c730b/ty-0.0.30-py3-none-musllinux_1_2_i686.whl", hash = "sha256:d969ebf9d8b08e93e638c56e6fb5a8dacd2a24f43e3519479d245ddde69f968e", size = 10789624, upload-time = "2026-04-14T13:53:42.156Z" }, - { url = "https://files.pythonhosted.org/packages/5c/74/3e74aba392ba2eeae5d86568ee282d9d6b2b6642445e3d9837c88d73c282/ty-0.0.30-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:66922c8c4381a016f90ec4b811748e7bb12da892f4c273640710da721caea7fb", size = 11260273, upload-time = "2026-04-14T13:53:44.974Z" }, - { url = "https://files.pythonhosted.org/packages/24/0e/e94a0e5e5a1850a2ba61c5efcfa594cfc2d23c026bf431cce33003d036a0/ty-0.0.30-py3-none-win32.whl", hash = "sha256:b7b2ecf80c872d7d9928b372e99233bdda7cabe639edd06b6232c3161a7dfa40", size = 10145096, upload-time = "2026-04-14T13:53:39.335Z" }, - { url = "https://files.pythonhosted.org/packages/50/d3/09c8df72ad37f7f4d9d79fe04a08bfa649d9f141d137e624fc23c7c3d7fe/ty-0.0.30-py3-none-win_amd64.whl", hash = "sha256:f29834e3d96c447f2adcf9eeb55b3f92005c91f52597c4c46d844188ec67ec72", size = 11156009, upload-time = "2026-04-14T13:53:32.847Z" }, - { url = "https://files.pythonhosted.org/packages/e6/17/a5c049c36e2fef9c593a1862f275af963b66045378f10b6908c6f10f6f4a/ty-0.0.30-py3-none-win_arm64.whl", hash = "sha256:d9be1d258dab615b447d20fa58633f0ae163af01bfa781a50457defec20642fd", size = 10552887, upload-time = "2026-04-14T13:53:27.455Z" }, + { url = "https://files.pythonhosted.org/packages/62/eb/1075dc6a49d7acbe2584ae4d5b410c41b1f177a5adcc567e09eca4c69000/ty-0.0.32-py3-none-linux_armv6l.whl", hash = "sha256:dacbc2f6cd698d488ae7436838ff929570455bf94bfa4d9fe57a630c552aff83", size = 10902959, upload-time = "2026-04-20T19:28:31.907Z" }, + { url = "https://files.pythonhosted.org/packages/33/d2/c35fc8bc66e98d1ee9b0f8ed319bf743e450e1f1e997574b178fab75670f/ty-0.0.32-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:914bbc4f605ce2a9e2a78982e28fae1d3359a169d141f9dc3b4c7749cd5eca81", size = 10726172, upload-time = "2026-04-20T19:28:44.765Z" }, + { url = "https://files.pythonhosted.org/packages/96/32/c827da3ca480456fb02d8cea68a2609273b6c220fea0be9a4c8d8470b86e/ty-0.0.32-py3-none-macosx_11_0_arm64.whl", hash = "sha256:4787ac9fe1f86b1f3133f5c6732adbe2df5668b50c679ac6e2d98cd284da812f", size = 10163701, upload-time = "2026-04-20T19:28:27.005Z" }, + { url = "https://files.pythonhosted.org/packages/ba/9e/2734478fbdb90c160cb2813a3916a16a2af5c1e231f87d635f6131d781fb/ty-0.0.32-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d8ea0a728af99fe40dd744cba6441a2404f80b7f4bde17aa6da393810af5ea57", size = 10656220, upload-time = "2026-04-20T19:29:03.814Z" }, + { url = "https://files.pythonhosted.org/packages/44/9f/0007da2d35e424debe7e9f86ffbc1ab7f60983cfbc5f0411324ab2de5292/ty-0.0.32-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2850561f9b018ae33d7e5bbfa0ac414d3c518513edcffe43877dc9801446b9c5", size = 10696086, upload-time = "2026-04-20T19:28:46.829Z" }, + { url = "https://files.pythonhosted.org/packages/3b/5e/ce5fd4ec803222ae3e69a76d2a2db2eed55e19f5b131702b9789ef45f93d/ty-0.0.32-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b5fa2fb3c614349ee211d36476b49d88c5ef79a687cdb91b2872ad023b94d2f8", size = 11184800, upload-time = "2026-04-20T19:28:42.57Z" }, + { url = "https://files.pythonhosted.org/packages/6c/46/ebcf67a5999421331214aac51a7464db42de2be15bbe929c612a3ed0b039/ty-0.0.32-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2b89969307ab2417d41c9be8059dd79feea577234e1e10d35132f5495e0d42c6", size = 11718718, upload-time = "2026-04-20T19:28:36.433Z" }, + { url = "https://files.pythonhosted.org/packages/18/2c/2141c86ed0ce0962b45cefb658a95e734f59759d47f20afdcd9c732910a1/ty-0.0.32-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9b59868ede9b1d69a088f0d695df52a0061f95fa7baa1d5e0dc6fc9cf06e1334", size = 11346369, upload-time = "2026-04-20T19:28:48.967Z" }, + { url = "https://files.pythonhosted.org/packages/7a/da/ed6f772339cf29bd9a46def9d6db5084689eb574ee4d150ff704224c1ed8/ty-0.0.32-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8300caf35345498e9b9b03e550bba03cee8f5f5f8ab4c83c3b1ff1b7403b7d3a", size = 11280714, upload-time = "2026-04-20T19:28:51.516Z" }, + { url = "https://files.pythonhosted.org/packages/da/9b/c6813987edf4816a40e0c8e408b555f97d3f267c7b3a1688c8bbdf65609c/ty-0.0.32-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:583c7094f4574b02f724db924f98b804d1387a0bd9405ecb5e078cc0f47fbcfb", size = 10638806, upload-time = "2026-04-20T19:28:29.651Z" }, + { url = "https://files.pythonhosted.org/packages/4e/d4/0cefcbd2ad0f3d51762ccf58e652ec7da146eb6ae34f87228f6254bbb8be/ty-0.0.32-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:e44ebe1bb4143a5628bc4db67ac0dfebe14594af671e4ee66f6f2e983da56501", size = 10726106, upload-time = "2026-04-20T19:29:06.3Z" }, + { url = "https://files.pythonhosted.org/packages/32/ad/2c8a97f91f06311f4367400f7d13534bbda2522c73c99a3e4c0757dff9b8/ty-0.0.32-py3-none-musllinux_1_2_i686.whl", hash = "sha256:06f17ada3e069cba6148342ef88e9929156beca8473e8d4f101b68f66c75643e", size = 10872951, upload-time = "2026-04-20T19:28:34.077Z" }, + { url = "https://files.pythonhosted.org/packages/ba/68/42293f9248106dd51875120971a5cc6ea315c2c4dcfb8e59aa063aa0af26/ty-0.0.32-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:e96e60fa556cec04f15d7ea62d2ceee5982bd389233e961ab9fd42304e278175", size = 11363334, upload-time = "2026-04-20T19:28:54.036Z" }, + { url = "https://files.pythonhosted.org/packages/df/92/be9abf4d3e589ad5023e2ea965b93e204ec856420d46adf73c5c36c04678/ty-0.0.32-py3-none-win32.whl", hash = "sha256:2ff2ebb4986b24aebcf1444db7db5ca41b36086040e95eea9f8fb851c11e805c", size = 10260689, upload-time = "2026-04-20T19:28:56.541Z" }, + { url = "https://files.pythonhosted.org/packages/14/61/dc86acea899349d2579cb8419aecedd83dc504d7d6a10df65eef546c8300/ty-0.0.32-py3-none-win_amd64.whl", hash = "sha256:ba7284a4a954b598c1b31500352b3ec1f89bff533825592b5958848226fdc7ee", size = 11255371, upload-time = "2026-04-20T19:28:39.917Z" }, + { url = "https://files.pythonhosted.org/packages/43/01/beffec56d71ca25b343ede63adb076456b5b3e211f1c066452a44cd120b3/ty-0.0.32-py3-none-win_arm64.whl", hash = "sha256:7e10aadbdbda989a7d567ee6a37f8b98d4d542e31e3b190a2879fd581f75d658", size = 10658087, upload-time = "2026-04-20T19:28:59.286Z" }, ] [[package]] @@ -490,15 +490,15 @@ wheels = [ [[package]] name = "uvicorn" -version = "0.44.0" +version = "0.46.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "click" }, { name = "h11" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/5e/da/6eee1ff8b6cbeed47eeb5229749168e81eb4b7b999a1a15a7176e51410c9/uvicorn-0.44.0.tar.gz", hash = "sha256:6c942071b68f07e178264b9152f1f16dfac5da85880c4ce06366a96d70d4f31e", size = 86947, upload-time = "2026-04-06T09:23:22.826Z" } +sdist = { url = "https://files.pythonhosted.org/packages/1f/93/041fca8274050e40e6791f267d82e0e2e27dd165627bd640d3e0e378d877/uvicorn-0.46.0.tar.gz", hash = "sha256:fb9da0926999cc6cb22dc7cd71a94a632f078e6ae47ff683c5c420750fb7413d", size = 88758, upload-time = "2026-04-23T07:16:00.151Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/b7/23/a5bbd9600dd607411fa644c06ff4951bec3a4d82c4b852374024359c19c0/uvicorn-0.44.0-py3-none-any.whl", hash = "sha256:ce937c99a2cc70279556967274414c087888e8cec9f9c94644dfca11bd3ced89", size = 69425, upload-time = "2026-04-06T09:23:21.524Z" }, + { url = "https://files.pythonhosted.org/packages/31/a3/5b1562db76a5a488274b2332a97199b32d0442aca0ed193697fd47786316/uvicorn-0.46.0-py3-none-any.whl", hash = "sha256:bbebbcbed972d162afca128605223022bedd345b7bc7855ce66deb31487a9048", size = 70926, upload-time = "2026-04-23T07:15:58.355Z" }, ] [[package]] From 3230a8104c8ca9fa4bd09498e177685ee8cc10b5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C6=B0u=20Quang=20V=C5=A9?= Date: Mon, 27 Apr 2026 10:50:28 +0700 Subject: [PATCH 250/291] Experiment with resolving the issue of cookies becoming inactive after a certain period of time --- app/main.py | 31 +------ app/services/client.py | 191 ++++++++++++++++++++++------------------- app/services/pool.py | 23 ----- app/utils/config.py | 11 +-- config/config.yaml | 3 +- uv.lock | 4 +- 6 files changed, 114 insertions(+), 149 deletions(-) diff --git a/app/main.py b/app/main.py index 5149b08..c856546 100644 --- a/app/main.py +++ b/app/main.py @@ -13,7 +13,6 @@ cleanup_expired_media, ) from .services import GeminiClientPool, LMDBConversationStore -from .utils import g_config RETENTION_CLEANUP_INTERVAL_SECONDS = 6 * 60 * 60 # Check every 6 hours @@ -49,31 +48,6 @@ async def _run_retention_cleanup(stop_event: asyncio.Event) -> None: logger.info("LMDB retention cleanup task stopped.") -async def _run_pool_watchdog(stop_event: asyncio.Event) -> None: - """ - Periodically check for dead clients in the pool and revive them. - """ - pool = GeminiClientPool() - interval = g_config.gemini.pool_watchdog_interval - logger.info(f"Starting Gemini pool watchdog task (interval={interval} seconds).") - - while not stop_event.is_set(): - try: - await pool.revive_dead_clients() - except Exception: - logger.exception("Gemini pool watchdog task encountered an error.") - - try: - await asyncio.wait_for( - stop_event.wait(), - timeout=interval, - ) - except TimeoutError: - continue - - logger.info("Gemini pool watchdog task stopped.") - - @asynccontextmanager async def lifespan(app: FastAPI): cleanup_stop_event = asyncio.Event() @@ -86,12 +60,11 @@ async def lifespan(app: FastAPI): raise cleanup_task = asyncio.create_task(_run_retention_cleanup(cleanup_stop_event)) - watchdog_task = asyncio.create_task(_run_pool_watchdog(cleanup_stop_event)) # Give the tasks a chance to start and surface immediate failures. await asyncio.sleep(0) - for task, name in [(cleanup_task, "LMDB retention cleanup"), (watchdog_task, "Pool watchdog")]: + for task, name in [(cleanup_task, "LMDB retention cleanup")]: if task.done(): try: task.result() @@ -112,7 +85,7 @@ async def lifespan(app: FastAPI): logger.exception("Failed to close Gemini client pool gracefully.") try: - await asyncio.gather(cleanup_task, watchdog_task) + await cleanup_task except asyncio.CancelledError: logger.debug("Background tasks cancelled during shutdown.") except Exception: diff --git a/app/services/client.py b/app/services/client.py index c08643a..a528978 100644 --- a/app/services/client.py +++ b/app/services/client.py @@ -1,5 +1,4 @@ import io -import time from pathlib import Path from typing import Any @@ -23,8 +22,6 @@ def __init__(self, client_id: str, **kwargs): self._cfg_impersonate: str | None = kwargs.pop("impersonate", None) super().__init__(**kwargs) self.id = client_id - self._failure_count: int = 0 - self._last_failure_time: float = 0.0 async def init(self, *args: Any, **kwargs: Any) -> None: """ @@ -36,6 +33,8 @@ async def init(self, *args: Any, **kwargs: Any) -> None: "watchdog_timeout": config.watchdog_timeout, "auto_refresh": config.auto_refresh, "refresh_interval": config.refresh_interval, + "auto_close": config.auto_close, + "close_delay": config.close_delay, "verbose": config.verbose, } if self._cfg_impersonate is not None: @@ -49,44 +48,39 @@ async def init(self, *args: Any, **kwargs: Any) -> None: def running(self) -> bool: return self._running - def record_success(self) -> None: - """Reset failure tracking on successful connection.""" - self._failure_count = 0 - self._last_failure_time = 0.0 - - def record_failure(self) -> None: - """Increment failure count and update timestamp on connection error.""" - self._failure_count += 1 - self._last_failure_time = time.time() - - def can_revive(self, now: float, base_delay: int, max_delay: int) -> bool: + @staticmethod + async def _process_content_item( + item: Any, role: str, tempdir: Path | None + ) -> tuple[str | None, Path | str | None]: """ - Check if the client is eligible for revival based on exponential backoff. - Delay = min(max_delay, base_delay * (2 ** (failure_count - 1))) + Process a single content item (text, image_url, file, input_audio). + Returns a tuple of (text_fragment, file_path). """ - if self._failure_count == 0: - return True - - delay = min(max_delay, base_delay * (2 ** (self._failure_count - 1))) - eligible = now >= (self._last_failure_time + delay) - if not eligible: - remaining = int((self._last_failure_time + delay) - now) - logger.debug( - f"Client {self.id} backoff active: {self._failure_count} failures, " - f"retrying in {remaining}s" - ) - return eligible + if item.type == "text": + item_text = getattr(item, "text", "") or "" + if item_text or role == "tool": + return item_text, None + elif item.type == "image_url": + if item_media_url := getattr(item, "url", None): + return None, await save_url_to_tempfile(item_media_url, tempdir) + raise ValueError(f"{item.type} cannot be empty") + elif item.type == "file": + if not (file_data := getattr(item, "file_data", None)): + raise ValueError("File must contain 'file_data'") + filename = getattr(item, "filename", "") or "" + return None, await save_file_to_tempfile(file_data, filename, tempdir) + elif item.type == "input_audio": + if file_data := getattr(item, "file_data", None): + return None, await save_file_to_tempfile(file_data, "audio.wav", tempdir) + raise ValueError("input_audio must contain 'file_data' key") + return None, None @staticmethod - async def process_message( - message: AppMessage, - tempdir: Path | None = None, - tagged: bool = True, - wrap_tool: bool = True, - ) -> tuple[str, list[Path | str]]: + async def _extract_content_and_files( + message: AppMessage, tempdir: Path | None + ) -> tuple[list[str], list[Path | str]]: """ - Process a Message into Gemini API format using the PascalCase technical protocol. - Extracts text, handles files, and appends ToolCalls/ToolResults blocks. + Extract text fragments and files from message content. """ files: list[Path | str] = [] text_fragments: list[str] = [] @@ -96,67 +90,86 @@ async def process_message( text_fragments.append(message.content or "") elif isinstance(message.content, list): for item in message.content: - if item.type == "text": - item_text = getattr(item, "text", "") or "" - if item_text or message.role == "tool": - text_fragments.append(item_text) - elif item.type == "image_url": - if item_media_url := getattr(item, "url", None): - files.append(await save_url_to_tempfile(item_media_url, tempdir)) - else: - raise ValueError(f"{item.type} cannot be empty") - elif item.type == "file": - if not (file_data := getattr(item, "file_data", None)): - raise ValueError("File must contain 'file_data'") - filename = getattr(item, "filename", "") or "" - files.append(await save_file_to_tempfile(file_data, filename, tempdir)) - elif item.type == "input_audio": - if file_data := getattr(item, "file_data", None): - files.append(await save_file_to_tempfile(file_data, "audio.wav", tempdir)) - else: - raise ValueError("input_audio must contain 'file_data' key") + text, file = await GeminiClientWrapper._process_content_item( + item, message.role, tempdir + ) + if text is not None: + text_fragments.append(text) + if file is not None: + files.append(file) elif message.content is None and message.role == "tool": text_fragments.append("") elif message.content is not None: raise ValueError(f"Unsupported message content type: {type(message.content)}") - if message.role == "tool": - tool_name = message.name or "unknown" - combined_content = "\n".join(text_fragments).strip() - res_block = ( - f"[Result:{tool_name}]\n[ToolResult]\n{combined_content}\n[/ToolResult]\n[/Result]" - ) - if wrap_tool: - text_fragments = [f"[ToolResults]\n{res_block}\n[/ToolResults]"] - else: - text_fragments = [res_block] - - if message.tool_calls: - tool_blocks: list[str] = [] - for call in message.tool_calls: - params_text = call.function.arguments.strip() - formatted_params = "" - if params_text: - try: - parsed_params = orjson.loads(params_text) - if isinstance(parsed_params, dict): - for k, v in parsed_params.items(): - val_str = ( - v if isinstance(v, str) else orjson.dumps(v).decode("utf-8") - ) - formatted_params += ( - f"[CallParameter:{k}]\n```\n{val_str}\n```\n[/CallParameter]\n" - ) - else: - formatted_params += f"```\n{params_text}\n```\n" - except orjson.JSONDecodeError: + return text_fragments, files + + @staticmethod + def _format_tool_results( + text_fragments: list[str], tool_name: str | None, wrap_tool: bool + ) -> list[str]: + """ + Format tool results into the PascalCase technical protocol blocks. + """ + tool_name = tool_name or "unknown" + combined_content = "\n".join(text_fragments).strip() + res_block = ( + f"[Result:{tool_name}]\n[ToolResult]\n{combined_content}\n[/ToolResult]\n[/Result]" + ) + return [f"[ToolResults]\n{res_block}\n[/ToolResults]"] if wrap_tool else [res_block] + + @staticmethod + def _format_tool_calls(message: AppMessage) -> str | None: + """ + Format tool calls into the PascalCase technical protocol blocks. + """ + if not message.tool_calls: + return None + + tool_blocks: list[str] = [] + for call in message.tool_calls: + params_text = call.function.arguments.strip() + formatted_params = "" + if params_text: + try: + parsed_params = orjson.loads(params_text) + if isinstance(parsed_params, dict): + for k, v in parsed_params.items(): + val_str = v if isinstance(v, str) else orjson.dumps(v).decode("utf-8") + formatted_params += ( + f"[CallParameter:{k}]\n```\n{val_str}\n```\n[/CallParameter]\n" + ) + else: formatted_params += f"```\n{params_text}\n```\n" + except orjson.JSONDecodeError: + formatted_params += f"```\n{params_text}\n```\n" + + tool_blocks.append(f"[Call:{call.function.name}]\n{formatted_params}[/Call]") - tool_blocks.append(f"[Call:{call.function.name}]\n{formatted_params}[/Call]") + return "[ToolCalls]\n" + "\n".join(tool_blocks) + "\n[/ToolCalls]" if tool_blocks else None + + @staticmethod + async def process_message( + message: AppMessage, + tempdir: Path | None = None, + tagged: bool = True, + wrap_tool: bool = True, + ) -> tuple[str, list[Path | str]]: + """ + Process a Message into Gemini API format using the PascalCase technical protocol. + Extracts text, handles files, and appends ToolCalls/ToolResults blocks. + """ + text_fragments, files = await GeminiClientWrapper._extract_content_and_files( + message, tempdir + ) + + if message.role == "tool": + text_fragments = GeminiClientWrapper._format_tool_results( + text_fragments, message.name, wrap_tool + ) - if tool_blocks: - tool_section = "[ToolCalls]\n" + "\n".join(tool_blocks) + "\n[/ToolCalls]" - text_fragments.append(tool_section) + if tool_section := GeminiClientWrapper._format_tool_calls(message): + text_fragments.append(tool_section) model_input = "\n".join(fragment for fragment in text_fragments if fragment is not None) diff --git a/app/services/pool.py b/app/services/pool.py index 3cefc15..7d7143c 100644 --- a/app/services/pool.py +++ b/app/services/pool.py @@ -1,6 +1,5 @@ import asyncio import random -import time from collections import deque from loguru import logger @@ -39,9 +38,7 @@ async def init(self) -> None: for i, client in enumerate(clients_to_init): try: await client.init() - client.record_success() except Exception: - client.record_failure() logger.error(f"Failed to initialize client {client.id}") if i < len(clients_to_init) - 1: @@ -91,11 +88,9 @@ async def _ensure_client_ready(self, client: GeminiClientWrapper) -> bool: try: await client.init() - client.record_success() logger.info(f"Restarted Gemini client {client.id} after it stopped.") return True except Exception: - client.record_failure() logger.exception(f"Failed to restart Gemini client {client.id}") return False @@ -104,24 +99,6 @@ def clients(self) -> list[GeminiClientWrapper]: """Return managed clients.""" return self._clients - async def revive_dead_clients(self) -> None: - """Check all clients and attempt to restart any that are not running with staggering and backoff.""" - now = time.time() - base_delay = g_config.gemini.pool_watchdog_interval - max_delay = 3600 # 1 hour - - dead_clients = [ - c for c in self._clients if not c.running() and c.can_revive(now, base_delay, max_delay) - ] - for i, client in enumerate(dead_clients): - logger.info(f"Watchdog detected dead client {client.id}; attempting revival.") - await self._ensure_client_ready(client) - - if i < len(dead_clients) - 1: - delay = random.uniform(5, 30) - logger.info(f"Staggering next revival by {delay:.2f}s") - await asyncio.sleep(delay) - async def close(self) -> None: """Close all clients in the pool.""" if not self._clients: diff --git a/app/utils/config.py b/app/utils/config.py index 46a8496..31578da 100644 --- a/app/utils/config.py +++ b/app/utils/config.py @@ -112,12 +112,13 @@ class GeminiConfig(BaseModel): ge=60, description="Interval in seconds to refresh Gemini sessions (Not less than 60s)", ) - verbose: bool = Field(True, description="Enable verbose logging for Gemini API requests") - pool_watchdog_interval: int = Field( - default=60, - ge=10, - description="Interval in seconds for the client pool watchdog to check and revive dead clients", + auto_close: bool = Field( + default=True, description="Enable auto-close for Gemini sessions after inactivity" + ) + close_delay: int = Field( + default=450, ge=30, description="Inactivity delay in seconds before auto-closing" ) + verbose: bool = Field(True, description="Enable verbose logging for Gemini API requests") max_chars_per_request: int = Field( default=1_000_000, ge=1, diff --git a/config/config.yaml b/config/config.yaml index e41c37c..7917a6a 100644 --- a/config/config.yaml +++ b/config/config.yaml @@ -27,8 +27,9 @@ gemini: watchdog_timeout: 120 # Watchdog timeout in seconds (Not less than 30s) auto_refresh: true # Auto-refresh session cookies refresh_interval: 600 # Refresh interval in seconds (Not less than 60s) + auto_close: true # Automatically close Gemini session after inactivity + close_delay: 450 # Inactivity delay in seconds before auto-closing (Not less than 30s) verbose: true # Enable verbose logging for Gemini requests - pool_watchdog_interval: 60 # Interval in seconds to check and revive dead clients (Not less than 10s) max_chars_per_request: 1000000 # Maximum characters Gemini Web accepts per request. Non-pro users might have a lower limit model_strategy: "append" # Strategy: 'append' (default + custom) or 'overwrite' (custom only) models: [] diff --git a/uv.lock b/uv.lock index 88ad776..f042b2f 100644 --- a/uv.lock +++ b/uv.lock @@ -176,8 +176,8 @@ dev = [{ name = "gemini-fastapi", extras = ["dev"] }] [[package]] name = "gemini-webapi" -version = "0.0.post272" -source = { git = "https://github.com/luuquangvu/Gemini-API.git?rev=enable-guest-mode#c9ac4bf411f35f3e24886d3b07a9547413d57434" } +version = "0.0.post273" +source = { git = "https://github.com/luuquangvu/Gemini-API.git?rev=enable-guest-mode#8ad85b688a1243d904ccf1685059cc32d173f41c" } dependencies = [ { name = "curl-cffi" }, { name = "loguru" }, From bf765bc08bcc5b73d605a30b35251f96a2c8f889 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C6=B0u=20Quang=20V=C5=A9?= Date: Mon, 27 Apr 2026 11:54:53 +0700 Subject: [PATCH 251/291] Experiment with resolving the issue of cookies becoming inactive after a certain period of time --- uv.lock | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/uv.lock b/uv.lock index f042b2f..72bac5b 100644 --- a/uv.lock +++ b/uv.lock @@ -176,8 +176,8 @@ dev = [{ name = "gemini-fastapi", extras = ["dev"] }] [[package]] name = "gemini-webapi" -version = "0.0.post273" -source = { git = "https://github.com/luuquangvu/Gemini-API.git?rev=enable-guest-mode#8ad85b688a1243d904ccf1685059cc32d173f41c" } +version = "0.0.post274" +source = { git = "https://github.com/luuquangvu/Gemini-API.git?rev=enable-guest-mode#a3878dca221b4428f0d96a2ffef47aa7ecc83bfe" } dependencies = [ { name = "curl-cffi" }, { name = "loguru" }, From 2b605b44724bbbaf776f362c435c703176953b55 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C6=B0u=20Quang=20V=C5=A9?= Date: Mon, 27 Apr 2026 13:36:26 +0700 Subject: [PATCH 252/291] Update health_check --- app/server/health.py | 2 +- app/services/client.py | 10 ++++++++++ app/services/pool.py | 4 ++-- 3 files changed, 13 insertions(+), 3 deletions(-) diff --git a/app/server/health.py b/app/server/health.py index 449081f..14587f9 100644 --- a/app/server/health.py +++ b/app/server/health.py @@ -16,7 +16,7 @@ async def health_check(): if not all(client_status.values()): down_clients = [client_id for client_id, status in client_status.items() if not status] - logger.warning(f"One or more Gemini clients not running: {', '.join(down_clients)}") + logger.warning(f"One or more Gemini clients are unhealthy: {', '.join(down_clients)}") if not stat: logger.error("Failed to retrieve LMDB conversation store stats") diff --git a/app/services/client.py b/app/services/client.py index a528978..6fe259c 100644 --- a/app/services/client.py +++ b/app/services/client.py @@ -22,6 +22,7 @@ def __init__(self, client_id: str, **kwargs): self._cfg_impersonate: str | None = kwargs.pop("impersonate", None) super().__init__(**kwargs) self.id = client_id + self._initialized = False async def init(self, *args: Any, **kwargs: Any) -> None: """ @@ -41,13 +42,22 @@ async def init(self, *args: Any, **kwargs: Any) -> None: init_kwargs["impersonate"] = self._cfg_impersonate try: await super().init(**init_kwargs) + self._initialized = True except Exception: + self._initialized = False logger.exception(f"Failed to initialize GeminiClient {self.id}") raise def running(self) -> bool: return self._running + def is_healthy(self) -> bool: + """ + Check if the client is healthy. + A client is healthy if it is running, or if auto_close is enabled and it has initialized successfully. + """ + return self._running or (self.auto_close and self._initialized) + @staticmethod async def _process_content_item( item: Any, role: str, tempdir: Path | None diff --git a/app/services/pool.py b/app/services/pool.py index 7d7143c..1e8aae5 100644 --- a/app/services/pool.py +++ b/app/services/pool.py @@ -112,5 +112,5 @@ async def close(self) -> None: logger.info("All Gemini clients closed.") def status(self) -> dict[str, bool]: - """Return running status for each client.""" - return {client.id: client.running() for client in self._clients} + """Return healthy status for each client.""" + return {client.id: client.is_healthy() for client in self._clients} From 7481322327a3a06064a572cea8411d5c23af56ab Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C6=B0u=20Quang=20V=C5=A9?= Date: Mon, 27 Apr 2026 15:10:44 +0700 Subject: [PATCH 253/291] Experiment with resolving the issue of cookies becoming inactive after a certain period of time --- README.md | 12 +++++------- README.zh.md | 12 +++++------- app/utils/config.py | 2 +- app/utils/helper.py | 2 +- config/config.yaml | 2 +- uv.lock | 4 ++-- 6 files changed, 15 insertions(+), 19 deletions(-) diff --git a/README.md b/README.md index 1715c74..19a04eb 100644 --- a/README.md +++ b/README.md @@ -57,7 +57,7 @@ gemini: secure_1psid: "YOUR_SECURE_1PSID_HERE" secure_1psidts: "YOUR_SECURE_1PSIDTS_HERE" proxy: null # Optional proxy URL (null/empty keeps direct connection) - impersonate: null # Optional browser impersonation target (null uses default "chrome") + impersonate: null # Optional browser impersonation target (null uses default "firefox") ``` > [!NOTE] @@ -182,7 +182,7 @@ export CONFIG_GEMINI__CLIENTS__0__SECURE_1PSIDTS="your-secure-1psidts" export CONFIG_GEMINI__CLIENTS__0__PROXY="socks5://127.0.0.1:1080" # Override browser impersonation for client 0 -export CONFIG_GEMINI__CLIENTS__0__IMPERSONATE="chrome" +export CONFIG_GEMINI__CLIENTS__0__IMPERSONATE="firefox" # Override conversation storage size limit @@ -225,18 +225,16 @@ Each client entry can be configured with a different proxy to work around rate l Each client can optionally set an `impersonate` value to control the TLS/HTTP fingerprint used by `curl_cffi`. This is useful when Google blocks requests from a specific browser profile. -- Set to `null` (default) to use the library's default (`"chrome"`, which maps to the latest Chrome version). -- Set to any value supported by [`curl_cffi`'s `BrowserTypeLiteral`](https://github.com/lexiforest/curl_cffi), for example: `chrome`, `safari`, `safari_ios`, `firefox`, etc. +- Set to `null` (default) to use the library's default (`"firefox"`, which maps to the latest Firefox version). +- Set to any value supported by [`curl_cffi`'s `BrowserTypeLiteral`](https://github.com/lexiforest/curl_cffi), for example: `safari`, `safari_ios`, `firefox`, etc. - The value is validated at startup; an invalid value will prevent the server from starting. ```yaml gemini: clients: - id: "client-a" - impersonate: "chrome" # Use latest Chrome fingerprint (default) - - id: "client-b" impersonate: "firefox" # Use Firefox fingerprint - - id: "client-c" + - id: "client-b" impersonate: null # Use library default ``` diff --git a/README.zh.md b/README.zh.md index ea4a042..14077a7 100644 --- a/README.zh.md +++ b/README.zh.md @@ -57,7 +57,7 @@ gemini: secure_1psid: "YOUR_SECURE_1PSID_HERE" secure_1psidts: "YOUR_SECURE_1PSIDTS_HERE" proxy: null # 可选代理 URL (null/空值则保持直连) - impersonate: null # 可选浏览器指纹模拟 (null 则使用默认值 "chrome") + impersonate: null # 可选浏览器指纹模拟 (null 则使用默认值 "firefox") ``` > [!NOTE] @@ -182,7 +182,7 @@ export CONFIG_GEMINI__CLIENTS__0__SECURE_1PSIDTS="your-secure-1psidts" export CONFIG_GEMINI__CLIENTS__0__PROXY="socks5://127.0.0.1:1080" # 覆盖 Client 0 的浏览器指纹模拟 -export CONFIG_GEMINI__CLIENTS__0__IMPERSONATE="chrome" +export CONFIG_GEMINI__CLIENTS__0__IMPERSONATE="firefox" # 覆盖对话存储大小限制 @@ -221,18 +221,16 @@ export CONFIG_STORAGE__MAX_SIZE=268435456 # 256 MB 每个客户端可以通过 `impersonate` 参数设置 `curl_cffi` 使用的 TLS/HTTP 指纹。当 Google 屏蔽某种浏览器指纹时,切换为其他浏览器会有帮助。 -- 设置为 `null`(默认)则使用库的默认值(`"chrome"`,即最新 Chrome 版本)。 -- 可设为 [`curl_cffi` 的 `BrowserTypeLiteral`](https://github.com/lexiforest/curl_cffi) 支持的任意值,例如:`chrome`、`safari`、`safari_ios`、`firefox` 等。 +- 设置为 `null`(默认)则使用库的默认值(`"firefox"`,即最新 Firefox 版本)。 +- 可设为 [`curl_cffi` 的 `BrowserTypeLiteral`](https://github.com/lexiforest/curl_cffi) 支持的任意值,例如:`safari`、`safari_ios`、`firefox` 等。 - 启动时会校验该值;无效值会阻止服务启动。 ```yaml gemini: clients: - id: "client-a" - impersonate: "chrome" # 使用最新 Chrome 指纹(默认) - - id: "client-b" impersonate: "firefox" # 使用 Firefox 指纹 - - id: "client-c" + - id: "client-b" impersonate: null # 使用库默认值 ``` diff --git a/app/utils/config.py b/app/utils/config.py index 31578da..6e0f4f6 100644 --- a/app/utils/config.py +++ b/app/utils/config.py @@ -45,7 +45,7 @@ class GeminiClientSettings(BaseModel): proxy: str | None = Field(default=None, description="Proxy URL for this Gemini client") impersonate: str | None = Field( default=None, - description="Browser impersonation target for curl_cffi (e.g. 'chrome'). None uses library default", + description="Browser impersonation target for curl_cffi (e.g. 'firefox'). None uses library default", ) @field_validator("proxy", "impersonate", mode="before") diff --git a/app/utils/helper.py b/app/utils/helper.py index ea4d18f..de32e76 100644 --- a/app/utils/helper.py +++ b/app/utils/helper.py @@ -192,7 +192,7 @@ async def save_url_to_tempfile(url: str, tempdir: Path | None = None) -> Path: ) else: async with requests.AsyncSession( - impersonate="chrome", allow_redirects=CurlFollow.SAFE, http_version=CurlHttpVersion.V3 + impersonate="firefox", allow_redirects=CurlFollow.SAFE, http_version=CurlHttpVersion.V3 ) as client: resp = await client.get(url) resp.raise_for_status() diff --git a/config/config.yaml b/config/config.yaml index 7917a6a..321073e 100644 --- a/config/config.yaml +++ b/config/config.yaml @@ -22,7 +22,7 @@ gemini: secure_1psid: "YOUR_SECURE_1PSID_HERE" # Gemini Secure 1PSID secure_1psidts: "YOUR_SECURE_1PSIDTS_HERE" # Gemini Secure 1PSIDTS proxy: null # Optional proxy URL (null/empty means direct connection) - impersonate: null # Optional browser impersonation target (null uses library default "chrome") + impersonate: null # Optional browser impersonation target (null uses library default "firefox") timeout: 450 # Init timeout in seconds (Not less than 30s) watchdog_timeout: 120 # Watchdog timeout in seconds (Not less than 30s) auto_refresh: true # Auto-refresh session cookies diff --git a/uv.lock b/uv.lock index 72bac5b..539840c 100644 --- a/uv.lock +++ b/uv.lock @@ -176,8 +176,8 @@ dev = [{ name = "gemini-fastapi", extras = ["dev"] }] [[package]] name = "gemini-webapi" -version = "0.0.post274" -source = { git = "https://github.com/luuquangvu/Gemini-API.git?rev=enable-guest-mode#a3878dca221b4428f0d96a2ffef47aa7ecc83bfe" } +version = "0.0.post275" +source = { git = "https://github.com/luuquangvu/Gemini-API.git?rev=enable-guest-mode#bf1394228aff8cc4740a458e2b2049154876f21c" } dependencies = [ { name = "curl-cffi" }, { name = "loguru" }, From 26f5ad44296d8fd8976abb965dd6bd7ac3897511 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C6=B0u=20Quang=20V=C5=A9?= Date: Mon, 27 Apr 2026 16:10:04 +0700 Subject: [PATCH 254/291] Update README to avoid using chrome based --- README.md | 11 +++++++---- README.zh.md | 11 +++++++---- config/config.yaml | 2 +- uv.lock | 4 ++-- 4 files changed, 17 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index 19a04eb..b2f9169 100644 --- a/README.md +++ b/README.md @@ -57,7 +57,7 @@ gemini: secure_1psid: "YOUR_SECURE_1PSID_HERE" secure_1psidts: "YOUR_SECURE_1PSIDTS_HERE" proxy: null # Optional proxy URL (null/empty keeps direct connection) - impersonate: null # Optional browser impersonation target (null uses default "firefox") + impersonate: null # Optional browser impersonation target (null uses library default) ``` > [!NOTE] @@ -223,12 +223,15 @@ Each client entry can be configured with a different proxy to work around rate l ### Browser Impersonation -Each client can optionally set an `impersonate` value to control the TLS/HTTP fingerprint used by `curl_cffi`. This is useful when Google blocks requests from a specific browser profile. +Each client can optionally set an `impersonate` value to control the TLS/HTTP fingerprint used by `curl_cffi`. -- Set to `null` (default) to use the library's default (`"firefox"`, which maps to the latest Firefox version). -- Set to any value supported by [`curl_cffi`'s `BrowserTypeLiteral`](https://github.com/lexiforest/curl_cffi), for example: `safari`, `safari_ios`, `firefox`, etc. +- Set to `null` (default) to use the library's default. +- Set to any value supported by [`curl_cffi`'s `BrowserTypeLiteral`](https://github.com/lexiforest/curl_cffi). - The value is validated at startup; an invalid value will prevent the server from starting. +> [!IMPORTANT] +> Currently, avoid using chrome based as it now applies device-bound session cookies. + ```yaml gemini: clients: diff --git a/README.zh.md b/README.zh.md index 14077a7..2216cb0 100644 --- a/README.zh.md +++ b/README.zh.md @@ -57,7 +57,7 @@ gemini: secure_1psid: "YOUR_SECURE_1PSID_HERE" secure_1psidts: "YOUR_SECURE_1PSIDTS_HERE" proxy: null # 可选代理 URL (null/空值则保持直连) - impersonate: null # 可选浏览器指纹模拟 (null 则使用默认值 "firefox") + impersonate: null # 可选浏览器指纹模拟 (null 则使用库的默认值) ``` > [!NOTE] @@ -219,12 +219,15 @@ export CONFIG_STORAGE__MAX_SIZE=268435456 # 256 MB ### 浏览器指纹模拟 -每个客户端可以通过 `impersonate` 参数设置 `curl_cffi` 使用的 TLS/HTTP 指纹。当 Google 屏蔽某种浏览器指纹时,切换为其他浏览器会有帮助。 +每个客户端可以通过 `impersonate` 参数设置 `curl_cffi` 使用的 TLS/HTTP 指纹。 -- 设置为 `null`(默认)则使用库的默认值(`"firefox"`,即最新 Firefox 版本)。 -- 可设为 [`curl_cffi` 的 `BrowserTypeLiteral`](https://github.com/lexiforest/curl_cffi) 支持的任意值,例如:`safari`、`safari_ios`、`firefox` 等。 +- 设置为 `null`(默认)则使用库的默认值。 +- 可设为 [`curl_cffi` 的 `BrowserTypeLiteral`](https://github.com/lexiforest/curl_cffi) 支持的任意值。 - 启动时会校验该值;无效值会阻止服务启动。 +> [!IMPORTANT] +> 目前请避免使用基于 Chrome 的指纹,因为它现在会应用设备绑定会话 Cookie (Device-bound session cookies)。 + ```yaml gemini: clients: diff --git a/config/config.yaml b/config/config.yaml index 321073e..b90bdc6 100644 --- a/config/config.yaml +++ b/config/config.yaml @@ -22,7 +22,7 @@ gemini: secure_1psid: "YOUR_SECURE_1PSID_HERE" # Gemini Secure 1PSID secure_1psidts: "YOUR_SECURE_1PSIDTS_HERE" # Gemini Secure 1PSIDTS proxy: null # Optional proxy URL (null/empty means direct connection) - impersonate: null # Optional browser impersonation target (null uses library default "firefox") + impersonate: null # Optional browser impersonation target (null uses library default) timeout: 450 # Init timeout in seconds (Not less than 30s) watchdog_timeout: 120 # Watchdog timeout in seconds (Not less than 30s) auto_refresh: true # Auto-refresh session cookies diff --git a/uv.lock b/uv.lock index 539840c..a88a03c 100644 --- a/uv.lock +++ b/uv.lock @@ -176,8 +176,8 @@ dev = [{ name = "gemini-fastapi", extras = ["dev"] }] [[package]] name = "gemini-webapi" -version = "0.0.post275" -source = { git = "https://github.com/luuquangvu/Gemini-API.git?rev=enable-guest-mode#bf1394228aff8cc4740a458e2b2049154876f21c" } +version = "0.0.post277" +source = { git = "https://github.com/luuquangvu/Gemini-API.git?rev=enable-guest-mode#4bb4ee6b58ca962c61fb22d3df6ec4679ff026d9" } dependencies = [ { name = "curl-cffi" }, { name = "loguru" }, From 9d56d33c91667507237429b8ff1d9f4141cee4c4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C6=B0u=20Quang=20V=C5=A9?= Date: Tue, 28 Apr 2026 08:23:44 +0700 Subject: [PATCH 255/291] Workaround for new device-bound session mechanism --- README.md | 7 ++----- README.zh.md | 7 ++----- app/utils/config.py | 2 +- app/utils/helper.py | 2 +- uv.lock | 4 ++-- 5 files changed, 8 insertions(+), 14 deletions(-) diff --git a/README.md b/README.md index b2f9169..12b5bd9 100644 --- a/README.md +++ b/README.md @@ -182,7 +182,7 @@ export CONFIG_GEMINI__CLIENTS__0__SECURE_1PSIDTS="your-secure-1psidts" export CONFIG_GEMINI__CLIENTS__0__PROXY="socks5://127.0.0.1:1080" # Override browser impersonation for client 0 -export CONFIG_GEMINI__CLIENTS__0__IMPERSONATE="firefox" +export CONFIG_GEMINI__CLIENTS__0__IMPERSONATE="chrome" # Override conversation storage size limit @@ -229,14 +229,11 @@ Each client can optionally set an `impersonate` value to control the TLS/HTTP fi - Set to any value supported by [`curl_cffi`'s `BrowserTypeLiteral`](https://github.com/lexiforest/curl_cffi). - The value is validated at startup; an invalid value will prevent the server from starting. -> [!IMPORTANT] -> Currently, avoid using chrome based as it now applies device-bound session cookies. - ```yaml gemini: clients: - id: "client-a" - impersonate: "firefox" # Use Firefox fingerprint + impersonate: "chrome" # Use Chrome fingerprint - id: "client-b" impersonate: null # Use library default ``` diff --git a/README.zh.md b/README.zh.md index 2216cb0..ef09562 100644 --- a/README.zh.md +++ b/README.zh.md @@ -182,7 +182,7 @@ export CONFIG_GEMINI__CLIENTS__0__SECURE_1PSIDTS="your-secure-1psidts" export CONFIG_GEMINI__CLIENTS__0__PROXY="socks5://127.0.0.1:1080" # 覆盖 Client 0 的浏览器指纹模拟 -export CONFIG_GEMINI__CLIENTS__0__IMPERSONATE="firefox" +export CONFIG_GEMINI__CLIENTS__0__IMPERSONATE="chrome" # 覆盖对话存储大小限制 @@ -225,14 +225,11 @@ export CONFIG_STORAGE__MAX_SIZE=268435456 # 256 MB - 可设为 [`curl_cffi` 的 `BrowserTypeLiteral`](https://github.com/lexiforest/curl_cffi) 支持的任意值。 - 启动时会校验该值;无效值会阻止服务启动。 -> [!IMPORTANT] -> 目前请避免使用基于 Chrome 的指纹,因为它现在会应用设备绑定会话 Cookie (Device-bound session cookies)。 - ```yaml gemini: clients: - id: "client-a" - impersonate: "firefox" # 使用 Firefox 指纹 + impersonate: "chrome" # 使用 Chrome 指纹 - id: "client-b" impersonate: null # 使用库默认值 ``` diff --git a/app/utils/config.py b/app/utils/config.py index 6e0f4f6..712fa40 100644 --- a/app/utils/config.py +++ b/app/utils/config.py @@ -45,7 +45,7 @@ class GeminiClientSettings(BaseModel): proxy: str | None = Field(default=None, description="Proxy URL for this Gemini client") impersonate: str | None = Field( default=None, - description="Browser impersonation target for curl_cffi (e.g. 'firefox'). None uses library default", + description="Browser impersonation target for curl_cffi. None uses library default", ) @field_validator("proxy", "impersonate", mode="before") diff --git a/app/utils/helper.py b/app/utils/helper.py index de32e76..ea4d18f 100644 --- a/app/utils/helper.py +++ b/app/utils/helper.py @@ -192,7 +192,7 @@ async def save_url_to_tempfile(url: str, tempdir: Path | None = None) -> Path: ) else: async with requests.AsyncSession( - impersonate="firefox", allow_redirects=CurlFollow.SAFE, http_version=CurlHttpVersion.V3 + impersonate="chrome", allow_redirects=CurlFollow.SAFE, http_version=CurlHttpVersion.V3 ) as client: resp = await client.get(url) resp.raise_for_status() diff --git a/uv.lock b/uv.lock index a88a03c..1cfe468 100644 --- a/uv.lock +++ b/uv.lock @@ -176,8 +176,8 @@ dev = [{ name = "gemini-fastapi", extras = ["dev"] }] [[package]] name = "gemini-webapi" -version = "0.0.post277" -source = { git = "https://github.com/luuquangvu/Gemini-API.git?rev=enable-guest-mode#4bb4ee6b58ca962c61fb22d3df6ec4679ff026d9" } +version = "0.0.post278" +source = { git = "https://github.com/luuquangvu/Gemini-API.git?rev=enable-guest-mode#aadd9958e3823d1d84be9cd536b18cd670ca6ed1" } dependencies = [ { name = "curl-cffi" }, { name = "loguru" }, From a2f1ebdc408afd213ce6c0b052c8a03125b8af98 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C6=B0u=20Quang=20V=C5=A9?= Date: Thu, 7 May 2026 13:16:08 +0700 Subject: [PATCH 256/291] Stop background tasks when the account status is not available --- app/services/client.py | 8 +++- pyproject.toml | 2 +- uv.lock | 86 +++++++++++++++++++++--------------------- 3 files changed, 50 insertions(+), 46 deletions(-) diff --git a/app/services/client.py b/app/services/client.py index 6fe259c..9984c46 100644 --- a/app/services/client.py +++ b/app/services/client.py @@ -4,6 +4,7 @@ import orjson from gemini_webapi import GeminiClient +from gemini_webapi.constants import AccountStatus from loguru import logger from app.models import AppMessage @@ -54,9 +55,12 @@ def running(self) -> bool: def is_healthy(self) -> bool: """ Check if the client is healthy. - A client is healthy if it is running, or if auto_close is enabled and it has initialized successfully. + + A client is healthy if it is active (running or initialized with auto-close) + and the account status is available. """ - return self._running or (self.auto_close and self._initialized) + is_active = self._running or (self.auto_close and self._initialized) + return is_active and self.account_status == AccountStatus.AVAILABLE @staticmethod async def _process_content_item( diff --git a/pyproject.toml b/pyproject.toml index d64b9ba..90c7396 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -11,7 +11,7 @@ dependencies = [ "httptools>=0.7.1", "lmdb>=2.2.0", "loguru>=0.7.3", - "orjson>=3.11.8", + "orjson>=3.11.9", "pydantic-settings[yaml]>=2.14.0", "uvicorn>=0.46.0", "uvloop>=0.22.1; sys_platform != 'win32'", diff --git a/uv.lock b/uv.lock index 1cfe468..06bbdf7 100644 --- a/uv.lock +++ b/uv.lock @@ -162,7 +162,7 @@ requires-dist = [ { name = "httptools", specifier = ">=0.7.1" }, { name = "lmdb", specifier = ">=2.2.0" }, { name = "loguru", specifier = ">=0.7.3" }, - { name = "orjson", specifier = ">=3.11.8" }, + { name = "orjson", specifier = ">=3.11.9" }, { name = "pydantic-settings", extras = ["yaml"], specifier = ">=2.14.0" }, { name = "ruff", marker = "extra == 'dev'" }, { name = "ty", marker = "extra == 'dev'" }, @@ -176,8 +176,8 @@ dev = [{ name = "gemini-fastapi", extras = ["dev"] }] [[package]] name = "gemini-webapi" -version = "0.0.post278" -source = { git = "https://github.com/luuquangvu/Gemini-API.git?rev=enable-guest-mode#aadd9958e3823d1d84be9cd536b18cd670ca6ed1" } +version = "0.0.post279" +source = { git = "https://github.com/luuquangvu/Gemini-API.git?rev=enable-guest-mode#d3d3e576d0a2ed9d328c3cad56ece134e9e93633" } dependencies = [ { name = "curl-cffi" }, { name = "loguru" }, @@ -247,14 +247,14 @@ wheels = [ [[package]] name = "markdown-it-py" -version = "4.0.0" +version = "4.1.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "mdurl" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/5b/f5/4ec618ed16cc4f8fb3b701563655a69816155e79e24a17b651541804721d/markdown_it_py-4.0.0.tar.gz", hash = "sha256:cb0a2b4aa34f932c007117b194e945bd74e0ec24133ceb5bac59009cda1cb9f3", size = 73070, upload-time = "2025-08-11T12:57:52.854Z" } +sdist = { url = "https://files.pythonhosted.org/packages/5c/5c/f3aedc83549aae71cd52b9e9687fe896e3dc6e966ba20eba04718605d198/markdown_it_py-4.1.0.tar.gz", hash = "sha256:760e3f87b2787c044c5138a5ba107b7c2be26c03b13cc7f8fe42756b65b1df6c", size = 81613, upload-time = "2026-05-06T16:32:13.649Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/94/54/e7d793b573f298e1c9013b8c4dade17d481164aa517d1d7148619c2cedbf/markdown_it_py-4.0.0-py3-none-any.whl", hash = "sha256:87327c59b172c5011896038353a81343b6754500a08cd7a4973bb48c6d578147", size = 87321, upload-time = "2025-08-11T12:57:51.923Z" }, + { url = "https://files.pythonhosted.org/packages/a8/88/802c82060c54bc7dde21eb0033e337838b8181a1323254aa9ec41cbfc3d1/markdown_it_py-4.1.0-py3-none-any.whl", hash = "sha256:d4939a62a2dd0cd9cb80a191a711ba1d39bac8ed5ef9e9966895b0171c01c46d", size = 90955, upload-time = "2026-05-06T16:32:12.184Z" }, ] [[package]] @@ -268,25 +268,25 @@ wheels = [ [[package]] name = "orjson" -version = "3.11.8" +version = "3.11.9" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/9d/1b/2024d06792d0779f9dbc51531b61c24f76c75b9f4ce05e6f3377a1814cea/orjson-3.11.8.tar.gz", hash = "sha256:96163d9cdc5a202703e9ad1b9ae757d5f0ca62f4fa0cc93d1f27b0e180cc404e", size = 5603832, upload-time = "2026-03-31T16:16:27.878Z" } +sdist = { url = "https://files.pythonhosted.org/packages/7e/0c/964746fcafbd16f8ff53219ad9f6b412b34f345c75f384ad434ceaadb538/orjson-3.11.9.tar.gz", hash = "sha256:4fef17e1f8722c11587a6ef18e35902450221da0028e65dbaaa543619e68e48f", size = 5599163, upload-time = "2026-05-06T15:11:08.309Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/66/7f/95fba509bb2305fab0073558f1e8c3a2ec4b2afe58ed9fcb7d3b8beafe94/orjson-3.11.8-cp313-cp313-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:3f23426851d98478c8970da5991f84784a76682213cd50eb73a1da56b95239dc", size = 229180, upload-time = "2026-03-31T16:15:36.426Z" }, - { url = "https://files.pythonhosted.org/packages/f6/9d/b237215c743ca073697d759b5503abd2cb8a0d7b9c9e21f524bcf176ab66/orjson-3.11.8-cp313-cp313-macosx_15_0_arm64.whl", hash = "sha256:ebaed4cef74a045b83e23537b52ef19a367c7e3f536751e355a2a394f8648559", size = 128754, upload-time = "2026-03-31T16:15:38.049Z" }, - { url = "https://files.pythonhosted.org/packages/42/3d/27d65b6d11e63f133781425f132807aef793ed25075fec686fc8e46dd528/orjson-3.11.8-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:97c8f5d3b62380b70c36ffacb2a356b7c6becec86099b177f73851ba095ef623", size = 131877, upload-time = "2026-03-31T16:15:39.484Z" }, - { url = "https://files.pythonhosted.org/packages/dd/cc/faee30cd8f00421999e40ef0eba7332e3a625ce91a58200a2f52c7fef235/orjson-3.11.8-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:436c4922968a619fb7fef1ccd4b8b3a76c13b67d607073914d675026e911a65c", size = 130361, upload-time = "2026-03-31T16:15:41.274Z" }, - { url = "https://files.pythonhosted.org/packages/5c/bb/a6c55896197f97b6d4b4e7c7fd77e7235517c34f5d6ad5aadd43c54c6d7c/orjson-3.11.8-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1ab359aff0436d80bfe8a23b46b5fea69f1e18aaf1760a709b4787f1318b317f", size = 135521, upload-time = "2026-03-31T16:15:42.758Z" }, - { url = "https://files.pythonhosted.org/packages/9c/7c/ca3a3525aa32ff636ebb1778e77e3587b016ab2edb1b618b36ba96f8f2c0/orjson-3.11.8-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f89b6d0b3a8d81e1929d3ab3d92bbc225688bd80a770c49432543928fe09ac55", size = 146862, upload-time = "2026-03-31T16:15:44.341Z" }, - { url = "https://files.pythonhosted.org/packages/3c/0c/18a9d7f18b5edd37344d1fd5be17e94dc652c67826ab749c6e5948a78112/orjson-3.11.8-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:29c009e7a2ca9ad0ed1376ce20dd692146a5d9fe4310848904b6b4fee5c5c137", size = 132847, upload-time = "2026-03-31T16:15:46.368Z" }, - { url = "https://files.pythonhosted.org/packages/23/91/7e722f352ad67ca573cee44de2a58fb810d0f4eb4e33276c6a557979fd8a/orjson-3.11.8-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:705b895b781b3e395c067129d8551655642dfe9437273211d5404e87ac752b53", size = 133637, upload-time = "2026-03-31T16:15:48.123Z" }, - { url = "https://files.pythonhosted.org/packages/af/04/32845ce13ac5bd1046ddb02ac9432ba856cc35f6d74dde95864fe0ad5523/orjson-3.11.8-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:88006eda83858a9fdf73985ce3804e885c2befb2f506c9a3723cdeb5a2880e3e", size = 141906, upload-time = "2026-03-31T16:15:49.626Z" }, - { url = "https://files.pythonhosted.org/packages/02/5e/c551387ddf2d7106d9039369862245c85738b828844d13b99ccb8d61fd06/orjson-3.11.8-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:55120759e61309af7fcf9e961c6f6af3dde5921cdb3ee863ef63fd9db126cae6", size = 423722, upload-time = "2026-03-31T16:15:51.176Z" }, - { url = "https://files.pythonhosted.org/packages/00/a3/ecfe62434096f8a794d4976728cb59bcfc4a643977f21c2040545d37eb4c/orjson-3.11.8-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:98bdc6cb889d19bed01de46e67574a2eab61f5cc6b768ed50e8ac68e9d6ffab6", size = 147801, upload-time = "2026-03-31T16:15:52.939Z" }, - { url = "https://files.pythonhosted.org/packages/18/6d/0dce10b9f6643fdc59d99333871a38fa5a769d8e2fc34a18e5d2bfdee900/orjson-3.11.8-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:708c95f925a43ab9f34625e45dcdadf09ec8a6e7b664a938f2f8d5650f6c090b", size = 136460, upload-time = "2026-03-31T16:15:54.431Z" }, - { url = "https://files.pythonhosted.org/packages/01/d6/6dde4f31842d87099238f1f07b459d24edc1a774d20687187443ab044191/orjson-3.11.8-cp313-cp313-win32.whl", hash = "sha256:01c4e5a6695dc09098f2e6468a251bc4671c50922d4d745aff1a0a33a0cf5b8d", size = 131956, upload-time = "2026-03-31T16:15:56.081Z" }, - { url = "https://files.pythonhosted.org/packages/c1/f9/4e494a56e013db957fb77186b818b916d4695b8fa2aa612364974160e91b/orjson-3.11.8-cp313-cp313-win_amd64.whl", hash = "sha256:c154a35dd1330707450bb4d4e7dd1f17fa6f42267a40c1e8a1daa5e13719b4b8", size = 127410, upload-time = "2026-03-31T16:15:57.54Z" }, - { url = "https://files.pythonhosted.org/packages/57/7f/803203d00d6edb6e9e7eef421d4e1adbb5ea973e40b3533f3cfd9aeb374e/orjson-3.11.8-cp313-cp313-win_arm64.whl", hash = "sha256:4861bde57f4d253ab041e374f44023460e60e71efaa121f3c5f0ed457c3a701e", size = 127338, upload-time = "2026-03-31T16:15:59.106Z" }, + { url = "https://files.pythonhosted.org/packages/32/33/93fcc25907235c344ae73122f8a4e01d2d393ef062b4af7d2e2487a32c37/orjson-3.11.9-cp313-cp313-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:4bab1b2d6141fe7b32ae71dac905666ece4f94936efbfb13d55bb7739a3a6021", size = 228458, upload-time = "2026-05-06T15:10:20.079Z" }, + { url = "https://files.pythonhosted.org/packages/8f/27/b1e6dadb3c080313c03fdd8067b85e6a0460c7d8d6a1c3984ef77b904e4d/orjson-3.11.9-cp313-cp313-macosx_15_0_arm64.whl", hash = "sha256:844417969855fc7a41be124aafe83dc424592a7f77cd4501900c67307122b92c", size = 128368, upload-time = "2026-05-06T15:10:21.549Z" }, + { url = "https://files.pythonhosted.org/packages/21/0f/c9ede0bf052f6b4051e64a7d4fa91b725cccf8321a6a786e86eb03519f00/orjson-3.11.9-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ffe02797b5e9f3a9d8292ddcd289b474ad13e81ad83cd1891a240811f1d2cb81", size = 132070, upload-time = "2026-05-06T15:10:23.371Z" }, + { url = "https://files.pythonhosted.org/packages/fd/26/d398e28048dc18205bbe812f2c88cb9b40313db2470778e25964796458fe/orjson-3.11.9-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:0e4eed3b200023042814d2fc8a5d2e880f13b52e1ed2485e83da4f3962f7dc1a", size = 127892, upload-time = "2026-05-06T15:10:24.714Z" }, + { url = "https://files.pythonhosted.org/packages/66/60/52b0054c4c700d5aa7fc5b7ca96917400d8f061307778578e67a10e25852/orjson-3.11.9-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8aff7da9952a5ad1cef8e68017724d96c7b9a66e99e91d6252e1b133d67a7b10", size = 135217, upload-time = "2026-05-06T15:10:26.084Z" }, + { url = "https://files.pythonhosted.org/packages/d5/97/1e3dc2b2a28b7b2528f403d2fc1d79ec5f39af3bc143ab65d3ec26426385/orjson-3.11.9-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4d4e98d6f3b8afed8bc8cd9718ec0cdf46661826beefb53fe8eafb37f2bf0362", size = 145980, upload-time = "2026-05-06T15:10:28.062Z" }, + { url = "https://files.pythonhosted.org/packages/fc/39/31fbfe7850f2de32dee7e7e5c09f26d403ab01e440ac96001c6b01ad3c99/orjson-3.11.9-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3a81d52442a7c99b3662333235b3adf96a1715864658b35bb797212be7bddb97", size = 132738, upload-time = "2026-05-06T15:10:29.727Z" }, + { url = "https://files.pythonhosted.org/packages/a1/08/dca0082dd2a194acb93e5457e73455388e2e2ca464a2672449a9ddbb679d/orjson-3.11.9-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4e39364e726a8fff737309aff059ff67d8a8c8d5b677be7bb49a8b3e84b7e218", size = 134033, upload-time = "2026-05-06T15:10:31.152Z" }, + { url = "https://files.pythonhosted.org/packages/11/d4/5bdb0626801230139987385554c5d4c42255218ac906525bf4347f22cd95/orjson-3.11.9-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4fd66214623f1b17501df9f0543bef0b833979ab5b6ded1e1d123222866aa8c9", size = 141492, upload-time = "2026-05-06T15:10:32.641Z" }, + { url = "https://files.pythonhosted.org/packages/fa/88/a21fb53b3ede6703aede6dce4710ed4111e5b201cfa6bbff5e544f9d47d7/orjson-3.11.9-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:8ecc30f10465fa1e0ce13fd01d9e22c316e5053a719a8d915d4545a09a5ff677", size = 415087, upload-time = "2026-05-06T15:10:34.438Z" }, + { url = "https://files.pythonhosted.org/packages/3d/57/1b30daf70f0d8180e9a73cefbfbdd99e4bf19eb020466502b01fba7e0e50/orjson-3.11.9-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:97db4c94a7db398a5bd636273324f0b3fd58b350bbbac8bb380ceb825a9b40f4", size = 148031, upload-time = "2026-05-06T15:10:36.358Z" }, + { url = "https://files.pythonhosted.org/packages/04/83/45fbb6d962e260807f99441db9613cee868ceda4baceda59b3720a563f97/orjson-3.11.9-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9f78cf8fec5bd627f4082b8dfeac7871b43d7f3274904492a43dab39f18a19a0", size = 136915, upload-time = "2026-05-06T15:10:38.013Z" }, + { url = "https://files.pythonhosted.org/packages/5f/cc/2d10025f9056d376e4127ec05a5808b218d46f035fdc08178a5411b34250/orjson-3.11.9-cp313-cp313-win32.whl", hash = "sha256:d4087e5c0209a0a8efe4de3303c234b9c44d1174161dcd851e8eea07c7560b32", size = 131613, upload-time = "2026-05-06T15:10:39.569Z" }, + { url = "https://files.pythonhosted.org/packages/67/bd/2775ff28bfe883b9aa1ff348300542eb2ef1ee18d8ae0e3a49846817a865/orjson-3.11.9-cp313-cp313-win_amd64.whl", hash = "sha256:051b102c93b4f634e89f3866b07b9a9a98915ada541f4ec30f177067b2694979", size = 127086, upload-time = "2026-05-06T15:10:41.262Z" }, + { url = "https://files.pythonhosted.org/packages/91/2b/d26799e580939e32a7da9a39531bc9e58e15ca32ffaa6a8cb3e9bb0d22cd/orjson-3.11.9-cp313-cp313-win_arm64.whl", hash = "sha256:cce9127885941bd28f080cecf1f1d288336b7e0d812c345b08be88b572796254", size = 126696, upload-time = "2026-05-06T15:10:42.651Z" }, ] [[package]] @@ -445,26 +445,26 @@ wheels = [ [[package]] name = "ty" -version = "0.0.32" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/85/7e/2aa791c9ae7b8cd5024cd4122e92267f664ca954cea3def3211919fa3c1f/ty-0.0.32.tar.gz", hash = "sha256:8743174c5f920f6700a4a0c9de140109189192ba16226884cd50095b43b8a45c", size = 5522294, upload-time = "2026-04-20T19:29:01.626Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/62/eb/1075dc6a49d7acbe2584ae4d5b410c41b1f177a5adcc567e09eca4c69000/ty-0.0.32-py3-none-linux_armv6l.whl", hash = "sha256:dacbc2f6cd698d488ae7436838ff929570455bf94bfa4d9fe57a630c552aff83", size = 10902959, upload-time = "2026-04-20T19:28:31.907Z" }, - { url = "https://files.pythonhosted.org/packages/33/d2/c35fc8bc66e98d1ee9b0f8ed319bf743e450e1f1e997574b178fab75670f/ty-0.0.32-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:914bbc4f605ce2a9e2a78982e28fae1d3359a169d141f9dc3b4c7749cd5eca81", size = 10726172, upload-time = "2026-04-20T19:28:44.765Z" }, - { url = "https://files.pythonhosted.org/packages/96/32/c827da3ca480456fb02d8cea68a2609273b6c220fea0be9a4c8d8470b86e/ty-0.0.32-py3-none-macosx_11_0_arm64.whl", hash = "sha256:4787ac9fe1f86b1f3133f5c6732adbe2df5668b50c679ac6e2d98cd284da812f", size = 10163701, upload-time = "2026-04-20T19:28:27.005Z" }, - { url = "https://files.pythonhosted.org/packages/ba/9e/2734478fbdb90c160cb2813a3916a16a2af5c1e231f87d635f6131d781fb/ty-0.0.32-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d8ea0a728af99fe40dd744cba6441a2404f80b7f4bde17aa6da393810af5ea57", size = 10656220, upload-time = "2026-04-20T19:29:03.814Z" }, - { url = "https://files.pythonhosted.org/packages/44/9f/0007da2d35e424debe7e9f86ffbc1ab7f60983cfbc5f0411324ab2de5292/ty-0.0.32-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2850561f9b018ae33d7e5bbfa0ac414d3c518513edcffe43877dc9801446b9c5", size = 10696086, upload-time = "2026-04-20T19:28:46.829Z" }, - { url = "https://files.pythonhosted.org/packages/3b/5e/ce5fd4ec803222ae3e69a76d2a2db2eed55e19f5b131702b9789ef45f93d/ty-0.0.32-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b5fa2fb3c614349ee211d36476b49d88c5ef79a687cdb91b2872ad023b94d2f8", size = 11184800, upload-time = "2026-04-20T19:28:42.57Z" }, - { url = "https://files.pythonhosted.org/packages/6c/46/ebcf67a5999421331214aac51a7464db42de2be15bbe929c612a3ed0b039/ty-0.0.32-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2b89969307ab2417d41c9be8059dd79feea577234e1e10d35132f5495e0d42c6", size = 11718718, upload-time = "2026-04-20T19:28:36.433Z" }, - { url = "https://files.pythonhosted.org/packages/18/2c/2141c86ed0ce0962b45cefb658a95e734f59759d47f20afdcd9c732910a1/ty-0.0.32-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9b59868ede9b1d69a088f0d695df52a0061f95fa7baa1d5e0dc6fc9cf06e1334", size = 11346369, upload-time = "2026-04-20T19:28:48.967Z" }, - { url = "https://files.pythonhosted.org/packages/7a/da/ed6f772339cf29bd9a46def9d6db5084689eb574ee4d150ff704224c1ed8/ty-0.0.32-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8300caf35345498e9b9b03e550bba03cee8f5f5f8ab4c83c3b1ff1b7403b7d3a", size = 11280714, upload-time = "2026-04-20T19:28:51.516Z" }, - { url = "https://files.pythonhosted.org/packages/da/9b/c6813987edf4816a40e0c8e408b555f97d3f267c7b3a1688c8bbdf65609c/ty-0.0.32-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:583c7094f4574b02f724db924f98b804d1387a0bd9405ecb5e078cc0f47fbcfb", size = 10638806, upload-time = "2026-04-20T19:28:29.651Z" }, - { url = "https://files.pythonhosted.org/packages/4e/d4/0cefcbd2ad0f3d51762ccf58e652ec7da146eb6ae34f87228f6254bbb8be/ty-0.0.32-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:e44ebe1bb4143a5628bc4db67ac0dfebe14594af671e4ee66f6f2e983da56501", size = 10726106, upload-time = "2026-04-20T19:29:06.3Z" }, - { url = "https://files.pythonhosted.org/packages/32/ad/2c8a97f91f06311f4367400f7d13534bbda2522c73c99a3e4c0757dff9b8/ty-0.0.32-py3-none-musllinux_1_2_i686.whl", hash = "sha256:06f17ada3e069cba6148342ef88e9929156beca8473e8d4f101b68f66c75643e", size = 10872951, upload-time = "2026-04-20T19:28:34.077Z" }, - { url = "https://files.pythonhosted.org/packages/ba/68/42293f9248106dd51875120971a5cc6ea315c2c4dcfb8e59aa063aa0af26/ty-0.0.32-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:e96e60fa556cec04f15d7ea62d2ceee5982bd389233e961ab9fd42304e278175", size = 11363334, upload-time = "2026-04-20T19:28:54.036Z" }, - { url = "https://files.pythonhosted.org/packages/df/92/be9abf4d3e589ad5023e2ea965b93e204ec856420d46adf73c5c36c04678/ty-0.0.32-py3-none-win32.whl", hash = "sha256:2ff2ebb4986b24aebcf1444db7db5ca41b36086040e95eea9f8fb851c11e805c", size = 10260689, upload-time = "2026-04-20T19:28:56.541Z" }, - { url = "https://files.pythonhosted.org/packages/14/61/dc86acea899349d2579cb8419aecedd83dc504d7d6a10df65eef546c8300/ty-0.0.32-py3-none-win_amd64.whl", hash = "sha256:ba7284a4a954b598c1b31500352b3ec1f89bff533825592b5958848226fdc7ee", size = 11255371, upload-time = "2026-04-20T19:28:39.917Z" }, - { url = "https://files.pythonhosted.org/packages/43/01/beffec56d71ca25b343ede63adb076456b5b3e211f1c066452a44cd120b3/ty-0.0.32-py3-none-win_arm64.whl", hash = "sha256:7e10aadbdbda989a7d567ee6a37f8b98d4d542e31e3b190a2879fd581f75d658", size = 10658087, upload-time = "2026-04-20T19:28:59.286Z" }, +version = "0.0.34" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c4/69/e24eefe2c35c0fdbdec9b60e162727af669bb76d64d993d982eb67b24c38/ty-0.0.34.tar.gz", hash = "sha256:a6efe66b0f13c03a65e6c72ec9abfe2792e2fd063c74fa67e2c4930e29d661be", size = 5585933, upload-time = "2026-05-01T23:06:46.388Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/83/7b/8b85003d6639ef17a97dcbb31f4511cfe78f1c81a964470db100c8c883e7/ty-0.0.34-py3-none-linux_armv6l.whl", hash = "sha256:9ecc3d14f07a95a6ceb88e07f8e62358dbd37325d3d5bd56da7217ff1fef7fb8", size = 11067094, upload-time = "2026-05-01T23:06:21.133Z" }, + { url = "https://files.pythonhosted.org/packages/d7/25/b0098f65b020b015c40567c763fc66fffbec88b2ba6f584bca1e92f05ebb/ty-0.0.34-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:0dccffd8a9d02321cd2dee3249df205e26d62694e741f4eeca36b157fd8b419f", size = 10840909, upload-time = "2026-05-01T23:06:18.409Z" }, + { url = "https://files.pythonhosted.org/packages/e4/55/5e4adcf7d2a1006b844903b27cb81244a9b748d850433a46a6c21776c401/ty-0.0.34-py3-none-macosx_11_0_arm64.whl", hash = "sha256:b0ea47a2998e167ab3b21d2f4b5309a9cf33c297809f6d7e3e753252223174d0", size = 10279378, upload-time = "2026-05-01T23:06:37.962Z" }, + { url = "https://files.pythonhosted.org/packages/4d/91/f537dca0db8fe2558e8ab04d8941d687b384fcc1df5eb9023b2db75ac26c/ty-0.0.34-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8b37da00b41a118a459ae56d8947e70651073fb33ebfbceb820e4a10b22d5023", size = 10817423, upload-time = "2026-05-01T23:06:26.247Z" }, + { url = "https://files.pythonhosted.org/packages/2c/c4/55a3ad1da2815af1009bdc1b8c90dc11a364cd314e4b48c5128ba9d38859/ty-0.0.34-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:81cbbb93c2342fe3de43e625d3a9eb149633e9f485e816ebf6395d08685355d8", size = 10851826, upload-time = "2026-05-01T23:06:24.198Z" }, + { url = "https://files.pythonhosted.org/packages/ce/8c/9c7606af22d73fb43ea4369472d9c66ece11231be73b0efe8e3c61655559/ty-0.0.34-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4c5b4dea1594a021289e172582df9cde7089dce14b276fc650e7b212b1772e12", size = 11356318, upload-time = "2026-05-01T23:06:51.139Z" }, + { url = "https://files.pythonhosted.org/packages/20/54/bb423f663721ab4138b216425c6b55eaefd3a068243b24d6d8fe988f4e13/ty-0.0.34-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:030fb00aa2d2a5b5ae9d9183d574e0c82dae80566700a7490c43669d8ece40cd", size = 11902968, upload-time = "2026-05-01T23:06:35.82Z" }, + { url = "https://files.pythonhosted.org/packages/b6/22/01122b21ab6b534a2f618c6bbe5f1f7f49fd56f4b2ec8887cd6d40d08fb3/ty-0.0.34-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5ae9555e24e36c63a8218e037a5a63f15579eb6aa94f41017e57cd41d335cfb5", size = 11548860, upload-time = "2026-05-01T23:06:42.155Z" }, + { url = "https://files.pythonhosted.org/packages/d1/50/86008b1392ec64bed1957bbcc7aaa43b466b50dfc91bb131841c21d7c5c3/ty-0.0.34-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:99eb23df9ed129fc26d1ab00d6f0b8dfe5253b09c2ac6abdb11523fa70d67f10", size = 11457097, upload-time = "2026-05-01T23:06:53.477Z" }, + { url = "https://files.pythonhosted.org/packages/92/3e/4558b2296963ba99c58d8409c57d7db4f3061b656c3613cb21c02c1ef4c2/ty-0.0.34-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:85de45382016eceae69e104815eb2cfa200787df104002e262a86cbd43ed2c02", size = 10798192, upload-time = "2026-05-01T23:06:40.004Z" }, + { url = "https://files.pythonhosted.org/packages/76/bf/650d24402be2ef678528d60caac1d9477a40fc37e3792ecef07834fd7a4a/ty-0.0.34-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:14cb575fb8fa5131f5129d100cfe23c1575d23faf5dfc5158432749a3e38c9b5", size = 10890390, upload-time = "2026-05-01T23:06:33.076Z" }, + { url = "https://files.pythonhosted.org/packages/5c/ef/ccd2ca13906079f7935fd7e067661b24233017f57d987d51d6a121d85bb5/ty-0.0.34-py3-none-musllinux_1_2_i686.whl", hash = "sha256:c6fc0b69d8450e6910ba9db34572b959b81329a97ae273c391f70e9fb6c1aade", size = 11031564, upload-time = "2026-05-01T23:06:55.812Z" }, + { url = "https://files.pythonhosted.org/packages/ba/2d/d27b72005b6f43599e3bcabab0d7135ac0c230b7a307bb99f9eea02c1cda/ty-0.0.34-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:30dfcec2f0fde3993f4f912ed0e057dcbebc8615299f610a4c2ddb7b5a3e1e06", size = 11553430, upload-time = "2026-05-01T23:06:31.096Z" }, + { url = "https://files.pythonhosted.org/packages/a7/12/20812e1ad930b8d4af70eebf19ad23cff6e31efcfa613ef884531fcdbaa1/ty-0.0.34-py3-none-win32.whl", hash = "sha256:97b77ddf007271b812a313a8f0a14929bc5590958433e1fb83ef585676f53342", size = 10436048, upload-time = "2026-05-01T23:06:49.108Z" }, + { url = "https://files.pythonhosted.org/packages/b0/6a/afa095c5987868fbda27c0f731146ac8e3d07b357adfa83daccaee5b1a16/ty-0.0.34-py3-none-win_amd64.whl", hash = "sha256:1f543968accb952705134028d1fda8656882787dbbc667ad4d6c3ba23791d604", size = 11462526, upload-time = "2026-05-01T23:06:28.514Z" }, + { url = "https://files.pythonhosted.org/packages/63/8f/bf041a06260d77662c0605e56dacfe90b786bf824cbe1aed238d15fe5e84/ty-0.0.34-py3-none-win_arm64.whl", hash = "sha256:ea09108cbcb16b6b06d7596312b433bf49681e78d30e4dc7fb3c1b248a95e09a", size = 10846945, upload-time = "2026-05-01T23:06:44.428Z" }, ] [[package]] From 539c398c3fb9a07ecd968d4b8b518546effb8f00 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C6=B0u=20Quang=20V=C5=A9?= Date: Thu, 21 May 2026 13:05:07 +0700 Subject: [PATCH 257/291] Add support for the new Flash Lite model --- pyproject.toml | 4 +- uv.lock | 127 +++++++++++++++++++++++++------------------------ 2 files changed, 66 insertions(+), 65 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 90c7396..2bc8a4e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -12,8 +12,8 @@ dependencies = [ "lmdb>=2.2.0", "loguru>=0.7.3", "orjson>=3.11.9", - "pydantic-settings[yaml]>=2.14.0", - "uvicorn>=0.46.0", + "pydantic-settings[yaml]>=2.14.1", + "uvicorn>=0.47.0", "uvloop>=0.22.1; sys_platform != 'win32'", ] diff --git a/uv.lock b/uv.lock index 06bbdf7..f36b15e 100644 --- a/uv.lock +++ b/uv.lock @@ -34,11 +34,11 @@ wheels = [ [[package]] name = "certifi" -version = "2026.4.22" +version = "2026.5.20" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/25/ee/6caf7a40c36a1220410afe15a1cc64993a1f864871f698c0f93acb72842a/certifi-2026.4.22.tar.gz", hash = "sha256:8d455352a37b71bf76a79caa83a3d6c25afee4a385d632127b6afb3963f1c580", size = 137077, upload-time = "2026-04-22T11:26:11.191Z" } +sdist = { url = "https://files.pythonhosted.org/packages/f3/ce/ee2ecad540810a79593028e88299baeae54d346cc7a0d94b6199988b89b1/certifi-2026.5.20.tar.gz", hash = "sha256:69dea482ab64caa7b9f6aba1c6bf48bb6a5448d1c0f1b17ab42ad8c763a5344d", size = 135422, upload-time = "2026-05-20T11:46:50.073Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/22/30/7cd8fdcdfbc5b869528b079bfb76dcdf6056b1a2097a662e5e8c04f42965/certifi-2026.4.22-py3-none-any.whl", hash = "sha256:3cb2210c8f88ba2318d29b0388d1023c8492ff72ecdde4ebdaddbb13a31b1c4a", size = 135707, upload-time = "2026-04-22T11:26:09.372Z" }, + { url = "https://files.pythonhosted.org/packages/59/8c/57e832b7af6d7c5abe66eb3fbe3a3a32f4d11ea23a1aa7131371035be991/certifi-2026.5.20-py3-none-any.whl", hash = "sha256:3c52e209ba0a4ad7aebe60436a4ab349c39e1e602e8c134221e546902ad25897", size = 134134, upload-time = "2026-05-20T11:46:48.578Z" }, ] [[package]] @@ -66,14 +66,14 @@ wheels = [ [[package]] name = "click" -version = "8.3.3" +version = "8.4.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "colorama", marker = "sys_platform == 'win32'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/bb/63/f9e1ea081ce35720d8b92acde70daaedace594dc93b693c869e0d5910718/click-8.3.3.tar.gz", hash = "sha256:398329ad4837b2ff7cbe1dd166a4c0f8900c3ca3a218de04466f38f6497f18a2", size = 328061, upload-time = "2026-04-22T15:11:27.506Z" } +sdist = { url = "https://files.pythonhosted.org/packages/23/e4/796662cd90cf80e3a363c99db2b88e0e394b988a575f60a17e16440cd011/click-8.4.0.tar.gz", hash = "sha256:638f1338fe1235c8f4e008e4a8a254fb5c5fbdcbb40ece3c9142ebb78e792973", size = 350843, upload-time = "2026-05-17T00:47:58.425Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ae/44/c1221527f6a71a01ec6fbad7fa78f1d50dfa02217385cf0fa3eec7087d59/click-8.3.3-py3-none-any.whl", hash = "sha256:a2bf429bb3033c89fa4936ffb35d5cb471e3719e1f3c8a7c3fff0b8314305613", size = 110502, upload-time = "2026-04-22T15:11:25.044Z" }, + { url = "https://files.pythonhosted.org/packages/ee/ae/8e92f8058baf87f6c7d86ee7e457668690195cc77efedb8d3797a06e3940/click-8.4.0-py3-none-any.whl", hash = "sha256:40c50b7c6c6adac2823d411041ec84f3f103f1b280d5e9ce0d7f998995832f81", size = 116147, upload-time = "2026-05-17T00:47:56.842Z" }, ] [[package]] @@ -163,10 +163,10 @@ requires-dist = [ { name = "lmdb", specifier = ">=2.2.0" }, { name = "loguru", specifier = ">=0.7.3" }, { name = "orjson", specifier = ">=3.11.9" }, - { name = "pydantic-settings", extras = ["yaml"], specifier = ">=2.14.0" }, + { name = "pydantic-settings", extras = ["yaml"], specifier = ">=2.14.1" }, { name = "ruff", marker = "extra == 'dev'" }, { name = "ty", marker = "extra == 'dev'" }, - { name = "uvicorn", specifier = ">=0.46.0" }, + { name = "uvicorn", specifier = ">=0.47.0" }, { name = "uvloop", marker = "sys_platform != 'win32'", specifier = ">=0.22.1" }, ] provides-extras = ["dev"] @@ -176,8 +176,8 @@ dev = [{ name = "gemini-fastapi", extras = ["dev"] }] [[package]] name = "gemini-webapi" -version = "0.0.post279" -source = { git = "https://github.com/luuquangvu/Gemini-API.git?rev=enable-guest-mode#d3d3e576d0a2ed9d328c3cad56ece134e9e93633" } +version = "0.0.post285" +source = { git = "https://github.com/luuquangvu/Gemini-API.git?rev=enable-guest-mode#2db51f41c7134dbe93afe58f2b52dfa8d11d7aeb" } dependencies = [ { name = "curl-cffi" }, { name = "loguru" }, @@ -211,11 +211,11 @@ wheels = [ [[package]] name = "idna" -version = "3.13" +version = "3.15" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/ce/cc/762dfb036166873f0059f3b7de4565e1b5bc3d6f28a414c13da27e442f99/idna-3.13.tar.gz", hash = "sha256:585ea8fe5d69b9181ec1afba340451fba6ba764af97026f92a91d4eef164a242", size = 194210, upload-time = "2026-04-22T16:42:42.314Z" } +sdist = { url = "https://files.pythonhosted.org/packages/82/77/7b3966d0b9d1d31a36ddf1746926a11dface89a83409bf1483f0237aa758/idna-3.15.tar.gz", hash = "sha256:ca962446ea538f7092a95e057da437618e886f4d349216d2b1e294abfdb65fdc", size = 199245, upload-time = "2026-05-12T22:45:57.011Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/5d/13/ad7d7ca3808a898b4612b6fe93cde56b53f3034dcde235acb1f0e1df24c6/idna-3.13-py3-none-any.whl", hash = "sha256:892ea0cde124a99ce773decba204c5552b69c3c67ffd5f232eb7696135bc8bb3", size = 68629, upload-time = "2026-04-22T16:42:40.909Z" }, + { url = "https://files.pythonhosted.org/packages/d2/23/408243171aa9aaba178d3e2559159c24c1171a641aa83b67bdd3394ead8e/idna-3.15-py3-none-any.whl", hash = "sha256:048adeaf8c2d788c40fee287673ccaa74c24ffd8dcf09ffa555a2fbb59f10ac8", size = 72340, upload-time = "2026-05-12T22:45:55.733Z" }, ] [[package]] @@ -247,14 +247,14 @@ wheels = [ [[package]] name = "markdown-it-py" -version = "4.1.0" +version = "4.2.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "mdurl" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/5c/5c/f3aedc83549aae71cd52b9e9687fe896e3dc6e966ba20eba04718605d198/markdown_it_py-4.1.0.tar.gz", hash = "sha256:760e3f87b2787c044c5138a5ba107b7c2be26c03b13cc7f8fe42756b65b1df6c", size = 81613, upload-time = "2026-05-06T16:32:13.649Z" } +sdist = { url = "https://files.pythonhosted.org/packages/06/ff/7841249c247aa650a76b9ee4bbaeae59370dc8bfd2f6c01f3630c35eb134/markdown_it_py-4.2.0.tar.gz", hash = "sha256:04a21681d6fbb623de53f6f364d352309d4094dd4194040a10fd51833e418d49", size = 82454, upload-time = "2026-05-07T12:08:28.36Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/a8/88/802c82060c54bc7dde21eb0033e337838b8181a1323254aa9ec41cbfc3d1/markdown_it_py-4.1.0-py3-none-any.whl", hash = "sha256:d4939a62a2dd0cd9cb80a191a711ba1d39bac8ed5ef9e9966895b0171c01c46d", size = 90955, upload-time = "2026-05-06T16:32:12.184Z" }, + { url = "https://files.pythonhosted.org/packages/b3/81/4da04ced5a082363ecfa159c010d200ecbd959ae410c10c0264a38cac0f5/markdown_it_py-4.2.0-py3-none-any.whl", hash = "sha256:9f7ebbcd14fe59494226453aed97c1070d83f8d24b6fc3a3bcf9a38092641c4a", size = 91687, upload-time = "2026-05-07T12:08:27.182Z" }, ] [[package]] @@ -340,16 +340,16 @@ wheels = [ [[package]] name = "pydantic-settings" -version = "2.14.0" +version = "2.14.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pydantic" }, { name = "python-dotenv" }, { name = "typing-inspection" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/42/98/c8345dccdc31de4228c039a98f6467a941e39558da41c1744fbe29fa5666/pydantic_settings-2.14.0.tar.gz", hash = "sha256:24285fd4b0e0c06507dd9fdfd331ee23794305352aaec8fc4eb92d4047aeb67d", size = 235709, upload-time = "2026-04-20T13:37:40.293Z" } +sdist = { url = "https://files.pythonhosted.org/packages/07/60/1d1e59c9c90d54591469ada7d268251f71c24bdb765f1a8a832cee8c6653/pydantic_settings-2.14.1.tar.gz", hash = "sha256:e874d3bec7e787b0c9958277956ed9b4dd5de6a80e162188fdaff7c5e26fd5fa", size = 235551, upload-time = "2026-05-08T13:40:06.542Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/01/dd/bebff3040138f00ae8a102d426b27349b9a49acc310fcae7f92112d867e3/pydantic_settings-2.14.0-py3-none-any.whl", hash = "sha256:fc8d5d692eb7092e43c8647c1c35a3ecd00e040fcf02ed86f4cb5458ca62182e", size = 60940, upload-time = "2026-04-20T13:37:38.586Z" }, + { url = "https://files.pythonhosted.org/packages/ae/8d/f1af3832f5e6eb13ba94ee809e72b8ecb5eef226d27ee0bef7d963d943c7/pydantic_settings-2.14.1-py3-none-any.whl", hash = "sha256:6e3c7edfd8277687cdc598f56e5cff0e9bfff0910a3749deaa8d4401c3a2b9de", size = 60964, upload-time = "2026-05-08T13:40:04.958Z" }, ] [package.optional-dependencies] @@ -408,27 +408,27 @@ wheels = [ [[package]] name = "ruff" -version = "0.15.12" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/99/43/3291f1cc9106f4c63bdce7a8d0df5047fe8422a75b091c16b5e9355e0b11/ruff-0.15.12.tar.gz", hash = "sha256:ecea26adb26b4232c0c2ca19ccbc0083a68344180bba2a600605538ce51a40a6", size = 4643852, upload-time = "2026-04-24T18:17:14.305Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/c3/6e/e78ffb61d4686f3d96ba3df2c801161843746dcbcbb17a1e927d4829312b/ruff-0.15.12-py3-none-linux_armv6l.whl", hash = "sha256:f86f176e188e94d6bdbc09f09bfd9dc729059ad93d0e7390b5a73efe19f8861c", size = 10640713, upload-time = "2026-04-24T18:17:22.841Z" }, - { url = "https://files.pythonhosted.org/packages/ae/08/a317bc231fb9e7b93e4ef3089501e51922ff88d6936ce5cf870c4fe55419/ruff-0.15.12-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:e3bcd123364c3770b8e1b7baaf343cc99a35f197c5c6e8af79015c666c423a6c", size = 11069267, upload-time = "2026-04-24T18:17:30.105Z" }, - { url = "https://files.pythonhosted.org/packages/aa/a4/f828e9718d3dce1f5f11c39c4f65afd32783c8b2aebb2e3d259e492c47bd/ruff-0.15.12-py3-none-macosx_11_0_arm64.whl", hash = "sha256:fe87510d000220aa1ed530d4448a7c696a0cae1213e5ec30e5874287b66557b5", size = 10397182, upload-time = "2026-04-24T18:17:07.177Z" }, - { url = "https://files.pythonhosted.org/packages/71/e0/3310fc6d1b5e1fdea22bf3b1b807c7e187b581021b0d7d4514cccdb5fb71/ruff-0.15.12-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:84a1630093121375a3e2a95b4a6dc7b59e2b4ee76216e32d81aae550a832d002", size = 10758012, upload-time = "2026-04-24T18:16:55.759Z" }, - { url = "https://files.pythonhosted.org/packages/11/c1/a606911aee04c324ddaa883ae418f3569792fd3c4a10c50e0dd0a2311e1e/ruff-0.15.12-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:fb129f40f114f089ebe0ca56c0d251cf2061b17651d464bb6478dc01e69f11f5", size = 10447479, upload-time = "2026-04-24T18:16:51.677Z" }, - { url = "https://files.pythonhosted.org/packages/9d/68/4201e8444f0894f21ab4aeeaee68aa4f10b51613514a20d80bd628d57e88/ruff-0.15.12-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b0c862b172d695db7598426b8af465e7e9ac00a3ea2a3630ee67eb82e366aaa6", size = 11234040, upload-time = "2026-04-24T18:17:16.529Z" }, - { url = "https://files.pythonhosted.org/packages/34/ff/8a6d6cf4ccc23fd67060874e832c18919d1557a0611ebef03fdb01fff11e/ruff-0.15.12-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2849ea9f3484c3aca43a82f484210370319e7170df4dfe4843395ddf6c57bc33", size = 12087377, upload-time = "2026-04-24T18:17:04.944Z" }, - { url = "https://files.pythonhosted.org/packages/85/f6/c669cf73f5152f623d34e69866a46d5e6185816b19fcd5b6dd8a2d299922/ruff-0.15.12-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9e77c7e51c07fe396826d5969a5b846d9cd4c402535835fb6e21ce8b28fef847", size = 11367784, upload-time = "2026-04-24T18:17:25.409Z" }, - { url = "https://files.pythonhosted.org/packages/e8/39/c61d193b8a1daaa8977f7dea9e8d8ba866e02ea7b65d32f6861693aa4c12/ruff-0.15.12-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:83b2f4f2f3b1026b5fb449b467d9264bf22067b600f7b6f41fc5958909f449d0", size = 11344088, upload-time = "2026-04-24T18:17:12.258Z" }, - { url = "https://files.pythonhosted.org/packages/c2/8d/49afab3645e31e12c590acb6d3b5b69d7aab5b81926dbaf7461f9441f37a/ruff-0.15.12-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:9ba3b8f1afd7e2e43d8943e55f249e13f9682fde09711644a6e7290eb4f3e339", size = 11271770, upload-time = "2026-04-24T18:17:02.457Z" }, - { url = "https://files.pythonhosted.org/packages/46/06/33f41fe94403e2b755481cdfb9b7ef3e4e0ed031c4581124658d935d52b4/ruff-0.15.12-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:e852ba9fdc890655e1d78f2df1499efbe0e54126bd405362154a75e2bde159c5", size = 10719355, upload-time = "2026-04-24T18:17:27.648Z" }, - { url = "https://files.pythonhosted.org/packages/0d/59/18aa4e014debbf559670e4048e39260a85c7fcee84acfd761ac01e7b8d35/ruff-0.15.12-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:dd8aed930da53780d22fc70bdf84452c843cf64f8cb4eb38984319c24c5cd5fd", size = 10462758, upload-time = "2026-04-24T18:17:32.347Z" }, - { url = "https://files.pythonhosted.org/packages/25/e7/cc9f16fd0f3b5fddcbd7ec3d6ae30c8f3fde1047f32a4093a98d633c6570/ruff-0.15.12-py3-none-musllinux_1_2_i686.whl", hash = "sha256:01da3988d225628b709493d7dc67c3b9b12c0210016b08690ef9bd27970b262b", size = 10953498, upload-time = "2026-04-24T18:17:20.674Z" }, - { url = "https://files.pythonhosted.org/packages/72/7a/a9ba7f98c7a575978698f4230c5e8cc54bbc761af34f560818f933dafa0c/ruff-0.15.12-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:9cae0f92bd5700d1213188b31cd3bdd2b315361296d10b96b8e2337d3d11f53e", size = 11447765, upload-time = "2026-04-24T18:17:09.755Z" }, - { url = "https://files.pythonhosted.org/packages/ea/f9/0ae446942c846b8266059ad8a30702a35afae55f5cdc54c5adf8d7afdc27/ruff-0.15.12-py3-none-win32.whl", hash = "sha256:d0185894e038d7043ba8fd6aee7499ece6462dc0ea9f1e260c7451807c714c20", size = 10657277, upload-time = "2026-04-24T18:17:18.591Z" }, - { url = "https://files.pythonhosted.org/packages/33/f1/9614e03e1cdcbf9437570b5400ced8a720b5db22b28d8e0f1bda429f660d/ruff-0.15.12-py3-none-win_amd64.whl", hash = "sha256:c87a162d61ab3adca47c03f7f717c68672edec7d1b5499e652331780fe74950d", size = 11837758, upload-time = "2026-04-24T18:17:00.113Z" }, - { url = "https://files.pythonhosted.org/packages/c0/98/6beb4b351e472e5f4c4613f7c35a5290b8be2497e183825310c4c3a3984b/ruff-0.15.12-py3-none-win_arm64.whl", hash = "sha256:a538f7a82d061cee7be55542aca1d86d1393d55d81d4fcc314370f4340930d4f", size = 11120821, upload-time = "2026-04-24T18:16:57.979Z" }, +version = "0.15.13" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/24/21/a7d5c126d5b557715ef81098f3db2fe20f622a039ff2e626af28d674ab80/ruff-0.15.13.tar.gz", hash = "sha256:f9d89f17f7ba7fb2ed42921f0df75da797a9a5d71bc39049e2c687cf2baf44b7", size = 4678180, upload-time = "2026-05-14T13:44:37.869Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c6/61/11d458dc6ac22504fd8e237b29dfd40504c7fbbcc8930402cfe51a8e63ed/ruff-0.15.13-py3-none-linux_armv6l.whl", hash = "sha256:444b580fc72fd6887e650acd3e575e18cdc79dbcf42fb4030b491057921f61f8", size = 10738279, upload-time = "2026-05-14T13:44:18.7Z" }, + { url = "https://files.pythonhosted.org/packages/86/ca/caa871ee7be718c45256fada4e16a218ee3e33f0c4a46b729a60a24912e6/ruff-0.15.13-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:6590d009e7cb7ebf36f83dbdd44a3fa48a0994ff6f1cdc1b08006abe58f98dc7", size = 11124798, upload-time = "2026-05-14T13:44:06.427Z" }, + { url = "https://files.pythonhosted.org/packages/d3/19/43f5f2e568dddde567fc41f8471f9432c09563e19d3e617a48cfa52f8f0a/ruff-0.15.13-py3-none-macosx_11_0_arm64.whl", hash = "sha256:1c26d2f66163deeb6e08d8b39fbbe983ce3c71cea06a6d7591cfd1421793c629", size = 10460761, upload-time = "2026-05-14T13:44:04.375Z" }, + { url = "https://files.pythonhosted.org/packages/99/df/cf938cd6de3003178f03ad7c1ea2a6c099468c03a35037985070b37e76be/ruff-0.15.13-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9dbd6f94b434f896308e4d57fb7bfde0d02b99f7a64b3bdab0fdfa6a864203a5", size = 10804451, upload-time = "2026-05-14T13:44:25.221Z" }, + { url = "https://files.pythonhosted.org/packages/c7/7d/5d0973129b154ded2225729169d7068f26b467760b146493fde138415f23/ruff-0.15.13-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bf3259f3be4d181bda591da5db2571aed6853c6a048157756448020bc6c5cd22", size = 10534285, upload-time = "2026-05-14T13:44:08.888Z" }, + { url = "https://files.pythonhosted.org/packages/1f/e3/6b999bbc66cd51e5f073842bc2a3995e99c5e0e72e16b15e7261f7abf57a/ruff-0.15.13-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ae9c17e5eb4430c154e76abc25d79a318190f5a997f38fb6b114416c5319ffc9", size = 11312063, upload-time = "2026-05-14T13:44:11.274Z" }, + { url = "https://files.pythonhosted.org/packages/af/5a/642639e9f5db04f1e97fbd6e091c6fd20725bdf072fb114d00eefb9e6eb8/ruff-0.15.13-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2e2e39bff6c341f4b577a21b801326fab0b11847f48fcaa83f00a113c9b3cb55", size = 12183079, upload-time = "2026-05-14T13:44:01.634Z" }, + { url = "https://files.pythonhosted.org/packages/19/4c/7585735f6b53b0f12de13618b2f7d250a844f018822efc899df2e7b8295f/ruff-0.15.13-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e8d9a8e08013542e94d3220bc5b62cc3e5ef87c5f74bff367d3fac14fab013e6", size = 11440833, upload-time = "2026-05-14T13:43:59.043Z" }, + { url = "https://files.pythonhosted.org/packages/e8/31/bf1a0803d077e679cfeee5f2f67290a0fa79c7385b5d9a8c17b9db2c48f0/ruff-0.15.13-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cc411dfebe5eebe55ce041c6ae080eb7668955e866daa2fbb16692a784f1c4ca", size = 11434486, upload-time = "2026-05-14T13:44:27.761Z" }, + { url = "https://files.pythonhosted.org/packages/e1/4e/62c9b999875d4f14db80f277c030578f5e249c9852d65b7ac7ad0b43c041/ruff-0.15.13-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:768494eb08b9cee54e2fd27969966f74db5a57f6eaa7a90fcb3306af34dfc4bd", size = 11385189, upload-time = "2026-05-14T13:44:13.704Z" }, + { url = "https://files.pythonhosted.org/packages/fc/89/7e959047a104df3eb12863447c110140191fc5b6c4f379ea2e803fcdb0e4/ruff-0.15.13-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:fb75f9a3a7e42ffe117d734494e6c5e5cb3565d66e12612cb63d0e572a41a5b6", size = 10781380, upload-time = "2026-05-14T13:43:56.734Z" }, + { url = "https://files.pythonhosted.org/packages/ff/52/5fd18f3b88cab63e88aa11516b3b4e1e5f720e5c330f8dbe5c26210f41f8/ruff-0.15.13-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:8cb74dd33bb2f6613faf7fc03b660053b5ac4f80e706d5788c6335e2a8048d51", size = 10540605, upload-time = "2026-05-14T13:44:20.748Z" }, + { url = "https://files.pythonhosted.org/packages/e8/e0/9e35f338990d3e41a82875ff7053ffe97541dae81c9d02143177f381d572/ruff-0.15.13-py3-none-musllinux_1_2_i686.whl", hash = "sha256:7ef823f817fcd191dc934e984be9cf4094f808effa16f2542ad8e821ba02bbf2", size = 11036554, upload-time = "2026-05-14T13:44:16.256Z" }, + { url = "https://files.pythonhosted.org/packages/c2/13/070fb048c24080fba188f66371e2a92785be257ad02242066dc7255ac6e9/ruff-0.15.13-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:f345a13937bd7f09f6f5d19fa0721b0c103e00e7f62bc67089a8e5e037719e0b", size = 11528133, upload-time = "2026-05-14T13:44:22.808Z" }, + { url = "https://files.pythonhosted.org/packages/6b/8c/b1e1666aef7fc6555094d73ae6cd981701781ae85b97ceefc0eebd0b4668/ruff-0.15.13-py3-none-win32.whl", hash = "sha256:4044f94208b3b05ba0fc4a4abd0558cf4d6459bd18325eead7fd8cc66f909b41", size = 10721455, upload-time = "2026-05-14T13:44:35.697Z" }, + { url = "https://files.pythonhosted.org/packages/ab/a6/870a3e8a50590bb92be184ad928c2922f088b00d9dc5c5ec7b924ee08c22/ruff-0.15.13-py3-none-win_amd64.whl", hash = "sha256:7064884d442b7d477b4e7473d12da7f08851d2b1982763c5d3f388a19468a1a4", size = 11900409, upload-time = "2026-05-14T13:44:30.389Z" }, + { url = "https://files.pythonhosted.org/packages/9b/36/9c015cd052fca743dae8cb2aeb16b551444787467db42ceab0fc968865af/ruff-0.15.13-py3-none-win_arm64.whl", hash = "sha256:2471da9bd1068c8c064b5fd9c0c4b6dddffd6369cb1cd68b29993b1709ff1b21", size = 11179336, upload-time = "2026-05-14T13:44:33.026Z" }, ] [[package]] @@ -445,26 +445,27 @@ wheels = [ [[package]] name = "ty" -version = "0.0.34" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/c4/69/e24eefe2c35c0fdbdec9b60e162727af669bb76d64d993d982eb67b24c38/ty-0.0.34.tar.gz", hash = "sha256:a6efe66b0f13c03a65e6c72ec9abfe2792e2fd063c74fa67e2c4930e29d661be", size = 5585933, upload-time = "2026-05-01T23:06:46.388Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/83/7b/8b85003d6639ef17a97dcbb31f4511cfe78f1c81a964470db100c8c883e7/ty-0.0.34-py3-none-linux_armv6l.whl", hash = "sha256:9ecc3d14f07a95a6ceb88e07f8e62358dbd37325d3d5bd56da7217ff1fef7fb8", size = 11067094, upload-time = "2026-05-01T23:06:21.133Z" }, - { url = "https://files.pythonhosted.org/packages/d7/25/b0098f65b020b015c40567c763fc66fffbec88b2ba6f584bca1e92f05ebb/ty-0.0.34-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:0dccffd8a9d02321cd2dee3249df205e26d62694e741f4eeca36b157fd8b419f", size = 10840909, upload-time = "2026-05-01T23:06:18.409Z" }, - { url = "https://files.pythonhosted.org/packages/e4/55/5e4adcf7d2a1006b844903b27cb81244a9b748d850433a46a6c21776c401/ty-0.0.34-py3-none-macosx_11_0_arm64.whl", hash = "sha256:b0ea47a2998e167ab3b21d2f4b5309a9cf33c297809f6d7e3e753252223174d0", size = 10279378, upload-time = "2026-05-01T23:06:37.962Z" }, - { url = "https://files.pythonhosted.org/packages/4d/91/f537dca0db8fe2558e8ab04d8941d687b384fcc1df5eb9023b2db75ac26c/ty-0.0.34-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8b37da00b41a118a459ae56d8947e70651073fb33ebfbceb820e4a10b22d5023", size = 10817423, upload-time = "2026-05-01T23:06:26.247Z" }, - { url = "https://files.pythonhosted.org/packages/2c/c4/55a3ad1da2815af1009bdc1b8c90dc11a364cd314e4b48c5128ba9d38859/ty-0.0.34-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:81cbbb93c2342fe3de43e625d3a9eb149633e9f485e816ebf6395d08685355d8", size = 10851826, upload-time = "2026-05-01T23:06:24.198Z" }, - { url = "https://files.pythonhosted.org/packages/ce/8c/9c7606af22d73fb43ea4369472d9c66ece11231be73b0efe8e3c61655559/ty-0.0.34-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4c5b4dea1594a021289e172582df9cde7089dce14b276fc650e7b212b1772e12", size = 11356318, upload-time = "2026-05-01T23:06:51.139Z" }, - { url = "https://files.pythonhosted.org/packages/20/54/bb423f663721ab4138b216425c6b55eaefd3a068243b24d6d8fe988f4e13/ty-0.0.34-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:030fb00aa2d2a5b5ae9d9183d574e0c82dae80566700a7490c43669d8ece40cd", size = 11902968, upload-time = "2026-05-01T23:06:35.82Z" }, - { url = "https://files.pythonhosted.org/packages/b6/22/01122b21ab6b534a2f618c6bbe5f1f7f49fd56f4b2ec8887cd6d40d08fb3/ty-0.0.34-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5ae9555e24e36c63a8218e037a5a63f15579eb6aa94f41017e57cd41d335cfb5", size = 11548860, upload-time = "2026-05-01T23:06:42.155Z" }, - { url = "https://files.pythonhosted.org/packages/d1/50/86008b1392ec64bed1957bbcc7aaa43b466b50dfc91bb131841c21d7c5c3/ty-0.0.34-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:99eb23df9ed129fc26d1ab00d6f0b8dfe5253b09c2ac6abdb11523fa70d67f10", size = 11457097, upload-time = "2026-05-01T23:06:53.477Z" }, - { url = "https://files.pythonhosted.org/packages/92/3e/4558b2296963ba99c58d8409c57d7db4f3061b656c3613cb21c02c1ef4c2/ty-0.0.34-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:85de45382016eceae69e104815eb2cfa200787df104002e262a86cbd43ed2c02", size = 10798192, upload-time = "2026-05-01T23:06:40.004Z" }, - { url = "https://files.pythonhosted.org/packages/76/bf/650d24402be2ef678528d60caac1d9477a40fc37e3792ecef07834fd7a4a/ty-0.0.34-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:14cb575fb8fa5131f5129d100cfe23c1575d23faf5dfc5158432749a3e38c9b5", size = 10890390, upload-time = "2026-05-01T23:06:33.076Z" }, - { url = "https://files.pythonhosted.org/packages/5c/ef/ccd2ca13906079f7935fd7e067661b24233017f57d987d51d6a121d85bb5/ty-0.0.34-py3-none-musllinux_1_2_i686.whl", hash = "sha256:c6fc0b69d8450e6910ba9db34572b959b81329a97ae273c391f70e9fb6c1aade", size = 11031564, upload-time = "2026-05-01T23:06:55.812Z" }, - { url = "https://files.pythonhosted.org/packages/ba/2d/d27b72005b6f43599e3bcabab0d7135ac0c230b7a307bb99f9eea02c1cda/ty-0.0.34-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:30dfcec2f0fde3993f4f912ed0e057dcbebc8615299f610a4c2ddb7b5a3e1e06", size = 11553430, upload-time = "2026-05-01T23:06:31.096Z" }, - { url = "https://files.pythonhosted.org/packages/a7/12/20812e1ad930b8d4af70eebf19ad23cff6e31efcfa613ef884531fcdbaa1/ty-0.0.34-py3-none-win32.whl", hash = "sha256:97b77ddf007271b812a313a8f0a14929bc5590958433e1fb83ef585676f53342", size = 10436048, upload-time = "2026-05-01T23:06:49.108Z" }, - { url = "https://files.pythonhosted.org/packages/b0/6a/afa095c5987868fbda27c0f731146ac8e3d07b357adfa83daccaee5b1a16/ty-0.0.34-py3-none-win_amd64.whl", hash = "sha256:1f543968accb952705134028d1fda8656882787dbbc667ad4d6c3ba23791d604", size = 11462526, upload-time = "2026-05-01T23:06:28.514Z" }, - { url = "https://files.pythonhosted.org/packages/63/8f/bf041a06260d77662c0605e56dacfe90b786bf824cbe1aed238d15fe5e84/ty-0.0.34-py3-none-win_arm64.whl", hash = "sha256:ea09108cbcb16b6b06d7596312b433bf49681e78d30e4dc7fb3c1b248a95e09a", size = 10846945, upload-time = "2026-05-01T23:06:44.428Z" }, +version = "0.0.38" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/33/3b/45be6b37d5060d6917bf7f1f234c00d360fc5f8b7486f8a96af640e25661/ty-0.0.38.tar.gz", hash = "sha256:fbc8d47f7630457669ab41e333dc093897fdb7ead1ffc94dcf8f30b5d39aa56d", size = 5681218, upload-time = "2026-05-20T00:15:32.781Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/43/ea9b4e57d6a266670dbe34858e92f6093ca054ad1b48f1c82580a72340fb/ty-0.0.38-py3-none-linux_armv6l.whl", hash = "sha256:3501dcf44ca03f813f9cb4fabfdf601adc0ac1337c411405b470530679e37a45", size = 11289326, upload-time = "2026-05-20T00:14:52.371Z" }, + { url = "https://files.pythonhosted.org/packages/5d/ff/24e2f623a1c6b5f5ccf8bf82fccd937033c6a7dba57a4028c7f41270fa4a/ty-0.0.38-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:b34b4094b76252c3e8c90762cdd5e8a9f1101534484745ff4b480f71eb38ac2e", size = 11063047, upload-time = "2026-05-20T00:14:42.832Z" }, + { url = "https://files.pythonhosted.org/packages/e9/41/4f0d910f0acbd20b358eda80a5cd6a8361d27ff5b8e87ab559d3f69f125e/ty-0.0.38-py3-none-macosx_11_0_arm64.whl", hash = "sha256:c518ad33a877677365baab2e21d82cf59ffee789203a15a143f5179ee5a1d3f8", size = 10494436, upload-time = "2026-05-20T00:15:24.425Z" }, + { url = "https://files.pythonhosted.org/packages/69/d8/da06833422082aa98b169a391f9197e2d73865e96c90b6979ac886b890a2/ty-0.0.38-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9238494722303eccddc6a27eb647948b694eecd6b974910d13b9e6cd46bbeb6a", size = 11000992, upload-time = "2026-05-20T00:14:58.368Z" }, + { url = "https://files.pythonhosted.org/packages/16/f7/e1172197fb827e6410ca3eb0dc68ef2789f3c70683696f2a0ce5c90764fd/ty-0.0.38-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:31d91d7336c5d51bf822ac0df512f300584ca4dcca041fc6a6d7df03a8ddbb31", size = 11058583, upload-time = "2026-05-20T00:15:11.314Z" }, + { url = "https://files.pythonhosted.org/packages/5b/61/7fbaf0c05981e006a8804287819c574dff90a6bf8e96efad7226be0700aa/ty-0.0.38-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:65165879814993450710b9349791e4898c65e36b1e14eec554884c06a2f20ff1", size = 11531036, upload-time = "2026-05-20T00:15:14.62Z" }, + { url = "https://files.pythonhosted.org/packages/49/e3/47c0c64e401d50f925df3e52479d4e7626754b2a9e38201d142fdacd6252/ty-0.0.38-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6d61868b8d1c4033bf8088191de953fed245c2f9e1bb9d2d53e5699170b0924c", size = 12129991, upload-time = "2026-05-20T00:14:39.475Z" }, + { url = "https://files.pythonhosted.org/packages/90/99/2f452d02901bcd7f1b109cf5b848727ce37f372c3406143aa52d1305d40e/ty-0.0.38-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8f9a9175548c98dbff7707865738c07c2b1f8e07a09b8c68101baebb5dac59a4", size = 11756167, upload-time = "2026-05-20T00:15:27.526Z" }, + { url = "https://files.pythonhosted.org/packages/dd/0c/c7e14d111c813e1a20b82e944f1c997c4631a2bb710eaa64fb6b26835e13/ty-0.0.38-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:375d3a964c6b4aea2e9237fdb5eb9ed03dc43088986a94209a28a4ea3b62001c", size = 11637099, upload-time = "2026-05-20T00:15:21.261Z" }, + { url = "https://files.pythonhosted.org/packages/37/de/ab02659dd1ed62898db7db4d37f9937c80854dd45e95093fa0fe10328d82/ty-0.0.38-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:cdfd547782c45267aa0b52abad31bd406bf4768c264532ef9e2360cd3c6ce048", size = 11813583, upload-time = "2026-05-20T00:14:45.875Z" }, + { url = "https://files.pythonhosted.org/packages/7e/57/bd1b5ebf4e71a4295484afac0202df1740b0807762b86744b1bef4534984/ty-0.0.38-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:858bc675b75626470abe4e6c3b3934b853642b04f2ac4d7139fcefea3b48b213", size = 10975405, upload-time = "2026-05-20T00:15:30.354Z" }, + { url = "https://files.pythonhosted.org/packages/e7/55/0305c78711bbd23922cf291996a08ef9544f4179da98e9a75c14e608f379/ty-0.0.38-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:54be4f00432870da42cd74fe145a3362fd248e22d032c74bd807cb45bf068f94", size = 11097551, upload-time = "2026-05-20T00:14:55.179Z" }, + { url = "https://files.pythonhosted.org/packages/7c/4f/7effe7f9a6ac9719eb7234172c01739c5f888bb47f9acc2ea8da1f4afed3/ty-0.0.38-py3-none-musllinux_1_2_i686.whl", hash = "sha256:494af66a76a86dbf16a3003d3b63b03484aa4c7489dfe11f3ee5413b98b22d60", size = 11214391, upload-time = "2026-05-20T00:15:18.094Z" }, + { url = "https://files.pythonhosted.org/packages/75/cd/d9fdfec3a74a6ad0209fa5e7113ae29d4f457d0651cfbb813b4c6563e0d4/ty-0.0.38-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:3d92527c4be78a5ce6d32e8bb0aa2a6988d4076eddf1294e56fdaf06d1a98e7e", size = 11730871, upload-time = "2026-05-20T00:14:49.219Z" }, + { url = "https://files.pythonhosted.org/packages/0e/4a/beefade12d109b4f7793d61b04b4478b1ad4d1465a719e7ff55b2d42461a/ty-0.0.38-py3-none-win32.whl", hash = "sha256:36fc5dd5dc09207ff3004b1560a79a3fb8d12456daeec914a7b802a918da654c", size = 10548583, upload-time = "2026-05-20T00:15:07.892Z" }, + { url = "https://files.pythonhosted.org/packages/15/64/941b205e2e46cc2297c245c64aa7691410b7454fa4d07a6cb3cf59487833/ty-0.0.38-py3-none-win_amd64.whl", hash = "sha256:eef0a8956ba14514076b1a963d13eb32986d9ebad7f0527b3cc01cb68bf35147", size = 11650542, upload-time = "2026-05-20T00:15:01.441Z" }, + { url = "https://files.pythonhosted.org/packages/59/02/c1c4f9ec4b94d95190636fa13f79c32f65165fbe3a0503882d4df164d2ac/ty-0.0.38-py3-none-win_arm64.whl", hash = "sha256:79abfc8658a026c30b1c955613437dab3ef4b12feca56a3e6df50903cc39e07f", size = 11010307, upload-time = "2026-05-20T00:15:04.567Z" }, ] [[package]] @@ -490,15 +491,15 @@ wheels = [ [[package]] name = "uvicorn" -version = "0.46.0" +version = "0.47.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "click" }, { name = "h11" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/1f/93/041fca8274050e40e6791f267d82e0e2e27dd165627bd640d3e0e378d877/uvicorn-0.46.0.tar.gz", hash = "sha256:fb9da0926999cc6cb22dc7cd71a94a632f078e6ae47ff683c5c420750fb7413d", size = 88758, upload-time = "2026-04-23T07:16:00.151Z" } +sdist = { url = "https://files.pythonhosted.org/packages/f6/b1/8e7077a8641086aea449e1b5752a570f1b5906c64e0a33cd6d93b63a066b/uvicorn-0.47.0.tar.gz", hash = "sha256:7c9a0ea1a9414106bbab7324609c162d8fa0cdcdcb703060987269d77c7bb533", size = 90582, upload-time = "2026-05-14T18:16:54.455Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/31/a3/5b1562db76a5a488274b2332a97199b32d0442aca0ed193697fd47786316/uvicorn-0.46.0-py3-none-any.whl", hash = "sha256:bbebbcbed972d162afca128605223022bedd345b7bc7855ce66deb31487a9048", size = 70926, upload-time = "2026-04-23T07:15:58.355Z" }, + { url = "https://files.pythonhosted.org/packages/15/41/ac2dfdbc1f60c7af4f994c7a335cfa7040c01642b605d65f611cecc2a1e4/uvicorn-0.47.0-py3-none-any.whl", hash = "sha256:2c5715bc12d1892d84752049f400cd1c3cb018514967fdfeb97640443a6a9432", size = 71301, upload-time = "2026-05-14T18:16:51.762Z" }, ] [[package]] From 228ca5e2c1e3deaa809c64a30c3232c07101c66f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C6=B0u=20Quang=20V=C5=A9?= Date: Thu, 21 May 2026 13:29:00 +0700 Subject: [PATCH 258/291] Tagged arguments preserve JSON-compatible types --- app/utils/helper.py | 26 ++++++++++++++++++++++++-- 1 file changed, 24 insertions(+), 2 deletions(-) diff --git a/app/utils/helper.py b/app/utils/helper.py index ea4d18f..892e216 100644 --- a/app/utils/helper.py +++ b/app/utils/helper.py @@ -8,6 +8,7 @@ import tempfile import unicodedata from pathlib import Path +from typing import Any from urllib.parse import urlparse import orjson @@ -16,6 +17,8 @@ from app.models import AppMessage, AppToolCall, AppToolCallFunction +type JsonValue = None | bool | int | float | str | list[JsonValue] | dict[str, JsonValue] + VALID_TAG_ROLES = {"user", "assistant", "system", "tool"} TOOL_WRAP_HINT = ( "\n\n### SYSTEM: TOOL CALLING PROTOCOL (MANDATORY) ###\n" @@ -162,6 +165,25 @@ def _strip_param_fences(s: str) -> str: return s[len(fence) : -len(fence)].strip() +def _parse_tool_argument_value(raw_value: str) -> JsonValue: + """ + Convert a tagged tool argument into the most specific JSON-compatible value. + + JSON literals, arrays, and objects are preserved so downstream clients receive + strict argument types, while plain text values remain strings for compatibility. + """ + value = _strip_param_fences(raw_value) + if not value: + return "" + + try: + parsed_value: Any = orjson.loads(value) + except orjson.JSONDecodeError: + return value + + return parsed_value + + def estimate_tokens(text: str | None) -> int: """Estimate the number of tokens heuristically based on character count.""" return len(text) // 3 if text else 0 @@ -272,7 +294,7 @@ def strip_system_hints(text: str) -> str: def _process_tools_internal(text: str, extract: bool = True) -> tuple[str, list[AppToolCall]]: """ Extract tool metadata and return text stripped of technical markers. - Arguments are parsed into JSON and assigned deterministic call IDs. + Tagged arguments preserve JSON-compatible types and receive deterministic call IDs. """ if not text: return text, [] @@ -292,7 +314,7 @@ def _create_tool_call(name: str, raw_args: str) -> None: arg_matches = TAGGED_ARG_RE.findall(raw_args) if arg_matches: args_dict = { - arg_name.strip(): _strip_param_fences(arg_value) + arg_name.strip(): _parse_tool_argument_value(arg_value) for arg_name, arg_value in arg_matches } arguments = orjson.dumps(args_dict).decode("utf-8") From d7ca5881a6ba6f0d06aaf5925c7f5405a97f6549 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C6=B0u=20Quang=20V=C5=A9?= Date: Thu, 21 May 2026 16:25:10 +0700 Subject: [PATCH 259/291] Implement new extended thinking level --- app/server/chat.py | 26 +++++++++++++++++++++----- app/utils/config.py | 6 +++++- config/config.yaml | 1 + uv.lock | 4 ++-- 4 files changed, 29 insertions(+), 8 deletions(-) diff --git a/app/server/chat.py b/app/server/chat.py index b1215b7..e270fac 100644 --- a/app/server/chat.py +++ b/app/server/chat.py @@ -986,12 +986,20 @@ async def _send_with_split( files: list[Any] | None = None, stream: bool = False, ) -> AsyncGenerator[ModelOutput] | ModelOutput: - """Send text to Gemini, splitting or converting to attachment if too long.""" + """Send text to Gemini with configured generation options, using an attachment if too long.""" if len(text) <= MAX_CHARS_PER_REQUEST: try: if stream: - return session.send_message_stream(text, files=files) - return await session.send_message(text, files=files) + return session.send_message_stream( + text, + files=files, + extended_thinking=g_config.gemini.extended_thinking, + ) + return await session.send_message( + text, + files=files, + extended_thinking=g_config.gemini.extended_thinking, + ) except Exception as e: logger.error(f"Error sending message to Gemini: {e}") raise @@ -1012,8 +1020,16 @@ async def _send_with_split( "3. Execute the instructions or answer the questions found *inside* that file immediately.\n" ) if stream: - return session.send_message_stream(instruction, files=final_files) - return await session.send_message(instruction, files=final_files) + return session.send_message_stream( + instruction, + files=final_files, + extended_thinking=g_config.gemini.extended_thinking, + ) + return await session.send_message( + instruction, + files=final_files, + extended_thinking=g_config.gemini.extended_thinking, + ) except Exception as e: logger.error(f"Error sending large text as file to Gemini: {e}") raise diff --git a/app/utils/config.py b/app/utils/config.py index 712fa40..281e41e 100644 --- a/app/utils/config.py +++ b/app/utils/config.py @@ -94,7 +94,7 @@ def _parse_json_string(cls, v: Any) -> Any: class GeminiConfig(BaseModel): - """Gemini API configuration""" + """Gemini API configuration, including session behavior and generation options.""" clients: list[GeminiClientSettings] = Field( ..., description="List of Gemini client credential pairs" @@ -119,6 +119,10 @@ class GeminiConfig(BaseModel): default=450, ge=30, description="Inactivity delay in seconds before auto-closing" ) verbose: bool = Field(True, description="Enable verbose logging for Gemini API requests") + extended_thinking: bool = Field( + default=False, + description="Enable Gemini extended thinking mode for message generation", + ) max_chars_per_request: int = Field( default=1_000_000, ge=1, diff --git a/config/config.yaml b/config/config.yaml index b90bdc6..fe18f48 100644 --- a/config/config.yaml +++ b/config/config.yaml @@ -30,6 +30,7 @@ gemini: auto_close: true # Automatically close Gemini session after inactivity close_delay: 450 # Inactivity delay in seconds before auto-closing (Not less than 30s) verbose: true # Enable verbose logging for Gemini requests + extended_thinking: false # Enable Gemini extended thinking mode for message generation max_chars_per_request: 1000000 # Maximum characters Gemini Web accepts per request. Non-pro users might have a lower limit model_strategy: "append" # Strategy: 'append' (default + custom) or 'overwrite' (custom only) models: [] diff --git a/uv.lock b/uv.lock index f36b15e..a69c8f1 100644 --- a/uv.lock +++ b/uv.lock @@ -176,8 +176,8 @@ dev = [{ name = "gemini-fastapi", extras = ["dev"] }] [[package]] name = "gemini-webapi" -version = "0.0.post285" -source = { git = "https://github.com/luuquangvu/Gemini-API.git?rev=enable-guest-mode#2db51f41c7134dbe93afe58f2b52dfa8d11d7aeb" } +version = "0.0.post287" +source = { git = "https://github.com/luuquangvu/Gemini-API.git?rev=enable-guest-mode#ee0efd9db03a044feef5daf576404b1740903bb2" } dependencies = [ { name = "curl-cffi" }, { name = "loguru" }, From cafd0468612b2144142b1fbebb3bda2bc4322d6d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C6=B0u=20Quang=20V=C5=A9?= Date: Thu, 21 May 2026 17:04:33 +0700 Subject: [PATCH 260/291] Implement new extended thinking level --- uv.lock | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/uv.lock b/uv.lock index a69c8f1..4b4039a 100644 --- a/uv.lock +++ b/uv.lock @@ -176,8 +176,8 @@ dev = [{ name = "gemini-fastapi", extras = ["dev"] }] [[package]] name = "gemini-webapi" -version = "0.0.post287" -source = { git = "https://github.com/luuquangvu/Gemini-API.git?rev=enable-guest-mode#ee0efd9db03a044feef5daf576404b1740903bb2" } +version = "0.0.post288" +source = { git = "https://github.com/luuquangvu/Gemini-API.git?rev=enable-guest-mode#2f1f940d347c36152d3916ebc3cedcbf2dd3071c" } dependencies = [ { name = "curl-cffi" }, { name = "loguru" }, From ad11981559759a90715cc7731905fb075d67e2be Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C6=B0u=20Quang=20V=C5=A9?= Date: Thu, 21 May 2026 18:05:55 +0700 Subject: [PATCH 261/291] Update to fix new extended thinking mode --- uv.lock | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/uv.lock b/uv.lock index 4b4039a..2f21109 100644 --- a/uv.lock +++ b/uv.lock @@ -176,8 +176,8 @@ dev = [{ name = "gemini-fastapi", extras = ["dev"] }] [[package]] name = "gemini-webapi" -version = "0.0.post288" -source = { git = "https://github.com/luuquangvu/Gemini-API.git?rev=enable-guest-mode#2f1f940d347c36152d3916ebc3cedcbf2dd3071c" } +version = "0.0.post289" +source = { git = "https://github.com/luuquangvu/Gemini-API.git?rev=enable-guest-mode#d70c97392f82491cdefded3b139f9764a03bfc0e" } dependencies = [ { name = "curl-cffi" }, { name = "loguru" }, From 90fb370ffc07b0e1d7608282ded7783023465d3e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C6=B0u=20Quang=20V=C5=A9?= Date: Thu, 21 May 2026 23:12:16 +0700 Subject: [PATCH 262/291] Increase the close_delay to allow cookies to refresh before going idle --- app/utils/config.py | 2 +- config/config.yaml | 2 +- uv.lock | 46 ++++++++++++++++++++++----------------------- 3 files changed, 25 insertions(+), 25 deletions(-) diff --git a/app/utils/config.py b/app/utils/config.py index 281e41e..66e5de7 100644 --- a/app/utils/config.py +++ b/app/utils/config.py @@ -116,7 +116,7 @@ class GeminiConfig(BaseModel): default=True, description="Enable auto-close for Gemini sessions after inactivity" ) close_delay: int = Field( - default=450, ge=30, description="Inactivity delay in seconds before auto-closing" + default=900, ge=30, description="Inactivity delay in seconds before auto-closing" ) verbose: bool = Field(True, description="Enable verbose logging for Gemini API requests") extended_thinking: bool = Field( diff --git a/config/config.yaml b/config/config.yaml index fe18f48..b3cd4d2 100644 --- a/config/config.yaml +++ b/config/config.yaml @@ -28,7 +28,7 @@ gemini: auto_refresh: true # Auto-refresh session cookies refresh_interval: 600 # Refresh interval in seconds (Not less than 60s) auto_close: true # Automatically close Gemini session after inactivity - close_delay: 450 # Inactivity delay in seconds before auto-closing (Not less than 30s) + close_delay: 900 # Inactivity delay in seconds before auto-closing (Not less than 30s) verbose: true # Enable verbose logging for Gemini requests extended_thinking: false # Enable Gemini extended thinking mode for message generation max_chars_per_request: 1000000 # Maximum characters Gemini Web accepts per request. Non-pro users might have a lower limit diff --git a/uv.lock b/uv.lock index 2f21109..9187ae9 100644 --- a/uv.lock +++ b/uv.lock @@ -176,8 +176,8 @@ dev = [{ name = "gemini-fastapi", extras = ["dev"] }] [[package]] name = "gemini-webapi" -version = "0.0.post289" -source = { git = "https://github.com/luuquangvu/Gemini-API.git?rev=enable-guest-mode#d70c97392f82491cdefded3b139f9764a03bfc0e" } +version = "0.0.post290" +source = { git = "https://github.com/luuquangvu/Gemini-API.git?rev=enable-guest-mode#af7fc6fec641c4dfa26527d25d9b518eb001d14a" } dependencies = [ { name = "curl-cffi" }, { name = "loguru" }, @@ -408,27 +408,27 @@ wheels = [ [[package]] name = "ruff" -version = "0.15.13" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/24/21/a7d5c126d5b557715ef81098f3db2fe20f622a039ff2e626af28d674ab80/ruff-0.15.13.tar.gz", hash = "sha256:f9d89f17f7ba7fb2ed42921f0df75da797a9a5d71bc39049e2c687cf2baf44b7", size = 4678180, upload-time = "2026-05-14T13:44:37.869Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/c6/61/11d458dc6ac22504fd8e237b29dfd40504c7fbbcc8930402cfe51a8e63ed/ruff-0.15.13-py3-none-linux_armv6l.whl", hash = "sha256:444b580fc72fd6887e650acd3e575e18cdc79dbcf42fb4030b491057921f61f8", size = 10738279, upload-time = "2026-05-14T13:44:18.7Z" }, - { url = "https://files.pythonhosted.org/packages/86/ca/caa871ee7be718c45256fada4e16a218ee3e33f0c4a46b729a60a24912e6/ruff-0.15.13-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:6590d009e7cb7ebf36f83dbdd44a3fa48a0994ff6f1cdc1b08006abe58f98dc7", size = 11124798, upload-time = "2026-05-14T13:44:06.427Z" }, - { url = "https://files.pythonhosted.org/packages/d3/19/43f5f2e568dddde567fc41f8471f9432c09563e19d3e617a48cfa52f8f0a/ruff-0.15.13-py3-none-macosx_11_0_arm64.whl", hash = "sha256:1c26d2f66163deeb6e08d8b39fbbe983ce3c71cea06a6d7591cfd1421793c629", size = 10460761, upload-time = "2026-05-14T13:44:04.375Z" }, - { url = "https://files.pythonhosted.org/packages/99/df/cf938cd6de3003178f03ad7c1ea2a6c099468c03a35037985070b37e76be/ruff-0.15.13-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9dbd6f94b434f896308e4d57fb7bfde0d02b99f7a64b3bdab0fdfa6a864203a5", size = 10804451, upload-time = "2026-05-14T13:44:25.221Z" }, - { url = "https://files.pythonhosted.org/packages/c7/7d/5d0973129b154ded2225729169d7068f26b467760b146493fde138415f23/ruff-0.15.13-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bf3259f3be4d181bda591da5db2571aed6853c6a048157756448020bc6c5cd22", size = 10534285, upload-time = "2026-05-14T13:44:08.888Z" }, - { url = "https://files.pythonhosted.org/packages/1f/e3/6b999bbc66cd51e5f073842bc2a3995e99c5e0e72e16b15e7261f7abf57a/ruff-0.15.13-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ae9c17e5eb4430c154e76abc25d79a318190f5a997f38fb6b114416c5319ffc9", size = 11312063, upload-time = "2026-05-14T13:44:11.274Z" }, - { url = "https://files.pythonhosted.org/packages/af/5a/642639e9f5db04f1e97fbd6e091c6fd20725bdf072fb114d00eefb9e6eb8/ruff-0.15.13-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2e2e39bff6c341f4b577a21b801326fab0b11847f48fcaa83f00a113c9b3cb55", size = 12183079, upload-time = "2026-05-14T13:44:01.634Z" }, - { url = "https://files.pythonhosted.org/packages/19/4c/7585735f6b53b0f12de13618b2f7d250a844f018822efc899df2e7b8295f/ruff-0.15.13-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e8d9a8e08013542e94d3220bc5b62cc3e5ef87c5f74bff367d3fac14fab013e6", size = 11440833, upload-time = "2026-05-14T13:43:59.043Z" }, - { url = "https://files.pythonhosted.org/packages/e8/31/bf1a0803d077e679cfeee5f2f67290a0fa79c7385b5d9a8c17b9db2c48f0/ruff-0.15.13-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cc411dfebe5eebe55ce041c6ae080eb7668955e866daa2fbb16692a784f1c4ca", size = 11434486, upload-time = "2026-05-14T13:44:27.761Z" }, - { url = "https://files.pythonhosted.org/packages/e1/4e/62c9b999875d4f14db80f277c030578f5e249c9852d65b7ac7ad0b43c041/ruff-0.15.13-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:768494eb08b9cee54e2fd27969966f74db5a57f6eaa7a90fcb3306af34dfc4bd", size = 11385189, upload-time = "2026-05-14T13:44:13.704Z" }, - { url = "https://files.pythonhosted.org/packages/fc/89/7e959047a104df3eb12863447c110140191fc5b6c4f379ea2e803fcdb0e4/ruff-0.15.13-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:fb75f9a3a7e42ffe117d734494e6c5e5cb3565d66e12612cb63d0e572a41a5b6", size = 10781380, upload-time = "2026-05-14T13:43:56.734Z" }, - { url = "https://files.pythonhosted.org/packages/ff/52/5fd18f3b88cab63e88aa11516b3b4e1e5f720e5c330f8dbe5c26210f41f8/ruff-0.15.13-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:8cb74dd33bb2f6613faf7fc03b660053b5ac4f80e706d5788c6335e2a8048d51", size = 10540605, upload-time = "2026-05-14T13:44:20.748Z" }, - { url = "https://files.pythonhosted.org/packages/e8/e0/9e35f338990d3e41a82875ff7053ffe97541dae81c9d02143177f381d572/ruff-0.15.13-py3-none-musllinux_1_2_i686.whl", hash = "sha256:7ef823f817fcd191dc934e984be9cf4094f808effa16f2542ad8e821ba02bbf2", size = 11036554, upload-time = "2026-05-14T13:44:16.256Z" }, - { url = "https://files.pythonhosted.org/packages/c2/13/070fb048c24080fba188f66371e2a92785be257ad02242066dc7255ac6e9/ruff-0.15.13-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:f345a13937bd7f09f6f5d19fa0721b0c103e00e7f62bc67089a8e5e037719e0b", size = 11528133, upload-time = "2026-05-14T13:44:22.808Z" }, - { url = "https://files.pythonhosted.org/packages/6b/8c/b1e1666aef7fc6555094d73ae6cd981701781ae85b97ceefc0eebd0b4668/ruff-0.15.13-py3-none-win32.whl", hash = "sha256:4044f94208b3b05ba0fc4a4abd0558cf4d6459bd18325eead7fd8cc66f909b41", size = 10721455, upload-time = "2026-05-14T13:44:35.697Z" }, - { url = "https://files.pythonhosted.org/packages/ab/a6/870a3e8a50590bb92be184ad928c2922f088b00d9dc5c5ec7b924ee08c22/ruff-0.15.13-py3-none-win_amd64.whl", hash = "sha256:7064884d442b7d477b4e7473d12da7f08851d2b1982763c5d3f388a19468a1a4", size = 11900409, upload-time = "2026-05-14T13:44:30.389Z" }, - { url = "https://files.pythonhosted.org/packages/9b/36/9c015cd052fca743dae8cb2aeb16b551444787467db42ceab0fc968865af/ruff-0.15.13-py3-none-win_arm64.whl", hash = "sha256:2471da9bd1068c8c064b5fd9c0c4b6dddffd6369cb1cd68b29993b1709ff1b21", size = 11179336, upload-time = "2026-05-14T13:44:33.026Z" }, +version = "0.15.14" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/dc/8a/8bce2894573e9dae6ff4d77fe34ad727d79b9e6238ad288c5638990d90f6/ruff-0.15.14.tar.gz", hash = "sha256:48e866b165be4a9bdbf310f7d3c9a07edef2fe8cd63ffeb4e00bb590506ebf9f", size = 4700910, upload-time = "2026-05-21T14:34:55.177Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b9/c8/74a92c6ff9fcfb4f1f947126d3ebee8389276e161ecc85de5bda7cda51bd/ruff-0.15.14-py3-none-linux_armv6l.whl", hash = "sha256:8dd2db9416e487c8d4b01fa7056bb02c4d05969d4f8d17a08c229c2f4ff3c108", size = 10739177, upload-time = "2026-05-21T14:34:37.332Z" }, + { url = "https://files.pythonhosted.org/packages/45/91/254a35c20acc38a7223c9d2d594af12e794432464f2cdeb52af1dc4a892d/ruff-0.15.14-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:be4ff55af755bd71a00ab3dc6bd7ffc467bd76e0df6881e286c2e3d23e8fb43b", size = 11144969, upload-time = "2026-05-21T14:34:43.978Z" }, + { url = "https://files.pythonhosted.org/packages/56/9e/d13e40f83b8d0a94430e6778ce1d94a43b38cf2efe63278bdd2b4c65abbf/ruff-0.15.14-py3-none-macosx_11_0_arm64.whl", hash = "sha256:48d5909d7d06276ce7dde6d32bfa4b0d4cb2651145cd8ee4b440722cbc77832f", size = 10478207, upload-time = "2026-05-21T14:34:48.378Z" }, + { url = "https://files.pythonhosted.org/packages/8d/f1/b15a7839fa4f332f8acec78e20564f26bb2d866e3d21710b877fd0263000/ruff-0.15.14-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ca8cbfa94c4f90984a67561978602746d4cd27103568f745fa90eee3f0d4107d", size = 10818459, upload-time = "2026-05-21T14:34:22.318Z" }, + { url = "https://files.pythonhosted.org/packages/45/33/53d651177f84f94b400a0e27f8824eeada3dddc9d5ee8aeb048f4352a520/ruff-0.15.14-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9a6bbc0333f1ab053423bcbf6226477d266ca7cec7738c4c8e3f55647803f3c4", size = 10541800, upload-time = "2026-05-21T14:34:20.209Z" }, + { url = "https://files.pythonhosted.org/packages/b8/a6/868f87e0bf9786ed24b5d0d0ad8676b8a94fd1912f42cddf9cfc7857818a/ruff-0.15.14-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8a24a4f7605d7003a6674d4387651effd939dead3fddd0f36561eb77a9a2e542", size = 11342149, upload-time = "2026-05-21T14:34:46.365Z" }, + { url = "https://files.pythonhosted.org/packages/a7/8b/38cd5c19faffdcc05a408d2b78edccc69492ab9720eadb49ea15ef80d768/ruff-0.15.14-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:049b5326e53ed80978f2fc041a280603f69dd6b0c95464342a2bb4572d9d9e2f", size = 12212563, upload-time = "2026-05-21T14:34:28.579Z" }, + { url = "https://files.pythonhosted.org/packages/3e/4d/a3c5b874a556d5731e3e657aaf04311bb76f0a5c3ec220ed43051be6b64b/ruff-0.15.14-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d4ed42e6696c8dfa5f06728e6441993901f548eb92d73bc472cb5a38d1395fbf", size = 11493299, upload-time = "2026-05-21T14:34:41.836Z" }, + { url = "https://files.pythonhosted.org/packages/1e/c0/56472c251d09858a53e51efbd485b09e1995d8731668b76d52e5dd6ee0f1/ruff-0.15.14-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:715c543cf450c4888251f91c52f1942a800541d9bddd7ac060aa4e6b77ae7cba", size = 11455931, upload-time = "2026-05-21T14:34:57.276Z" }, + { url = "https://files.pythonhosted.org/packages/2c/4a/e2e7b4d8dbf233d4eace59c75bc3435fa6d8bd3bae82d351d4e4300c0fd1/ruff-0.15.14-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:72ebab6013ec887d439d8b7593737a0a4ffb06d45d209d4e4bf2e92813082d3f", size = 11400794, upload-time = "2026-05-21T14:34:39.773Z" }, + { url = "https://files.pythonhosted.org/packages/97/c7/83c0539fe34c3e09136204d1e75d6052492364e0b3cb05e9465423f567d7/ruff-0.15.14-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:49072d36abdbe97a8dd7f480afe9c675699c0c495d4c84076e2c1203c4550581", size = 10804759, upload-time = "2026-05-21T14:34:31.045Z" }, + { url = "https://files.pythonhosted.org/packages/86/a6/18f2bfc095a2ab4a78745644e428205532ce6653a5d0fa8501572891534d/ruff-0.15.14-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:958522aee105068640c2c2ceae08f413ae44d922f52a1374ac13d6a96032fc93", size = 10539517, upload-time = "2026-05-21T14:34:53.064Z" }, + { url = "https://files.pythonhosted.org/packages/54/3a/5a8b3b69c654d4e4bf1d246ac5b49cbcdac6eaab6905925f8915f31e3b80/ruff-0.15.14-py3-none-musllinux_1_2_i686.whl", hash = "sha256:f3707da619a143a2e8830e2abab8224478d69ace2d28cb6c20543ae97c36bf61", size = 11065169, upload-time = "2026-05-21T14:34:24.484Z" }, + { url = "https://files.pythonhosted.org/packages/ed/c5/8864e4e7925b836ea354b31d57641ec03830564e281a8b6f061f8c3e0ec1/ruff-0.15.14-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:bb01d645694e3ec0102105d07ef2d53703970407d59c04e59d3ba0b7a1d53553", size = 11560214, upload-time = "2026-05-21T14:34:50.975Z" }, + { url = "https://files.pythonhosted.org/packages/36/38/012bf76752e1f89ed50b77b99532d90f3a3e287bc7918e1fc0948ac866ac/ruff-0.15.14-py3-none-win32.whl", hash = "sha256:6d0c1ad2a0ab718d39b6d8fd2217981ce4d625cd96a720095f798fb47d8b13e6", size = 10805548, upload-time = "2026-05-21T14:34:33.453Z" }, + { url = "https://files.pythonhosted.org/packages/d1/b7/4ea2c170f10ad760fff2a5250beb18897719dc8b52b53a24cddbb9dd3f19/ruff-0.15.14-py3-none-win_amd64.whl", hash = "sha256:802342981e056db3851a7836e5b070f8f15f67d4a685ae2a6160939d364b2902", size = 11939523, upload-time = "2026-05-21T14:34:18.077Z" }, + { url = "https://files.pythonhosted.org/packages/62/d5/bc97ff895ec35cf3925d4bd60f3b39d822f377a446906ec9bcc87405e59b/ruff-0.15.14-py3-none-win_arm64.whl", hash = "sha256:ff47b90a9ef6a40c9e2f3b479c1fb78531adf055b94c1eba0a7ba04b31951826", size = 11208607, upload-time = "2026-05-21T14:34:26.525Z" }, ] [[package]] From 04abb9b28fb96ae7efe7eafc6ab88062817d6729 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C6=B0u=20Quang=20V=C5=A9?= Date: Fri, 22 May 2026 11:32:08 +0700 Subject: [PATCH 263/291] Include Pyright in the type-checking pipeline and small improvements --- .github/workflows/lint.yaml | 7 ++++-- Dockerfile | 2 -- README.md | 8 ++++--- README.zh.md | 8 ++++--- app/server/chat.py | 48 ++++++++++++++++++++++++++----------- app/utils/config.py | 15 ++++++------ pyproject.toml | 1 + uv.lock | 42 +++++++++++++++++++++++++------- 8 files changed, 91 insertions(+), 40 deletions(-) diff --git a/.github/workflows/lint.yaml b/.github/workflows/lint.yaml index dc799c9..4d46e56 100644 --- a/.github/workflows/lint.yaml +++ b/.github/workflows/lint.yaml @@ -32,10 +32,13 @@ jobs: run: uv sync --all-groups - name: Run Ruff - run: uv run ruff check . + run: uv run ruff check - name: Run Ruff Format - run: uv run ruff format . --check + run: uv run ruff format --check - name: Run Ty Check run: uv run ty check + + - name: Run Pyright + run: uv run pyright diff --git a/Dockerfile b/Dockerfile index 2499479..bb96c27 100644 --- a/Dockerfile +++ b/Dockerfile @@ -7,8 +7,6 @@ USER root WORKDIR /app -SHELL ["/bin/bash", "-o", "pipefail", "-c"] - RUN apt-get update && apt-get install -y --no-install-recommends \ tini curl ca-certificates git \ && rm -rf /var/lib/apt/lists/* diff --git a/README.md b/README.md index 12b5bd9..f425323 100644 --- a/README.md +++ b/README.md @@ -248,9 +248,11 @@ You can define custom models in `config/config.yaml` or via environment variable gemini: model_strategy: "append" # "append" (default + custom) or "overwrite" (custom only) models: - - model_name: "gemini-3.0-pro" + - model_name: "xxx" model_header: - x-goog-ext-525001261-jspb: '[1,null,null,null,"9d8ca3786ebdfbea",null,null,0,[4],null,null,1]' + x-goog-ext-525001261-jspb: '[1,null,null,null,"fbb127bbb056c959",null,null,0,[4,5,6,8],null,null,1,null,null,1,1,"EA3C5672-E422-4A5F-BE26-B5B57D3B9AC3"]' + x-goog-ext-73010989-jspb: "[0]" + x-goog-ext-73010990-jspb: "[0,0,0]" ``` #### Environment Variables @@ -259,7 +261,7 @@ You can supply models as a JSON string or list structure via `CONFIG_GEMINI__MOD ```bash export CONFIG_GEMINI__MODEL_STRATEGY="overwrite" -export CONFIG_GEMINI__MODELS='[{"model_name": "gemini-3.0-pro", "model_header": {"x-goog-ext-525001261-jspb": "[1,null,null,null,\"9d8ca3786ebdfbea\",null,null,0,[4],null,null,1]"}}]' +export CONFIG_GEMINI__MODELS='[{"model_name": "xxx", "model_header": {"x-goog-ext-525001261-jspb": "[1,null,null,null,\"fbb127bbb056c959\",null,null,0,\[4,5,6,8\],null,null,1,null,null,1,1,\"EA3C5672-E422-4A5F-BE26-B5B57D3B9AC3\"]", "x-goog-ext-73010989-jspb": "[0]", "x-goog-ext-73010990-jspb": "[0,0,0]"}}]' ``` ## Acknowledgments diff --git a/README.zh.md b/README.zh.md index ef09562..27ecebc 100644 --- a/README.zh.md +++ b/README.zh.md @@ -244,9 +244,11 @@ gemini: gemini: model_strategy: "append" # "append" (默认 + 自定义) 或 "overwrite" (仅限自定义) models: - - model_name: "gemini-3.0-pro" + - model_name: "xxx" model_header: - x-goog-ext-525001261-jspb: '[1,null,null,null,"9d8ca3786ebdfbea",null,null,0,[4],null,null,1]' + x-goog-ext-525001261-jspb: '[1,null,null,null,"fbb127bbb056c959",null,null,0,[4,5,6,8],null,null,1,null,null,1,1,"EA3C5672-E422-4A5F-BE26-B5B57D3B9AC3"]' + x-goog-ext-73010989-jspb: "[0]" + x-goog-ext-73010990-jspb: "[0,0,0]" ``` #### 环境变量 @@ -255,7 +257,7 @@ gemini: ```bash export CONFIG_GEMINI__MODEL_STRATEGY="overwrite" -export CONFIG_GEMINI__MODELS='[{"model_name": "gemini-3.0-pro", "model_header": {"x-goog-ext-525001261-jspb": "[1,null,null,null,\"9d8ca3786ebdfbea\",null,null,0,[4],null,null,1]"}}]' +export CONFIG_GEMINI__MODELS='[{"model_name": "xxx", "model_header": {"x-goog-ext-525001261-jspb": "[1,null,null,null,\"fbb127bbb056c959\",null,null,0,\[4,5,6,8\],null,null,1,null,null,1,1,\"EA3C5672-E422-4A5F-BE26-B5B57D3B9AC3\"]", "x-goog-ext-73010989-jspb": "[0]", "x-goog-ext-73010990-jspb": "[0,0,0]"}}]' ``` ## 鸣谢 diff --git a/app/server/chat.py b/app/server/chat.py index e270fac..c2ae616 100644 --- a/app/server/chat.py +++ b/app/server/chat.py @@ -92,6 +92,14 @@ class StructuredOutputRequirement: raw_format: dict[str, Any] +type ProcessedImageData = tuple[str, int | None, int | None, str, str] +type ProcessedMediaData = dict[str, tuple[str, str]] +type ProcessedImageResult = tuple[Literal["image"], Image, ProcessedImageData] +type ProcessedMediaResult = tuple[ + Literal["media"], GeneratedVideo | GeneratedMedia, ProcessedMediaData +] + + # --- Helper Functions --- @@ -693,7 +701,7 @@ def _prepare_messages_for_model( def _convert_responses_to_app_messages( items: Any, ) -> list[AppMessage]: - """Convert Responses API input items into internal AppMessage objects.""" + """Convert Responses API input items into internal AppMessage objects, skipping incomplete tool calls.""" messages: list[AppMessage] = [] if isinstance(items, str): @@ -747,12 +755,18 @@ def _convert_responses_to_app_messages( ) elif isinstance(item, ResponseFunctionToolCall): + call_id = item.call_id or item.id + if not call_id or not item.name or item.arguments is None: + logger.warning( + f"Skipping incomplete function_call input item: {reprlib.repr(item.model_dump(mode='json'))}" + ) + continue messages.append( AppMessage( role="assistant", tool_calls=[ AppToolCall( - id=item.call_id, + id=call_id, type="function", function=AppToolCallFunction(name=item.name, arguments=item.arguments), ) @@ -1140,8 +1154,8 @@ def flush(self) -> str: # --- Media Processing Helpers --- -async def _process_image_item(image: Image): - """Process an image item by converting it to base64 and returning a standard result tuple.""" +async def _process_image_item(image: Image) -> ProcessedImageResult | None: + """Process an image item by converting it to base64 and returning a typed image result tuple.""" try: media_store = get_media_store_dir() return "image", image, await _image_to_base64(image, media_store) @@ -1150,8 +1164,10 @@ async def _process_image_item(image: Image): return None -async def _process_media_item(media_item: GeneratedVideo | GeneratedMedia): - """Process a media item by saving it to a local file and returning a standard result tuple.""" +async def _process_media_item( + media_item: GeneratedVideo | GeneratedMedia, +) -> ProcessedMediaResult | None: + """Process a media item by saving it to local files and returning a typed media result tuple.""" try: media_store = get_media_store_dir() return "media", media_item, await _media_to_local_file(media_item, media_store) @@ -2305,9 +2321,10 @@ async def create_chat_completion( for res in results: if not res: continue - rtype, original_item, media_data = res - if rtype == "image": + if res[0] == "image": + original_item = res[1] + media_data = res[2] _, _, _, fname, fhash = media_data if fhash in seen_hashes: (media_store / fname).unlink(missing_ok=True) @@ -2319,7 +2336,9 @@ async def create_chat_completion( title = getattr(original_item, "title", "Image") image_markdown += f"\n\n![{title}]({img_url})" - elif rtype == "media": + elif res[0] == "media": + original_item = res[1] + media_data = res[2] m_dict = media_data if not m_dict: continue @@ -2518,8 +2537,7 @@ async def create_response( ) images = resp_or_stream.images or [] if ( - request.tool_choice is not None - and hasattr(request.tool_choice, "type") + isinstance(request.tool_choice, ToolChoiceTypes) and request.tool_choice.type == "image_generation" ) and not images: raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail="No images returned.") @@ -2546,9 +2564,9 @@ async def create_response( for res in results: if not res: continue - rtype, original_item, media_data = res - if rtype == "image": + if res[0] == "image": + media_data = res[2] b64, w, h, fname, fhash = media_data if fhash in seen_hashes: (media_store / fname).unlink(missing_ok=True) @@ -2565,7 +2583,9 @@ async def create_response( ) ) - elif rtype == "media": + elif res[0] == "media": + original_item = res[1] + media_data = res[2] m_dict = media_data if not m_dict: continue diff --git a/app/utils/config.py b/app/utils/config.py index 66e5de7..c2effde 100644 --- a/app/utils/config.py +++ b/app/utils/config.py @@ -288,10 +288,10 @@ def extract_gemini_clients_env() -> dict[int, dict[str, Any]]: def _merge_clients_with_env( base_clients: list[GeminiClientSettings] | None, env_overrides: dict[int, dict[str, Any]], -): - """Override base_clients with env_overrides, return the new clients list.""" +) -> list[GeminiClientSettings]: + """Return Gemini clients with environment overrides applied to the base list.""" if not env_overrides: - return base_clients + return base_clients or [] result_clients: list[GeminiClientSettings] = [] if base_clients: result_clients = [client.model_copy() for client in base_clients] @@ -309,7 +309,7 @@ def _merge_clients_with_env( f"Client index {idx} in env is out of range (current count: {len(result_clients)}). " "Client indices must be contiguous starting from 0." ) - return result_clients or base_clients + return result_clients or base_clients or [] def extract_gemini_models_env() -> dict[int, dict[str, Any]]: @@ -372,15 +372,16 @@ def _merge_models_with_env( def initialize_config() -> Config: """ - Initialize the configuration. + Initialize configuration from environment variables and the YAML settings source. Returns: - Config: Configuration object + Config: Configuration object with Gemini client and model overrides merged """ try: env_clients_overrides = extract_gemini_clients_env() env_models_overrides = extract_gemini_models_env() - config = Config() + settings_cls: type[Any] = Config + config = cast(Config, settings_cls()) config.gemini.clients = _merge_clients_with_env( config.gemini.clients, env_clients_overrides diff --git a/pyproject.toml b/pyproject.toml index 2bc8a4e..4912505 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -22,6 +22,7 @@ Repository = "https://github.com/Nativu5/Gemini-FastAPI" [project.optional-dependencies] dev = [ + "pyright", "ruff", "ty", ] diff --git a/uv.lock b/uv.lock index 9187ae9..1c62c45 100644 --- a/uv.lock +++ b/uv.lock @@ -66,14 +66,14 @@ wheels = [ [[package]] name = "click" -version = "8.4.0" +version = "8.4.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "colorama", marker = "sys_platform == 'win32'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/23/e4/796662cd90cf80e3a363c99db2b88e0e394b988a575f60a17e16440cd011/click-8.4.0.tar.gz", hash = "sha256:638f1338fe1235c8f4e008e4a8a254fb5c5fbdcbb40ece3c9142ebb78e792973", size = 350843, upload-time = "2026-05-17T00:47:58.425Z" } +sdist = { url = "https://files.pythonhosted.org/packages/9b/98/518d8e5081007684232226f475082b30087d0f585e8457db087298259f49/click-8.4.1.tar.gz", hash = "sha256:918b5633eddf6b41c32d4f454bf0de810065c74e3f7dbf8ee5452f8be88d3e96", size = 353007, upload-time = "2026-05-22T04:08:37.769Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ee/ae/8e92f8058baf87f6c7d86ee7e457668690195cc77efedb8d3797a06e3940/click-8.4.0-py3-none-any.whl", hash = "sha256:40c50b7c6c6adac2823d411041ec84f3f103f1b280d5e9ce0d7f998995832f81", size = 116147, upload-time = "2026-05-17T00:47:56.842Z" }, + { url = "https://files.pythonhosted.org/packages/c7/0d/67e5b4109ea4a837e80daa87c2c696711955e40449a97e8926672534def2/click-8.4.1-py3-none-any.whl", hash = "sha256:482be17c6991b8c19c5429a1e995d9b0efdbb63172824c41f99965dc0ade8ec2", size = 116639, upload-time = "2026-05-22T04:08:35.26Z" }, ] [[package]] @@ -145,6 +145,7 @@ dependencies = [ [package.optional-dependencies] dev = [ + { name = "pyright" }, { name = "ruff" }, { name = "ty" }, ] @@ -164,6 +165,7 @@ requires-dist = [ { name = "loguru", specifier = ">=0.7.3" }, { name = "orjson", specifier = ">=3.11.9" }, { name = "pydantic-settings", extras = ["yaml"], specifier = ">=2.14.1" }, + { name = "pyright", marker = "extra == 'dev'" }, { name = "ruff", marker = "extra == 'dev'" }, { name = "ty", marker = "extra == 'dev'" }, { name = "uvicorn", specifier = ">=0.47.0" }, @@ -211,11 +213,11 @@ wheels = [ [[package]] name = "idna" -version = "3.15" +version = "3.16" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/82/77/7b3966d0b9d1d31a36ddf1746926a11dface89a83409bf1483f0237aa758/idna-3.15.tar.gz", hash = "sha256:ca962446ea538f7092a95e057da437618e886f4d349216d2b1e294abfdb65fdc", size = 199245, upload-time = "2026-05-12T22:45:57.011Z" } +sdist = { url = "https://files.pythonhosted.org/packages/1a/88/bcf9709822fe69d02c2a6a77956c98ce6ea8ca8767a9aadcedc7eb6a2390/idna-3.16.tar.gz", hash = "sha256:d7a6da03db833450fca25d2358ac9ff06cd624577a4aea3a596d5c0f77b8e03d", size = 203770, upload-time = "2026-05-22T00:16:18.781Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d2/23/408243171aa9aaba178d3e2559159c24c1171a641aa83b67bdd3394ead8e/idna-3.15-py3-none-any.whl", hash = "sha256:048adeaf8c2d788c40fee287673ccaa74c24ffd8dcf09ffa555a2fbb59f10ac8", size = 72340, upload-time = "2026-05-12T22:45:55.733Z" }, + { url = "https://files.pythonhosted.org/packages/94/16/70255075a9859a0e3adb789b68ceb0e210dec03934245fd98d248226572f/idna-3.16-py3-none-any.whl", hash = "sha256:cc246e3a3f89580c3a951b5ad298ca4638078b2cdd4f115654332b5c26daded5", size = 74165, upload-time = "2026-05-22T00:16:16.698Z" }, ] [[package]] @@ -266,6 +268,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" }, ] +[[package]] +name = "nodeenv" +version = "1.10.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/24/bf/d1bda4f6168e0b2e9e5958945e01910052158313224ada5ce1fb2e1113b8/nodeenv-1.10.0.tar.gz", hash = "sha256:996c191ad80897d076bdfba80a41994c2b47c68e224c542b48feba42ba00f8bb", size = 55611, upload-time = "2025-12-20T14:08:54.006Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/88/b2/d0896bdcdc8d28a7fc5717c305f1a861c26e18c05047949fb371034d98bd/nodeenv-1.10.0-py2.py3-none-any.whl", hash = "sha256:5bb13e3eed2923615535339b3c620e76779af4cb4c6a90deccc9e36b274d3827", size = 23438, upload-time = "2025-12-20T14:08:52.782Z" }, +] + [[package]] name = "orjson" version = "3.11.9" @@ -366,6 +377,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, ] +[[package]] +name = "pyright" +version = "1.1.409" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "nodeenv" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/51/4e/3aa27f74211522dba7e9cbc3e74de779c6d4b654c54e50a4840623be8014/pyright-1.1.409.tar.gz", hash = "sha256:986ee05beca9e077c165758ad123667c679e050059a2546aa02473930394bc93", size = 4430434, upload-time = "2026-04-23T11:02:03.799Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/16/6b/330d8ebae582b30c2959a1ef4c3bc344ebde48c2ff0c3f113c4710735e11/pyright-1.1.409-py3-none-any.whl", hash = "sha256:aa3ea228cab90c845c7a60d28db7a844c04315356392aa09fafcee98c8c22fb3", size = 6438161, upload-time = "2026-04-23T11:02:01.309Z" }, +] + [[package]] name = "python-dotenv" version = "1.2.2" @@ -433,14 +457,14 @@ wheels = [ [[package]] name = "starlette" -version = "1.0.0" +version = "1.0.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/81/69/17425771797c36cded50b7fe44e850315d039f28b15901ab44839e70b593/starlette-1.0.0.tar.gz", hash = "sha256:6a4beaf1f81bb472fd19ea9b918b50dc3a77a6f2e190a12954b25e6ed5eea149", size = 2655289, upload-time = "2026-03-22T18:29:46.779Z" } +sdist = { url = "https://files.pythonhosted.org/packages/08/a3/84e821cc54b4ab50ae6dbc6ac3800a651b65ec35f045cc73785380654057/starlette-1.0.1.tar.gz", hash = "sha256:512399c5f1de7fac99c88572212ded9ddeddef2fb32afa82d724000e88b38f4f", size = 2659596, upload-time = "2026-05-21T21:58:58.433Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/0b/c9/584bc9651441b4ba60cc4d557d8a547b5aff901af35bda3a4ee30c819b82/starlette-1.0.0-py3-none-any.whl", hash = "sha256:d3ec55e0bb321692d275455ddfd3df75fff145d009685eb40dc91fc66b03d38b", size = 72651, upload-time = "2026-03-22T18:29:45.111Z" }, + { url = "https://files.pythonhosted.org/packages/ec/e1/b2df4bc09a1e51ff664c1e17018a4274b42e5e9352e4a478ea540512dc88/starlette-1.0.1-py3-none-any.whl", hash = "sha256:7c0e69b2ee1c848bd54669d908500117a3ee13de603a21427e5c6fc1adf98dcd", size = 72802, upload-time = "2026-05-21T21:58:56.551Z" }, ] [[package]] From 1abc647b11b63479d3061597054676dce2aabe0c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C6=B0u=20Quang=20V=C5=A9?= Date: Fri, 22 May 2026 22:43:18 +0700 Subject: [PATCH 264/291] Implement a new helper to get compute usage based info --- uv.lock | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/uv.lock b/uv.lock index 1c62c45..1548cdd 100644 --- a/uv.lock +++ b/uv.lock @@ -178,8 +178,8 @@ dev = [{ name = "gemini-fastapi", extras = ["dev"] }] [[package]] name = "gemini-webapi" -version = "0.0.post290" -source = { git = "https://github.com/luuquangvu/Gemini-API.git?rev=enable-guest-mode#af7fc6fec641c4dfa26527d25d9b518eb001d14a" } +version = "0.0.post291" +source = { git = "https://github.com/luuquangvu/Gemini-API.git?rev=enable-guest-mode#e3d2b7349cb29b2e392d00225ae4f82b2a277ef2" } dependencies = [ { name = "curl-cffi" }, { name = "loguru" }, From 103606c65ff8f00be28f831083649145ddc57868 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C6=B0u=20Quang=20V=C5=A9?= Date: Fri, 22 May 2026 23:19:43 +0700 Subject: [PATCH 265/291] Update helper to get compute-based usage limits info --- uv.lock | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/uv.lock b/uv.lock index 1548cdd..c614de1 100644 --- a/uv.lock +++ b/uv.lock @@ -178,8 +178,8 @@ dev = [{ name = "gemini-fastapi", extras = ["dev"] }] [[package]] name = "gemini-webapi" -version = "0.0.post291" -source = { git = "https://github.com/luuquangvu/Gemini-API.git?rev=enable-guest-mode#e3d2b7349cb29b2e392d00225ae4f82b2a277ef2" } +version = "0.0.post292" +source = { git = "https://github.com/luuquangvu/Gemini-API.git?rev=enable-guest-mode#fb4e01e812ddffa38fe40918055bffbb71913679" } dependencies = [ { name = "curl-cffi" }, { name = "loguru" }, From c0b59d916ecefbb61ba317e6570c02906dc8f72a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C6=B0u=20Quang=20V=C5=A9?= Date: Mon, 25 May 2026 10:19:37 +0700 Subject: [PATCH 266/291] Cache the available model list to prevent it from becoming empty after being idle --- app/main.py | 2 ++ app/server/chat.py | 25 +++++++++++++++++++++++-- 2 files changed, 25 insertions(+), 2 deletions(-) diff --git a/app/main.py b/app/main.py index c856546..a1c275c 100644 --- a/app/main.py +++ b/app/main.py @@ -4,6 +4,7 @@ from fastapi import FastAPI from loguru import logger +from .server.chat import refresh_available_models_cache from .server.chat import router as chat_router from .server.health import router as health_router from .server.media import router as media_router @@ -55,6 +56,7 @@ async def lifespan(app: FastAPI): pool = GeminiClientPool() try: await pool.init() + await refresh_available_models_cache(pool) except Exception as e: logger.exception(f"Failed to initialize Gemini clients: {e}") raise diff --git a/app/server/chat.py b/app/server/chat.py index c2ae616..d810735 100644 --- a/app/server/chat.py +++ b/app/server/chat.py @@ -80,6 +80,8 @@ MAX_CHARS_PER_REQUEST = int(g_config.gemini.max_chars_per_request * 0.9) router = APIRouter() +_AVAILABLE_MODELS_CACHE: list[ModelData] | None = None +_AVAILABLE_MODELS_CACHE_LOCK = asyncio.Lock() @dataclass @@ -921,8 +923,8 @@ def _get_model_by_name(name: str) -> Model: return Model.from_name(name) -async def _get_available_models(pool: GeminiClientPool) -> list[ModelData]: - """Return a list of available models based on the configuration strategy and per-client accounts.""" +async def _build_available_models(pool: GeminiClientPool) -> list[ModelData]: + """Build the available model list from configured models and currently running clients.""" now = int(datetime.now(tz=UTC).timestamp()) strategy = g_config.gemini.model_strategy models_data = [] @@ -960,6 +962,25 @@ async def _get_available_models(pool: GeminiClientPool) -> list[ModelData]: return models_data +async def refresh_available_models_cache(pool: GeminiClientPool) -> list[ModelData]: + """Refresh and return the cached model list while clients are available.""" + global _AVAILABLE_MODELS_CACHE + + async with _AVAILABLE_MODELS_CACHE_LOCK: + models = await _build_available_models(pool) + _AVAILABLE_MODELS_CACHE = models + logger.info(f"Cached {len(models)} available model(s).") + return list(models) + + +async def _get_available_models(pool: GeminiClientPool) -> list[ModelData]: + """Return cached available models, populating the cache if it has not been warmed yet.""" + if _AVAILABLE_MODELS_CACHE is not None: + return list(_AVAILABLE_MODELS_CACHE) + + return await refresh_available_models_cache(pool) + + async def _find_reusable_session( db: LMDBConversationStore, pool: GeminiClientPool, From c448cfba100157bc605146f07c5c089af1ed6240 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C6=B0u=20Quang=20V=C5=A9?= Date: Mon, 25 May 2026 13:54:38 +0700 Subject: [PATCH 267/291] Apply strict rules for a structured JSON format --- app/server/chat.py | 43 +++++++++++++++++++++++++++---------------- app/utils/helper.py | 20 ++++++++++++++++---- 2 files changed, 43 insertions(+), 20 deletions(-) diff --git a/app/server/chat.py b/app/server/chat.py index d810735..d41b6e7 100644 --- a/app/server/chat.py +++ b/app/server/chat.py @@ -66,6 +66,7 @@ from app.utils.helper import ( STREAM_MASTER_RE, STREAM_TAIL_RE, + STRUCTURED_JSON_WRAP_HINT, TOOL_HINT_STRIPPED, TOOL_WRAP_HINT, detect_image_extension, @@ -73,6 +74,7 @@ extract_image_dimensions, extract_tool_calls, normalize_llm_text, + strip_markdown_fence, strip_system_hints, text_from_message, ) @@ -348,13 +350,31 @@ def _create_chat_completion_standard_payload( ) +def _canonicalize_structured_output( + visible_output: str, structured_requirement: StructuredOutputRequirement +) -> str | None: + """Parse raw or fenced structured JSON and return its canonical JSON representation.""" + candidate = strip_markdown_fence(visible_output) + try: + structured_payload = orjson.loads(candidate) + except orjson.JSONDecodeError: + logger.warning( + f"Failed to decode JSON for structured response (schema={structured_requirement.schema_name})." + ) + return None + + canonical_output = orjson.dumps(structured_payload).decode("utf-8") + logger.debug(f"Structured response fulfilled (schema={structured_requirement.schema_name}).") + return canonical_output + + def _process_llm_output( thoughts: str | None, raw_text: str, structured_requirement: StructuredOutputRequirement | None, ) -> tuple[str | None, str, str, list[AppToolCall]]: """ - Post-process Gemini output to extract tool calls and prepare clean text for display and storage. + Post-process Gemini output to extract tool calls, unwrap structured JSON fences, and prepare clean text for display and storage. Returns: (thoughts, visible_text, storage_output, tool_calls) """ if thoughts: @@ -368,18 +388,10 @@ def _process_llm_output( storage_output = visible_output if structured_requirement and visible_output: - try: - structured_payload = orjson.loads(visible_output) - canonical_output = orjson.dumps(structured_payload).decode("utf-8") + canonical_output = _canonicalize_structured_output(visible_output, structured_requirement) + if canonical_output: visible_output = canonical_output storage_output = canonical_output - logger.debug( - f"Structured response fulfilled (schema={structured_requirement.schema_name})." - ) - except orjson.JSONDecodeError: - logger.warning( - f"Failed to decode JSON for structured response (schema={structured_requirement.schema_name})." - ) return thoughts, visible_output, storage_output, tool_calls @@ -491,7 +503,7 @@ def _persist_conversation( def _build_structured_requirement( response_format: dict[str, Any] | None, ) -> StructuredOutputRequirement | None: - """Translate OpenAI-style response_format into internal instructions.""" + """Translate OpenAI-style response_format into helper-managed fenced JSON instructions.""" if not response_format or not isinstance(response_format, dict): return None @@ -520,16 +532,15 @@ def _build_structured_requirement( 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.", + STRUCTURED_JSON_WRAP_HINT, f'Schema name: "{schema_name}"', "JSON Schema:", pretty_schema, ] - if not strict: + if strict: instruction_parts.insert( 1, - "The schema allows unspecified fields, but include only what is necessary to satisfy the user's request.", + "Strict schema adherence is required: the JSON must conform exactly to the schema.", ) instruction = "\n\n".join(instruction_parts) diff --git a/app/utils/helper.py b/app/utils/helper.py index 892e216..88e42a4 100644 --- a/app/utils/helper.py +++ b/app/utils/helper.py @@ -38,6 +38,16 @@ "[/ToolCalls]\n\n" "CRITICAL: Do NOT mix natural language with protocol tags. Either respond naturally OR provide the protocol block alone. There is no middle ground." ) +STRUCTURED_JSON_WRAP_HINT = ( + "\n\n### SYSTEM: STRUCTURED JSON PROTOCOL (MANDATORY) ###\n" + "Return ONLY one markdown code block containing a single strict JSON document that conforms to the provided JSON Schema.\n" + "Use ```json by default. If the JSON contains backticks, the outer fence MUST be longer than any backtick sequence inside (e.g., ````json).\n" + "REQUIRED SYNTAX:\n" + "```json\n" + '{"field":"value"}\n' + "```\n\n" + "CRITICAL: Do NOT mix natural language with the fenced JSON block. Provide the protocol block alone. There is no middle ground." +) TOOL_BLOCK_RE = re.compile( r"\\?\[ToolCalls\\?](.*?)\\?\[\\?/ToolCalls\\?]", re.DOTALL | re.IGNORECASE, @@ -144,10 +154,12 @@ def unescape_text(s: str) -> str: return COMMONMARK_UNESCAPE_RE.sub(r"\1", s) if s else "" -def _strip_param_fences(s: str) -> str: +def strip_markdown_fence(s: str) -> str: """ - Remove one layer of outermost Markdown code fences, - supporting nested blocks by detecting variable fence lengths. + Remove one outer Markdown code fence layer for protected LLM payloads. + + The fence length is detected from the opening fence so tool parameters and + structured JSON can safely contain shorter backtick sequences inside. """ s = s.strip() if not s: @@ -172,7 +184,7 @@ def _parse_tool_argument_value(raw_value: str) -> JsonValue: JSON literals, arrays, and objects are preserved so downstream clients receive strict argument types, while plain text values remain strings for compatibility. """ - value = _strip_param_fences(raw_value) + value = strip_markdown_fence(raw_value) if not value: return "" From 3816709bbab94729fdab08880cceace2910821f4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C6=B0u=20Quang=20V=C5=A9?= Date: Mon, 25 May 2026 14:24:40 +0700 Subject: [PATCH 268/291] Fix leaked structured JSON format when using streaming mode --- app/server/chat.py | 152 +++++++++++++++++++++++++++++---------------- 1 file changed, 100 insertions(+), 52 deletions(-) diff --git a/app/server/chat.py b/app/server/chat.py index d41b6e7..994ed05 100644 --- a/app/server/chat.py +++ b/app/server/chat.py @@ -1275,7 +1275,9 @@ def make_chunk(delta_content: dict) -> str: if text_delta := chunk.text_delta: full_text += text_delta - if visible_delta := suppressor.process(text_delta): + if not structured_requirement and ( + visible_delta := suppressor.process(text_delta) + ): yield make_chunk( {"delta": {"content": visible_delta}, "finish_reason": None} ) @@ -1314,19 +1316,21 @@ def make_chunk(delta_content: dict) -> str: if f_len > c_len and f_text.startswith(full_text): drift = f_text[c_len:] full_text = f_text - if visible_drift := suppressor.process(drift): + if not structured_requirement and (visible_drift := suppressor.process(drift)): yield make_chunk( {"delta": {"content": visible_drift}, "finish_reason": None} ) - if remaining_text := suppressor.flush(): + if not structured_requirement and (remaining_text := suppressor.flush()): yield make_chunk({"delta": {"content": remaining_text}, "finish_reason": None}) - _, _, storage_output, detected_tool_calls = _process_llm_output( + _, visible_output, storage_output, detected_tool_calls = _process_llm_output( normalize_llm_text(full_thoughts or ""), normalize_llm_text(full_text or ""), structured_requirement, ) + if structured_requirement and visible_output: + yield make_chunk({"delta": {"content": visible_output}, "finish_reason": None}) seen_hashes = {} seen_media_hashes = {} @@ -1607,6 +1611,7 @@ async def _make_async_gen(item: ModelOutput) -> AsyncGenerator[ModelOutput]: ) if chunk.text_delta: + full_text += chunk.text_delta if thought_open: yield make_event( "response.reasoning_summary_text.done", @@ -1648,54 +1653,54 @@ async def _make_async_gen(item: ModelOutput) -> AsyncGenerator[ModelOutput]: ) thought_open = False - if not message_open: - message_index = next_output_index - next_output_index += 1 - yield make_event( - "response.output_item.added", - { - **base_event, - "type": "response.output_item.added", - "output_index": message_index, - "item": ResponseOutputMessage( - id=message_item_id, - type="message", - status="in_progress", - role="assistant", - content=[], - ).model_dump(mode="json"), - }, - ) + if not structured_requirement: + if not message_open: + message_index = next_output_index + next_output_index += 1 + yield make_event( + "response.output_item.added", + { + **base_event, + "type": "response.output_item.added", + "output_index": message_index, + "item": ResponseOutputMessage( + id=message_item_id, + type="message", + status="in_progress", + role="assistant", + content=[], + ).model_dump(mode="json"), + }, + ) - yield make_event( - "response.content_part.added", - { - **base_event, - "type": "response.content_part.added", - "item_id": message_item_id, - "output_index": message_index, - "content_index": 0, - "part": ResponseOutputText(type="output_text", text="").model_dump( - mode="json" - ), - }, - ) - message_open = True + yield make_event( + "response.content_part.added", + { + **base_event, + "type": "response.content_part.added", + "item_id": message_item_id, + "output_index": message_index, + "content_index": 0, + "part": ResponseOutputText( + type="output_text", text="" + ).model_dump(mode="json"), + }, + ) + message_open = True - full_text += chunk.text_delta - if visible := suppressor.process(chunk.text_delta): - yield make_event( - "response.output_text.delta", - { - **base_event, - "type": "response.output_text.delta", - "item_id": message_item_id, - "output_index": message_index, - "content_index": 0, - "delta": visible, - "logprobs": [], - }, - ) + if visible := suppressor.process(chunk.text_delta): + yield make_event( + "response.output_text.delta", + { + **base_event, + "type": "response.output_text.delta", + "item_id": message_item_id, + "output_index": message_index, + "content_index": 0, + "delta": visible, + "logprobs": [], + }, + ) for img in chunk.images or []: if img.url and img.url not in seen_image_urls: @@ -1777,7 +1782,7 @@ async def _make_async_gen(item: ModelOutput) -> AsyncGenerator[ModelOutput]: if l_len > c_len and l_text.startswith(full_text): drift = l_text[c_len:] full_text = l_text - if visible := suppressor.process(drift): + if not structured_requirement and (visible := suppressor.process(drift)): if not message_open: message_index = next_output_index next_output_index += 1 @@ -1824,7 +1829,7 @@ async def _make_async_gen(item: ModelOutput) -> AsyncGenerator[ModelOutput]: }, ) - remaining = suppressor.flush() + remaining = "" if structured_requirement else suppressor.flush() if remaining and message_open: yield make_event( "response.output_text.delta", @@ -1883,6 +1888,49 @@ async def _make_async_gen(item: ModelOutput) -> AsyncGenerator[ModelOutput]: structured_requirement, ) + if structured_requirement and assistant_text and not message_open: + message_index = next_output_index + next_output_index += 1 + yield make_event( + "response.output_item.added", + { + **base_event, + "type": "response.output_item.added", + "output_index": message_index, + "item": ResponseOutputMessage( + id=message_item_id, + type="message", + status="in_progress", + role="assistant", + content=[], + ).model_dump(mode="json"), + }, + ) + yield make_event( + "response.content_part.added", + { + **base_event, + "type": "response.content_part.added", + "item_id": message_item_id, + "output_index": message_index, + "content_index": 0, + "part": ResponseOutputText(type="output_text", text="").model_dump(mode="json"), + }, + ) + message_open = True + yield make_event( + "response.output_text.delta", + { + **base_event, + "type": "response.output_text.delta", + "item_id": message_item_id, + "output_index": message_index, + "content_index": 0, + "delta": assistant_text, + "logprobs": [], + }, + ) + image_items = [] seen_hashes = {} seen_media_hashes = {} From a1d9c537ea3be1f70f95018cecbc949b9a5118ca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C6=B0u=20Quang=20V=C5=A9?= Date: Thu, 28 May 2026 10:47:33 +0700 Subject: [PATCH 269/291] Improve performance by switching from a stateless to a stateful parser to prevent CPU spikes when handling large output frames --- app/server/chat.py | 6 ++-- pyproject.toml | 6 ++-- uv.lock | 88 +++++++++++++++++++++++----------------------- 3 files changed, 51 insertions(+), 49 deletions(-) diff --git a/app/server/chat.py b/app/server/chat.py index 994ed05..e1abea5 100644 --- a/app/server/chat.py +++ b/app/server/chat.py @@ -1227,6 +1227,7 @@ def _create_real_streaming_response( """ Create a real-time streaming response. Reconciles manual delta accumulation with the model's final authoritative state. + Emits typed image and media results as incremental markdown deltas. """ async def generate_stream(): @@ -1369,7 +1370,7 @@ def make_chunk(delta_content: dict) -> str: yield make_chunk({"delta": {"content": f"\n\n{md}"}, "finish_reason": None}) elif rtype == "media": - m_dict = media_data + m_dict = cast(ProcessedMediaData, media_data) if not m_dict: continue @@ -1481,6 +1482,7 @@ def _create_responses_real_streaming_response( """ Create a real-time streaming response for the Responses API. Ensures final accumulated text and thoughts are synchronized and follow the formal event stream spec. + Emits typed image and media results as incremental response output text events. """ base_event = { "id": response_id, @@ -2051,7 +2053,7 @@ async def _make_async_gen(item: ModelOutput) -> AsyncGenerator[ModelOutput]: image_items.append(img_item) elif rtype == "media": - m_dict = media_data + m_dict = cast(ProcessedMediaData, media_data) if not m_dict: continue diff --git a/pyproject.toml b/pyproject.toml index 4912505..88f6dc7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,14 +6,14 @@ readme = "README.md" requires-python = "==3.13.*" dependencies = [ "curl-cffi>=0.15.0", - "fastapi>=0.136.1", + "fastapi>=0.136.3", "gemini-webapi>=2.0.0", - "httptools>=0.7.1", + "httptools>=0.8.0", "lmdb>=2.2.0", "loguru>=0.7.3", "orjson>=3.11.9", "pydantic-settings[yaml]>=2.14.1", - "uvicorn>=0.47.0", + "uvicorn>=0.48.0", "uvloop>=0.22.1; sys_platform != 'win32'", ] diff --git a/uv.lock b/uv.lock index c614de1..a2fbae2 100644 --- a/uv.lock +++ b/uv.lock @@ -112,7 +112,7 @@ wheels = [ [[package]] name = "fastapi" -version = "0.136.1" +version = "0.136.3" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "annotated-doc" }, @@ -121,9 +121,9 @@ dependencies = [ { name = "typing-extensions" }, { name = "typing-inspection" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/5d/45/c130091c2dfa061bbfe3150f2a5091ef1adf149f2a8d2ae769ecaf6e99a2/fastapi-0.136.1.tar.gz", hash = "sha256:7af665ad7acfa0a3baf8983d393b6b471b9da10ede59c60045f49fbc89a0fa7f", size = 397448, upload-time = "2026-04-23T16:49:44.046Z" } +sdist = { url = "https://files.pythonhosted.org/packages/81/2d/ff8d91d7b564d464629a0fd50a4489c97fcb836ac230bf3a7269232a9b1f/fastapi-0.136.3.tar.gz", hash = "sha256:e487fae93ad408e6f47641ee4dfe389864fd7bec92e547ea8498fc13f43e83ab", size = 396410, upload-time = "2026-05-23T18:53:15.192Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/5a/ff/2e4eca3ade2c22fe1dea7043b8ee9dabe47753349eb1b56a202de8af6349/fastapi-0.136.1-py3-none-any.whl", hash = "sha256:a6e9d7eeada96c93a4d69cb03836b44fa34e2854accb7244a1ece36cd4781c3f", size = 117683, upload-time = "2026-04-23T16:49:42.437Z" }, + { url = "https://files.pythonhosted.org/packages/e0/82/45359b62a067409bd929ae8a56b8ed13e5a8c8a61194b3c236920999ab83/fastapi-0.136.3-py3-none-any.whl", hash = "sha256:3d2a69bdf04b7e9f3afa292c3bc7a98816bbfafa10bc9b45f3f3700d2f761620", size = 117481, upload-time = "2026-05-23T18:53:16.924Z" }, ] [[package]] @@ -158,9 +158,9 @@ dev = [ [package.metadata] requires-dist = [ { name = "curl-cffi", specifier = ">=0.15.0" }, - { name = "fastapi", specifier = ">=0.136.1" }, + { name = "fastapi", specifier = ">=0.136.3" }, { name = "gemini-webapi", git = "https://github.com/luuquangvu/Gemini-API.git?rev=enable-guest-mode" }, - { name = "httptools", specifier = ">=0.7.1" }, + { name = "httptools", specifier = ">=0.8.0" }, { name = "lmdb", specifier = ">=2.2.0" }, { name = "loguru", specifier = ">=0.7.3" }, { name = "orjson", specifier = ">=3.11.9" }, @@ -168,7 +168,7 @@ requires-dist = [ { name = "pyright", marker = "extra == 'dev'" }, { name = "ruff", marker = "extra == 'dev'" }, { name = "ty", marker = "extra == 'dev'" }, - { name = "uvicorn", specifier = ">=0.47.0" }, + { name = "uvicorn", specifier = ">=0.48.0" }, { name = "uvloop", marker = "sys_platform != 'win32'", specifier = ">=0.22.1" }, ] provides-extras = ["dev"] @@ -178,8 +178,8 @@ dev = [{ name = "gemini-fastapi", extras = ["dev"] }] [[package]] name = "gemini-webapi" -version = "0.0.post292" -source = { git = "https://github.com/luuquangvu/Gemini-API.git?rev=enable-guest-mode#fb4e01e812ddffa38fe40918055bffbb71913679" } +version = "0.0.post293" +source = { git = "https://github.com/luuquangvu/Gemini-API.git?rev=enable-guest-mode#b1a2b48a4cab4ba44c1dd3d2782822b3abf86b7e" } dependencies = [ { name = "curl-cffi" }, { name = "loguru" }, @@ -198,17 +198,17 @@ wheels = [ [[package]] name = "httptools" -version = "0.7.1" +version = "0.8.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/b5/46/120a669232c7bdedb9d52d4aeae7e6c7dfe151e99dc70802e2fc7a5e1993/httptools-0.7.1.tar.gz", hash = "sha256:abd72556974f8e7c74a259655924a717a2365b236c882c3f6f8a45fe94703ac9", size = 258961, upload-time = "2025-10-10T03:55:08.559Z" } +sdist = { url = "https://files.pythonhosted.org/packages/43/e5/d471fcb0e14523fe1c3f4ba58ca52480e7bd70ad7109a3846bc75892f7fb/httptools-0.8.0.tar.gz", hash = "sha256:6b2a32f18d97e16e90827d7a819ffa8dbd8cc245fc4e1fa9d1095b54ef4bd999", size = 271342, upload-time = "2026-05-25T22:17:48.841Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/09/8f/c77b1fcbfd262d422f12da02feb0d218fa228d52485b77b953832105bb90/httptools-0.7.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:6babce6cfa2a99545c60bfef8bee0cc0545413cb0018f617c8059a30ad985de3", size = 202889, upload-time = "2025-10-10T03:54:47.089Z" }, - { url = "https://files.pythonhosted.org/packages/0a/1a/22887f53602feaa066354867bc49a68fc295c2293433177ee90870a7d517/httptools-0.7.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:601b7628de7504077dd3dcb3791c6b8694bbd967148a6d1f01806509254fb1ca", size = 108180, upload-time = "2025-10-10T03:54:48.052Z" }, - { url = "https://files.pythonhosted.org/packages/32/6a/6aaa91937f0010d288d3d124ca2946d48d60c3a5ee7ca62afe870e3ea011/httptools-0.7.1-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:04c6c0e6c5fb0739c5b8a9eb046d298650a0ff38cf42537fc372b28dc7e4472c", size = 478596, upload-time = "2025-10-10T03:54:48.919Z" }, - { url = "https://files.pythonhosted.org/packages/6d/70/023d7ce117993107be88d2cbca566a7c1323ccbaf0af7eabf2064fe356f6/httptools-0.7.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:69d4f9705c405ae3ee83d6a12283dc9feba8cc6aaec671b412917e644ab4fa66", size = 473268, upload-time = "2025-10-10T03:54:49.993Z" }, - { url = "https://files.pythonhosted.org/packages/32/4d/9dd616c38da088e3f436e9a616e1d0cc66544b8cdac405cc4e81c8679fc7/httptools-0.7.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:44c8f4347d4b31269c8a9205d8a5ee2df5322b09bbbd30f8f862185bb6b05346", size = 455517, upload-time = "2025-10-10T03:54:51.066Z" }, - { url = "https://files.pythonhosted.org/packages/1d/3a/a6c595c310b7df958e739aae88724e24f9246a514d909547778d776799be/httptools-0.7.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:465275d76db4d554918aba40bf1cbebe324670f3dfc979eaffaa5d108e2ed650", size = 458337, upload-time = "2025-10-10T03:54:52.196Z" }, - { url = "https://files.pythonhosted.org/packages/fd/82/88e8d6d2c51edc1cc391b6e044c6c435b6aebe97b1abc33db1b0b24cd582/httptools-0.7.1-cp313-cp313-win_amd64.whl", hash = "sha256:322d00c2068d125bd570f7bf78b2d367dad02b919d8581d7476d8b75b294e3e6", size = 85743, upload-time = "2025-10-10T03:54:53.448Z" }, + { url = "https://files.pythonhosted.org/packages/5e/e5/8cfcabc5546e8022f168be28bcdaa128a240a0befdd03b59d558b4f18bd6/httptools-0.8.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:614ceea8ea606848bece2338ac03b3ce5324bcb4be8dc7d377ed708012fa4db8", size = 205148, upload-time = "2026-05-25T22:17:16.333Z" }, + { url = "https://files.pythonhosted.org/packages/2a/0e/0fb14848c19a686c8062ff9067c1a48793e3224b47bc5b201535b6036fce/httptools-0.8.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2d689918c15a013c65ef52d9fd495d766893ab831a2c8d89f2ac5940a5df847c", size = 111368, upload-time = "2026-05-25T22:17:17.586Z" }, + { url = "https://files.pythonhosted.org/packages/2e/1b/46f1cecf06b9bbde8e4b8c88034ac7908989e5ff7a3a388ef38392949c1f/httptools-0.8.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:eb3028cca2fc0a6d720e52ef61d8ebb62fcbfeb1de56874546d858d3f25a26b7", size = 486447, upload-time = "2026-05-25T22:17:18.564Z" }, + { url = "https://files.pythonhosted.org/packages/77/00/258bfc0837221f81d9725c45f9b948a6a6b2994a147a4fb66e85100c668f/httptools-0.8.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:88bdd940f2b5d487b4d032c6afa5489a7dc4694410d43de3c38c4fb3af0dc45d", size = 482448, upload-time = "2026-05-25T22:17:19.912Z" }, + { url = "https://files.pythonhosted.org/packages/04/ab/d1cef3b5523f4d272a70f42a776c3169a2dddfe3a54de4b2ce4a36341528/httptools-0.8.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:6a43c9dd399758ccc0531acb0a3c4a6c299ee893ee9400e9c893b7bdcfae0681", size = 464460, upload-time = "2026-05-25T22:17:20.882Z" }, + { url = "https://files.pythonhosted.org/packages/ce/48/5d1d072442277bb2b3434e0e60690b8e8c23840ef7de8b6ea54040a536d3/httptools-0.8.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:0770728beb05094c809b98e814edff5fef69d26ad7d21185f2f6d5884a0ba683", size = 471312, upload-time = "2026-05-25T22:17:22.085Z" }, + { url = "https://files.pythonhosted.org/packages/0d/66/b96623b27e51a68199ef4efdda0613cced9233fe3062ac74e50749c5ad37/httptools-0.8.0-cp313-cp313-win_amd64.whl", hash = "sha256:7685df791fad561384bfb139e77fde27a1ffd93134e016f95a0db424ffbf77b1", size = 90117, upload-time = "2026-05-25T22:17:23.074Z" }, ] [[package]] @@ -457,39 +457,39 @@ wheels = [ [[package]] name = "starlette" -version = "1.0.1" +version = "1.1.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/08/a3/84e821cc54b4ab50ae6dbc6ac3800a651b65ec35f045cc73785380654057/starlette-1.0.1.tar.gz", hash = "sha256:512399c5f1de7fac99c88572212ded9ddeddef2fb32afa82d724000e88b38f4f", size = 2659596, upload-time = "2026-05-21T21:58:58.433Z" } +sdist = { url = "https://files.pythonhosted.org/packages/95/66/4d20cdf39a8d6a51e663b7038e3b828ff211d3891a43a713fe7e4643f3a8/starlette-1.1.0.tar.gz", hash = "sha256:e83c7fe0ddecd8719c5b840080325aec0260acec86e9832899e377b91d65e90f", size = 2660060, upload-time = "2026-05-23T16:55:41.376Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ec/e1/b2df4bc09a1e51ff664c1e17018a4274b42e5e9352e4a478ea540512dc88/starlette-1.0.1-py3-none-any.whl", hash = "sha256:7c0e69b2ee1c848bd54669d908500117a3ee13de603a21427e5c6fc1adf98dcd", size = 72802, upload-time = "2026-05-21T21:58:56.551Z" }, + { url = "https://files.pythonhosted.org/packages/93/79/920b8e0a8b20f793e8d64855095cb8febabf6175b8550b6f7a547d813891/starlette-1.1.0-py3-none-any.whl", hash = "sha256:7f0dfd38e428aad5cb6f9f667f0ca1d2d8ca3f3385dccac8305f79ec98458382", size = 72899, upload-time = "2026-05-23T16:55:39.201Z" }, ] [[package]] name = "ty" -version = "0.0.38" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/33/3b/45be6b37d5060d6917bf7f1f234c00d360fc5f8b7486f8a96af640e25661/ty-0.0.38.tar.gz", hash = "sha256:fbc8d47f7630457669ab41e333dc093897fdb7ead1ffc94dcf8f30b5d39aa56d", size = 5681218, upload-time = "2026-05-20T00:15:32.781Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/54/43/ea9b4e57d6a266670dbe34858e92f6093ca054ad1b48f1c82580a72340fb/ty-0.0.38-py3-none-linux_armv6l.whl", hash = "sha256:3501dcf44ca03f813f9cb4fabfdf601adc0ac1337c411405b470530679e37a45", size = 11289326, upload-time = "2026-05-20T00:14:52.371Z" }, - { url = "https://files.pythonhosted.org/packages/5d/ff/24e2f623a1c6b5f5ccf8bf82fccd937033c6a7dba57a4028c7f41270fa4a/ty-0.0.38-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:b34b4094b76252c3e8c90762cdd5e8a9f1101534484745ff4b480f71eb38ac2e", size = 11063047, upload-time = "2026-05-20T00:14:42.832Z" }, - { url = "https://files.pythonhosted.org/packages/e9/41/4f0d910f0acbd20b358eda80a5cd6a8361d27ff5b8e87ab559d3f69f125e/ty-0.0.38-py3-none-macosx_11_0_arm64.whl", hash = "sha256:c518ad33a877677365baab2e21d82cf59ffee789203a15a143f5179ee5a1d3f8", size = 10494436, upload-time = "2026-05-20T00:15:24.425Z" }, - { url = "https://files.pythonhosted.org/packages/69/d8/da06833422082aa98b169a391f9197e2d73865e96c90b6979ac886b890a2/ty-0.0.38-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9238494722303eccddc6a27eb647948b694eecd6b974910d13b9e6cd46bbeb6a", size = 11000992, upload-time = "2026-05-20T00:14:58.368Z" }, - { url = "https://files.pythonhosted.org/packages/16/f7/e1172197fb827e6410ca3eb0dc68ef2789f3c70683696f2a0ce5c90764fd/ty-0.0.38-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:31d91d7336c5d51bf822ac0df512f300584ca4dcca041fc6a6d7df03a8ddbb31", size = 11058583, upload-time = "2026-05-20T00:15:11.314Z" }, - { url = "https://files.pythonhosted.org/packages/5b/61/7fbaf0c05981e006a8804287819c574dff90a6bf8e96efad7226be0700aa/ty-0.0.38-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:65165879814993450710b9349791e4898c65e36b1e14eec554884c06a2f20ff1", size = 11531036, upload-time = "2026-05-20T00:15:14.62Z" }, - { url = "https://files.pythonhosted.org/packages/49/e3/47c0c64e401d50f925df3e52479d4e7626754b2a9e38201d142fdacd6252/ty-0.0.38-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6d61868b8d1c4033bf8088191de953fed245c2f9e1bb9d2d53e5699170b0924c", size = 12129991, upload-time = "2026-05-20T00:14:39.475Z" }, - { url = "https://files.pythonhosted.org/packages/90/99/2f452d02901bcd7f1b109cf5b848727ce37f372c3406143aa52d1305d40e/ty-0.0.38-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8f9a9175548c98dbff7707865738c07c2b1f8e07a09b8c68101baebb5dac59a4", size = 11756167, upload-time = "2026-05-20T00:15:27.526Z" }, - { url = "https://files.pythonhosted.org/packages/dd/0c/c7e14d111c813e1a20b82e944f1c997c4631a2bb710eaa64fb6b26835e13/ty-0.0.38-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:375d3a964c6b4aea2e9237fdb5eb9ed03dc43088986a94209a28a4ea3b62001c", size = 11637099, upload-time = "2026-05-20T00:15:21.261Z" }, - { url = "https://files.pythonhosted.org/packages/37/de/ab02659dd1ed62898db7db4d37f9937c80854dd45e95093fa0fe10328d82/ty-0.0.38-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:cdfd547782c45267aa0b52abad31bd406bf4768c264532ef9e2360cd3c6ce048", size = 11813583, upload-time = "2026-05-20T00:14:45.875Z" }, - { url = "https://files.pythonhosted.org/packages/7e/57/bd1b5ebf4e71a4295484afac0202df1740b0807762b86744b1bef4534984/ty-0.0.38-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:858bc675b75626470abe4e6c3b3934b853642b04f2ac4d7139fcefea3b48b213", size = 10975405, upload-time = "2026-05-20T00:15:30.354Z" }, - { url = "https://files.pythonhosted.org/packages/e7/55/0305c78711bbd23922cf291996a08ef9544f4179da98e9a75c14e608f379/ty-0.0.38-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:54be4f00432870da42cd74fe145a3362fd248e22d032c74bd807cb45bf068f94", size = 11097551, upload-time = "2026-05-20T00:14:55.179Z" }, - { url = "https://files.pythonhosted.org/packages/7c/4f/7effe7f9a6ac9719eb7234172c01739c5f888bb47f9acc2ea8da1f4afed3/ty-0.0.38-py3-none-musllinux_1_2_i686.whl", hash = "sha256:494af66a76a86dbf16a3003d3b63b03484aa4c7489dfe11f3ee5413b98b22d60", size = 11214391, upload-time = "2026-05-20T00:15:18.094Z" }, - { url = "https://files.pythonhosted.org/packages/75/cd/d9fdfec3a74a6ad0209fa5e7113ae29d4f457d0651cfbb813b4c6563e0d4/ty-0.0.38-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:3d92527c4be78a5ce6d32e8bb0aa2a6988d4076eddf1294e56fdaf06d1a98e7e", size = 11730871, upload-time = "2026-05-20T00:14:49.219Z" }, - { url = "https://files.pythonhosted.org/packages/0e/4a/beefade12d109b4f7793d61b04b4478b1ad4d1465a719e7ff55b2d42461a/ty-0.0.38-py3-none-win32.whl", hash = "sha256:36fc5dd5dc09207ff3004b1560a79a3fb8d12456daeec914a7b802a918da654c", size = 10548583, upload-time = "2026-05-20T00:15:07.892Z" }, - { url = "https://files.pythonhosted.org/packages/15/64/941b205e2e46cc2297c245c64aa7691410b7454fa4d07a6cb3cf59487833/ty-0.0.38-py3-none-win_amd64.whl", hash = "sha256:eef0a8956ba14514076b1a963d13eb32986d9ebad7f0527b3cc01cb68bf35147", size = 11650542, upload-time = "2026-05-20T00:15:01.441Z" }, - { url = "https://files.pythonhosted.org/packages/59/02/c1c4f9ec4b94d95190636fa13f79c32f65165fbe3a0503882d4df164d2ac/ty-0.0.38-py3-none-win_arm64.whl", hash = "sha256:79abfc8658a026c30b1c955613437dab3ef4b12feca56a3e6df50903cc39e07f", size = 11010307, upload-time = "2026-05-20T00:15:04.567Z" }, +version = "0.0.40" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5a/f8/a754c96967b71de8723f88be17df8738216bd382ffed229cd500b7a24d13/ty-0.0.40.tar.gz", hash = "sha256:883b53dd98f6e5b33ab1c8e1a3cd94b0f29c762ef22cdf1e86aaffb4fd711c67", size = 5726484, upload-time = "2026-05-27T17:55:43.615Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2c/42/d029a72165ad39f95228b67355927fbd35c821dc8e3e475d49f47c2eeb1e/ty-0.0.40-py3-none-linux_armv6l.whl", hash = "sha256:9defb4742450e569a6a09de286a04008d6c2e815112da4362c88b6eaa2f52a36", size = 11406372, upload-time = "2026-05-27T17:55:49.633Z" }, + { url = "https://files.pythonhosted.org/packages/23/99/7f8ea09b7e49afbf795cb3341a3217f30f228db7e62a2268ed8cbbf813d6/ty-0.0.40-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:868258a3330db88b683fcafe2c4e936d6226a6312799bf15b585d93557b2d38c", size = 11159782, upload-time = "2026-05-27T17:55:47.405Z" }, + { url = "https://files.pythonhosted.org/packages/04/d8/1ea745ee97a98b26ae9564d19a430a76a35297cd450e84dcaad22e1f7ee8/ty-0.0.40-py3-none-macosx_11_0_arm64.whl", hash = "sha256:589c81060cf1e7a9ffa2f45bfa35ffd9b9fbd214104e3f13959f113627efcd91", size = 10594139, upload-time = "2026-05-27T17:55:37.206Z" }, + { url = "https://files.pythonhosted.org/packages/39/1a/fbef21273c6617ff4715b4827ee1c0b6550aa7d1df4b8c43b325545c1cf4/ty-0.0.40-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7b06108990cb338d941c315ae6e9ba2fff8f518bc15d3f33e5619ff6a6c9beab", size = 11114156, upload-time = "2026-05-27T17:55:56.11Z" }, + { url = "https://files.pythonhosted.org/packages/3c/f9/389fc4976d7ec016a7473cf1274bf9c4f491bb54c66649bd022bff9f2b6a/ty-0.0.40-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3913ef37336bec4f96bd2512f8c3a543ca34c259b7170f7eb5adf75b3ed7f04c", size = 11189050, upload-time = "2026-05-27T17:55:54.099Z" }, + { url = "https://files.pythonhosted.org/packages/fa/a9/4ecabbf4bdda7df0d99d8d3892c6edac0efc8c4cae756a5109178a3d0e86/ty-0.0.40-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8fd1486bd5fe48779a8aa857137f3642a0a9161f5cf57d4380f4a0ecea01c8f3", size = 11664266, upload-time = "2026-05-27T17:55:28.17Z" }, + { url = "https://files.pythonhosted.org/packages/45/02/0aa78730116507c265afb1d6d5961c583b49d4c2e368c4a49fd81bcae6dc/ty-0.0.40-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1668364d5254a734329917ee66c2c5fdd5665389d41043f6fce0f22ddb32b749", size = 12187743, upload-time = "2026-05-27T17:56:04.337Z" }, + { url = "https://files.pythonhosted.org/packages/e6/68/ccabf2d173523598271a385c1d3f864dbda23e5ebdc67f5969b9e830ea05/ty-0.0.40-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:43f77a73edb91e5dfa2ab9af7c4cac64614f8cc121f38a8875f22e830d3aba6a", size = 11862999, upload-time = "2026-05-27T17:55:58.087Z" }, + { url = "https://files.pythonhosted.org/packages/03/8d/6d7ec22771bb23d534797cdb446eb644bccfe7a62b729bb99e7235a02fc3/ty-0.0.40-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1274ce0212ecbfed01bda7c3659c46e8bd0068e32d00c46c790466a95274c3df", size = 11743896, upload-time = "2026-05-27T17:56:00.017Z" }, + { url = "https://files.pythonhosted.org/packages/cd/a4/f9fa076b010c91cb249b1fcc3476569b7b8462cb4b688da2d04c23a0622f/ty-0.0.40-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:5ee1261dbc363e5cc1a0c5bb0c8612c192bfe53491214df8bc85a540835685f9", size = 11883581, upload-time = "2026-05-27T17:56:02.319Z" }, + { url = "https://files.pythonhosted.org/packages/fd/0f/5b776a2328c756d574dd4d6afbd30fc24e1ab4b76935c7c3c23f27ebbcb9/ty-0.0.40-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:6220e2cd5cdc4683dd87fb150d195bbd9f1a021395e04cb08bd3c66ea6da6ef8", size = 11093946, upload-time = "2026-05-27T17:55:33.284Z" }, + { url = "https://files.pythonhosted.org/packages/64/c4/eb23154bae83ad7c2935e9e5916660fb3e31598a92ee232aebd79410480c/ty-0.0.40-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:46b9ed69d01d98ef046afac9983c68336f572605ea2a27b90fbe6f80bfc8d6b7", size = 11210737, upload-time = "2026-05-27T17:55:45.523Z" }, + { url = "https://files.pythonhosted.org/packages/ff/19/1fb2529703f708cacfd13a89f98613cae2907dfa941b26976467e6119803/ty-0.0.40-py3-none-musllinux_1_2_i686.whl", hash = "sha256:ddbca9fab4406260f141674ab5efcfe7b02bd468e6985e4cdde0a21626e69ffe", size = 11332563, upload-time = "2026-05-27T17:55:41.674Z" }, + { url = "https://files.pythonhosted.org/packages/87/69/b3f5a8ef26c31204e0391147b3adcdb0674eda3e7d99868478ef168a41c6/ty-0.0.40-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:b1fcc082a749e6dc11b68fe9aab0420238bbf2a2374c2c7aa3c22e8c1618b136", size = 11843216, upload-time = "2026-05-27T17:55:35.367Z" }, + { url = "https://files.pythonhosted.org/packages/ac/e8/20193069d32787f3e1a6ec8940aaa3759d3de8f48f9281bcc0c5cb0939da/ty-0.0.40-py3-none-win32.whl", hash = "sha256:75feb115b3587824c5bdf8f8305e9547b0d1e398e3077b0addc7a1988ea9bb50", size = 10670731, upload-time = "2026-05-27T17:55:31.316Z" }, + { url = "https://files.pythonhosted.org/packages/a3/f9/8b2aa4da61db81322d4a2f9db227afeb48110ca15ae31d380f64c64ceb63/ty-0.0.40-py3-none-win_amd64.whl", hash = "sha256:b0f905edaad788bd61f779a85801b60a267a25ed57fca05aaddd168d9d8896be", size = 11766211, upload-time = "2026-05-27T17:55:51.898Z" }, + { url = "https://files.pythonhosted.org/packages/04/87/369056ed46f1b235130ec0595393262f9cd2061ca3dab276d490980f9343/ty-0.0.40-py3-none-win_arm64.whl", hash = "sha256:07da2b09d9130e2c9a257d2a29beb53105835b0256ee5fdb288fe1aab83fee47", size = 11117369, upload-time = "2026-05-27T17:55:39.329Z" }, ] [[package]] @@ -515,15 +515,15 @@ wheels = [ [[package]] name = "uvicorn" -version = "0.47.0" +version = "0.48.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "click" }, { name = "h11" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/f6/b1/8e7077a8641086aea449e1b5752a570f1b5906c64e0a33cd6d93b63a066b/uvicorn-0.47.0.tar.gz", hash = "sha256:7c9a0ea1a9414106bbab7324609c162d8fa0cdcdcb703060987269d77c7bb533", size = 90582, upload-time = "2026-05-14T18:16:54.455Z" } +sdist = { url = "https://files.pythonhosted.org/packages/e6/bf/f6544ba992ddb9a6077343a576f9844f7f8f06ab819aefd00206e9255f18/uvicorn-0.48.0.tar.gz", hash = "sha256:a5504207195d08c2511bf9125ede5ac4a4b71725d519e758d01dcf0bc2d31c37", size = 91074, upload-time = "2026-05-24T12:08:41.925Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/15/41/ac2dfdbc1f60c7af4f994c7a335cfa7040c01642b605d65f611cecc2a1e4/uvicorn-0.47.0-py3-none-any.whl", hash = "sha256:2c5715bc12d1892d84752049f400cd1c3cb018514967fdfeb97640443a6a9432", size = 71301, upload-time = "2026-05-14T18:16:51.762Z" }, + { url = "https://files.pythonhosted.org/packages/01/be/72532be3da7acc5fdfbccdb95215cd04f995a0886532a5b423f929cda4cc/uvicorn-0.48.0-py3-none-any.whl", hash = "sha256:48097851328b87ec36117d3d575234519eb58c2b22d79666e9bbc6c49a761dad", size = 71410, upload-time = "2026-05-24T12:08:40.258Z" }, ] [[package]] From ac53ed03dc869077cb190a3fa183e4fbad442381 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C6=B0u=20Quang=20V=C5=A9?= Date: Wed, 29 Jul 2026 13:12:20 +0700 Subject: [PATCH 270/291] Implement fully dynamic models loading from RPC and update dependencies --- Dockerfile | 6 +- app/models/models.py | 5 +- app/server/chat.py | 4 +- app/services/lmdb.py | 4 +- app/utils/helper.py | 2 +- pyproject.toml | 8 +- uv.lock | 214 ++++++++++++++++++++++--------------------- 7 files changed, 124 insertions(+), 119 deletions(-) diff --git a/Dockerfile b/Dockerfile index bb96c27..78716cf 100644 --- a/Dockerfile +++ b/Dockerfile @@ -16,12 +16,14 @@ ENV UV_COMPILE_BYTECODE=1 \ PYTHONDONTWRITEBYTECODE=1 COPY pyproject.toml uv.lock ./ -RUN uv sync --no-cache --frozen --no-install-project --no-dev +RUN uv sync --refresh --frozen --no-install-project --no-dev COPY app/ app/ COPY config/ config/ COPY run.py . +ENV PATH="/app/.venv/bin:$PATH" + EXPOSE 8000 HEALTHCHECK --interval=30s --timeout=10s --start-period=300s --retries=3 \ @@ -29,4 +31,4 @@ HEALTHCHECK --interval=30s --timeout=10s --start-period=300s --retries=3 \ ENTRYPOINT ["/usr/bin/tini", "--"] -CMD ["uv", "run", "--no-dev", "run.py"] +CMD ["python", "run.py"] diff --git a/app/models/models.py b/app/models/models.py index 71462b2..0b8586f 100644 --- a/app/models/models.py +++ b/app/models/models.py @@ -1,5 +1,6 @@ from __future__ import annotations +from collections.abc import Mapping from typing import Any, Literal from pydantic import BaseModel, Field, model_validator @@ -448,8 +449,8 @@ class HealthCheckResponse(BaseModel): """Response body for the health check endpoint.""" ok: bool - storage: dict[str, Any] | None = Field(default=None) - clients: dict[str, bool] | None = Field(default=None) + storage: Mapping[str, Any] | None = Field(default=None) + clients: Mapping[str, bool] | None = Field(default=None) error: str | None = Field(default=None) diff --git a/app/server/chat.py b/app/server/chat.py index e1abea5..7656487 100644 --- a/app/server/chat.py +++ b/app/server/chat.py @@ -457,7 +457,7 @@ def _convert_to_app_messages(messages: list[ChatCompletionMessage]) -> list[AppM app_messages.append( AppMessage( - role=role, # type: ignore + role=role, content=app_content, tool_calls=tool_calls, tool_call_id=msg.tool_call_id, @@ -889,7 +889,7 @@ def _convert_instructions_to_app_messages( normalized_role = {"developer": "system", "function": "tool"}.get(raw_role, raw_role) if normalized_role not in ("system", "user", "assistant", "tool"): normalized_role = "system" - role = cast(Literal["system", "user", "assistant", "tool"], normalized_role) + role = normalized_role content = instruction.content if isinstance(content, str): diff --git a/app/services/lmdb.py b/app/services/lmdb.py index 1b8f8fc..0905448 100644 --- a/app/services/lmdb.py +++ b/app/services/lmdb.py @@ -1,6 +1,6 @@ import hashlib import string -from collections.abc import Generator +from collections.abc import Generator, Mapping from contextlib import contextmanager, suppress from datetime import datetime, timedelta from pathlib import Path @@ -530,7 +530,7 @@ def cleanup_expired(self, retention_days: int | None = None) -> int: return removed - def stats(self) -> dict[str, Any]: + def stats(self) -> Mapping[str, Any]: """Get database statistics.""" if not self._env: logger.error("LMDB environment not initialized") diff --git a/app/utils/helper.py b/app/utils/helper.py index 88e42a4..bbe52c0 100644 --- a/app/utils/helper.py +++ b/app/utils/helper.py @@ -17,7 +17,7 @@ from app.models import AppMessage, AppToolCall, AppToolCallFunction -type JsonValue = None | bool | int | float | str | list[JsonValue] | dict[str, JsonValue] +type JsonValue = bool | int | float | str | list[JsonValue] | dict[str, JsonValue] | None VALID_TAG_ROLES = {"user", "assistant", "system", "tool"} TOOL_WRAP_HINT = ( diff --git a/pyproject.toml b/pyproject.toml index 88f6dc7..88b8d1b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,14 +6,14 @@ readme = "README.md" requires-python = "==3.13.*" dependencies = [ "curl-cffi>=0.15.0", - "fastapi>=0.136.3", + "fastapi>=0.140.13", "gemini-webapi>=2.0.0", "httptools>=0.8.0", - "lmdb>=2.2.0", + "lmdb>=2.3.0", "loguru>=0.7.3", "orjson>=3.11.9", - "pydantic-settings[yaml]>=2.14.1", - "uvicorn>=0.48.0", + "pydantic-settings[yaml]>=2.14.2", + "uvicorn>=0.51.0", "uvloop>=0.22.1; sys_platform != 'win32'", ] diff --git a/uv.lock b/uv.lock index a2fbae2..4b1eeac 100644 --- a/uv.lock +++ b/uv.lock @@ -4,76 +4,78 @@ requires-python = "==3.13.*" [[package]] name = "annotated-doc" -version = "0.0.4" +version = "0.0.5" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/57/ba/046ceea27344560984e26a590f90bc7f4a75b06701f653222458922b558c/annotated_doc-0.0.4.tar.gz", hash = "sha256:fbcda96e87e9c92ad167c2e53839e57503ecfda18804ea28102353485033faa4", size = 7288, upload-time = "2025-11-10T22:07:42.062Z" } +sdist = { url = "https://files.pythonhosted.org/packages/5a/8e/38aa427ed5402449e226975b649c5dc73ccadfefeb95e6aecb8f8ea4b6b6/annotated_doc-0.0.5.tar.gz", hash = "sha256:c7e58ce09192557605d8bbd92836d7e1d520ac9580096042c0bfd197efacf1bb", size = 10758, upload-time = "2026-07-28T13:50:58.129Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/1e/d3/26bf1008eb3d2daa8ef4cacc7f3bfdc11818d111f7e2d0201bc6e3b49d45/annotated_doc-0.0.4-py3-none-any.whl", hash = "sha256:571ac1dc6991c450b25a9c2d84a3705e2ae7a53467b5d111c24fa8baabbed320", size = 5303, upload-time = "2025-11-10T22:07:40.673Z" }, + { url = "https://files.pythonhosted.org/packages/3e/30/e900b21425a860e195f32e37657aa1f7c7f2b1bfb26f03ca209b90933c06/annotated_doc-0.0.5-py3-none-any.whl", hash = "sha256:117bac03a25ede5df5440e855b32d556049ca169ead221505badf432fed4b101", size = 5302, upload-time = "2026-07-28T13:50:57.239Z" }, ] [[package]] name = "annotated-types" -version = "0.7.0" +version = "0.8.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" } +sdist = { url = "https://files.pythonhosted.org/packages/5f/56/a8120250d128bed162cd73c76d45f6ef9991f3e068f62a8ee060afa3104a/annotated_types-0.8.0.tar.gz", hash = "sha256:13b2beaad985e05e2d6407ee4c4f35590b11f8d693a258a561055cac8f64cab7", size = 15893, upload-time = "2026-07-23T20:16:13.995Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" }, + { url = "https://files.pythonhosted.org/packages/99/91/8acff4f5e50511b911bbccb72b8628a49c68ce14148cd9f6431094859a90/annotated_types-0.8.0-py3-none-any.whl", hash = "sha256:f072f4d804ea359e4eaf198b1af7a8b0943881a87f31bb764f8bf219bb9419e0", size = 13427, upload-time = "2026-07-23T20:16:12.938Z" }, ] [[package]] name = "anyio" -version = "4.13.0" +version = "4.14.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "idna" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/19/14/2c5dd9f512b66549ae92767a9c7b330ae88e1932ca57876909410251fe13/anyio-4.13.0.tar.gz", hash = "sha256:334b70e641fd2221c1505b3890c69882fe4a2df910cba14d97019b90b24439dc", size = 231622, upload-time = "2026-03-24T12:59:09.671Z" } +sdist = { url = "https://files.pythonhosted.org/packages/61/cc/a381afa6efea9f496eff839d4a6a1aed3bfafc7b3ab4b0d1b243a12573dd/anyio-4.14.2.tar.gz", hash = "sha256:cfa139f3ed1a23ee8f88a145ddb5ac7605b8bbfd8592baacd7ce3d8bb4313c7f", size = 260176, upload-time = "2026-07-12T20:29:07.082Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/da/42/e921fccf5015463e32a3cf6ee7f980a6ed0f395ceeaa45060b61d86486c2/anyio-4.13.0-py3-none-any.whl", hash = "sha256:08b310f9e24a9594186fd75b4f73f4a4152069e3853f1ed8bfbf58369f4ad708", size = 114353, upload-time = "2026-03-24T12:59:08.246Z" }, + { url = "https://files.pythonhosted.org/packages/da/35/f2287558c17e29fafc8ef3daf819bb9834061cfa43bff8014f7df7f63bdc/anyio-4.14.2-py3-none-any.whl", hash = "sha256:9f505dda5ac9f0c8309b5e8bd445a8c2bf7246f3ce950121e45ea15bc41d1494", size = 125813, upload-time = "2026-07-12T20:29:05.763Z" }, ] [[package]] name = "certifi" -version = "2026.5.20" +version = "2026.7.22" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f3/ce/ee2ecad540810a79593028e88299baeae54d346cc7a0d94b6199988b89b1/certifi-2026.5.20.tar.gz", hash = "sha256:69dea482ab64caa7b9f6aba1c6bf48bb6a5448d1c0f1b17ab42ad8c763a5344d", size = 135422, upload-time = "2026-05-20T11:46:50.073Z" } +sdist = { url = "https://files.pythonhosted.org/packages/a3/c2/24167ea9858356b47a87a50d39908bfdb72ceeefe0041586e704e5376b3a/certifi-2026.7.22.tar.gz", hash = "sha256:741e2c3b351ddf169a738da9f2c048608ff7f2c5cc02f1ebc6b118bb090d5d55", size = 138112, upload-time = "2026-07-22T03:35:12.644Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/59/8c/57e832b7af6d7c5abe66eb3fbe3a3a32f4d11ea23a1aa7131371035be991/certifi-2026.5.20-py3-none-any.whl", hash = "sha256:3c52e209ba0a4ad7aebe60436a4ab349c39e1e602e8c134221e546902ad25897", size = 134134, upload-time = "2026-05-20T11:46:48.578Z" }, + { url = "https://files.pythonhosted.org/packages/0b/a7/71ac2cff56fec219ed242bb11b8efb69fcc4bec75db06fb7bfe35de520e6/certifi-2026.7.22-py3-none-any.whl", hash = "sha256:62f22742b58a1a33014a2b6b706588a8d7e2a88ae7bd1a6ebe8c992928483775", size = 136983, upload-time = "2026-07-22T03:35:11.276Z" }, ] [[package]] name = "cffi" -version = "2.0.0" +version = "2.1.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pycparser", marker = "implementation_name != 'PyPy'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/eb/56/b1ba7935a17738ae8453301356628e8147c79dbb825bcbc73dc7401f9846/cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529", size = 523588, upload-time = "2025-09-08T23:24:04.541Z" } +sdist = { url = "https://files.pythonhosted.org/packages/57/5f/ff100cae70ebe9d8df1c01a00e510e45d9adb5c1fdda84791b199141de97/cffi-2.1.0.tar.gz", hash = "sha256:efc1cdd798b1aaf39b4610bba7aad28c9bea9b910f25c784ccf9ec1fa719d1f9", size = 531036, upload-time = "2026-07-06T21:34:30.382Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/4b/8d/a0a47a0c9e413a658623d014e91e74a50cdd2c423f7ccfd44086ef767f90/cffi-2.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:00bdf7acc5f795150faa6957054fbbca2439db2f775ce831222b66f192f03beb", size = 185230, upload-time = "2025-09-08T23:23:00.879Z" }, - { url = "https://files.pythonhosted.org/packages/4a/d2/a6c0296814556c68ee32009d9c2ad4f85f2707cdecfd7727951ec228005d/cffi-2.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:45d5e886156860dc35862657e1494b9bae8dfa63bf56796f2fb56e1679fc0bca", size = 181043, upload-time = "2025-09-08T23:23:02.231Z" }, - { url = "https://files.pythonhosted.org/packages/b0/1e/d22cc63332bd59b06481ceaac49d6c507598642e2230f201649058a7e704/cffi-2.0.0-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:07b271772c100085dd28b74fa0cd81c8fb1a3ba18b21e03d7c27f3436a10606b", size = 212446, upload-time = "2025-09-08T23:23:03.472Z" }, - { url = "https://files.pythonhosted.org/packages/a9/f5/a2c23eb03b61a0b8747f211eb716446c826ad66818ddc7810cc2cc19b3f2/cffi-2.0.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d48a880098c96020b02d5a1f7d9251308510ce8858940e6fa99ece33f610838b", size = 220101, upload-time = "2025-09-08T23:23:04.792Z" }, - { url = "https://files.pythonhosted.org/packages/f2/7f/e6647792fc5850d634695bc0e6ab4111ae88e89981d35ac269956605feba/cffi-2.0.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f93fd8e5c8c0a4aa1f424d6173f14a892044054871c771f8566e4008eaa359d2", size = 207948, upload-time = "2025-09-08T23:23:06.127Z" }, - { url = "https://files.pythonhosted.org/packages/cb/1e/a5a1bd6f1fb30f22573f76533de12a00bf274abcdc55c8edab639078abb6/cffi-2.0.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:dd4f05f54a52fb558f1ba9f528228066954fee3ebe629fc1660d874d040ae5a3", size = 206422, upload-time = "2025-09-08T23:23:07.753Z" }, - { url = "https://files.pythonhosted.org/packages/98/df/0a1755e750013a2081e863e7cd37e0cdd02664372c754e5560099eb7aa44/cffi-2.0.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c8d3b5532fc71b7a77c09192b4a5a200ea992702734a2e9279a37f2478236f26", size = 219499, upload-time = "2025-09-08T23:23:09.648Z" }, - { url = "https://files.pythonhosted.org/packages/50/e1/a969e687fcf9ea58e6e2a928ad5e2dd88cc12f6f0ab477e9971f2309b57c/cffi-2.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d9b29c1f0ae438d5ee9acb31cadee00a58c46cc9c0b2f9038c6b0b3470877a8c", size = 222928, upload-time = "2025-09-08T23:23:10.928Z" }, - { url = "https://files.pythonhosted.org/packages/36/54/0362578dd2c9e557a28ac77698ed67323ed5b9775ca9d3fe73fe191bb5d8/cffi-2.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6d50360be4546678fc1b79ffe7a66265e28667840010348dd69a314145807a1b", size = 221302, upload-time = "2025-09-08T23:23:12.42Z" }, - { url = "https://files.pythonhosted.org/packages/eb/6d/bf9bda840d5f1dfdbf0feca87fbdb64a918a69bca42cfa0ba7b137c48cb8/cffi-2.0.0-cp313-cp313-win32.whl", hash = "sha256:74a03b9698e198d47562765773b4a8309919089150a0bb17d829ad7b44b60d27", size = 172909, upload-time = "2025-09-08T23:23:14.32Z" }, - { url = "https://files.pythonhosted.org/packages/37/18/6519e1ee6f5a1e579e04b9ddb6f1676c17368a7aba48299c3759bbc3c8b3/cffi-2.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:19f705ada2530c1167abacb171925dd886168931e0a7b78f5bffcae5c6b5be75", size = 183402, upload-time = "2025-09-08T23:23:15.535Z" }, - { url = "https://files.pythonhosted.org/packages/cb/0e/02ceeec9a7d6ee63bb596121c2c8e9b3a9e150936f4fbef6ca1943e6137c/cffi-2.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:256f80b80ca3853f90c21b23ee78cd008713787b1b1e93eae9f3d6a7134abd91", size = 177780, upload-time = "2025-09-08T23:23:16.761Z" }, + { url = "https://files.pythonhosted.org/packages/96/88/a996879e2eeccb815f6e3a5967b12a308257412acec882039d386bd2aa7b/cffi-2.1.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:10537b1df4967ca26d21e5072d7d54188354483b91dc75058968d3f0cf13fbda", size = 194331, upload-time = "2026-07-06T21:33:03.697Z" }, + { url = "https://files.pythonhosted.org/packages/58/85/7ae00d5c8dd6266f4e944c3db630f3c5c9a98b61d469c714d848b1d8138a/cffi-2.1.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:a95b05f9baf29b91171b3a8bd2020b028835243e7b0ff6bb23e2a3c228518b1b", size = 196966, upload-time = "2026-07-06T21:33:05.353Z" }, + { url = "https://files.pythonhosted.org/packages/8c/e9/45c3a76ad8d43ad9261f4c95436da61128d3ca545d72b9612c0ab5be0b1c/cffi-2.1.0-cp313-cp313-macosx_10_15_x86_64.whl", hash = "sha256:15faec4adfff450819f3aee0e2e02c812de6edb88203aa58807955db2003472a", size = 184795, upload-time = "2026-07-06T21:33:06.699Z" }, + { url = "https://files.pythonhosted.org/packages/84/4c/82f132cb4418ee6d953d982b19191e87e2a6372c8a4ce36e50b69d6ade4a/cffi-2.1.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:716ff8ec22f20b4d988b12884086bcef0fc99737043e503f7a3935a6be99b1ea", size = 184746, upload-time = "2026-07-06T21:33:08.071Z" }, + { url = "https://files.pythonhosted.org/packages/a0/1c/4ed5a0e5bdca6cbc275556de3328dd1b76fd0c11cc13c88fe66d1d8715f2/cffi-2.1.0-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:63960549e4f8dc41e31accb97b975abaecfc44c03e396c093a6436763c2ea7db", size = 214747, upload-time = "2026-07-06T21:33:09.671Z" }, + { url = "https://files.pythonhosted.org/packages/3a/a6/e879bb68cc23a2bc9ba8f4b7d8019f0c2694bad2ab6c4a3701d429439f58/cffi-2.1.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ff067a8d8d880e7809e4ac88eb009bb848870115317b306666502ccad30b147f", size = 222392, upload-time = "2026-07-06T21:33:10.896Z" }, + { url = "https://files.pythonhosted.org/packages/88/f6/01890cfd63c08f8eb96a8319b0443690197d240a8bd6346048cf7bde9190/cffi-2.1.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:3b926723c13eba9f81d2ef3820d63aeceec3b2d4639906047bf675cb8a7a500d", size = 210285, upload-time = "2026-07-06T21:33:12.251Z" }, + { url = "https://files.pythonhosted.org/packages/a6/cf/2b684132056f438567b61e19d690dd31cd0921ace051e0a458be6074369e/cffi-2.1.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:47ff3a8bfd8cb9da1af7524b965127095055654c177fcfc7578debcb015eecd0", size = 208801, upload-time = "2026-07-06T21:33:13.617Z" }, + { url = "https://files.pythonhosted.org/packages/6f/08/f2e7d62c460faae0926f2d6e423694aa409ced3bc1fe2927a0a6e5f05416/cffi-2.1.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:799416bae98336e400981ff6e532d67d5c709cfb30afb79865a1315f94b0e224", size = 221808, upload-time = "2026-07-06T21:33:15.466Z" }, + { url = "https://files.pythonhosted.org/packages/38/37/04f54b8e63a02f3d908332c9effbf8c366167c6f733ed8a3d4f79b7e2a1e/cffi-2.1.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:961be50688f7fba2fa65f63712d3b9b341a22311f5253460ce933f52f0de1c8c", size = 225241, upload-time = "2026-07-06T21:33:16.869Z" }, + { url = "https://files.pythonhosted.org/packages/a9/d6/c72eecca433cd3e681c65ed313ab4835d9d4a379704d0f628a6a05f51c2e/cffi-2.1.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:bf5c6cf48238b0eb4c086978c492ad1cbc22373fc5b2d7353b3a598ce6db887a", size = 223588, upload-time = "2026-07-06T21:33:18.239Z" }, + { url = "https://files.pythonhosted.org/packages/c6/4b/e706f67279140f92939da3475ad610df18bfd52d50f14953a8e5fede71d5/cffi-2.1.0-cp313-cp313-win32.whl", hash = "sha256:db3eb7d46527159a878ec3460e9d40615bc25ba337d477db681aea6e4f05c5d2", size = 175248, upload-time = "2026-07-06T21:33:19.799Z" }, + { url = "https://files.pythonhosted.org/packages/5a/47/59eb7975cb0e4ef0afa764ea945b29a5bb4537a9f771cb7d6c8a5dd74c95/cffi-2.1.0-cp313-cp313-win_amd64.whl", hash = "sha256:8e74a6135550c4748af665b1b1118b6aab33b1fc6a16f9aff630af107c3b4512", size = 185717, upload-time = "2026-07-06T21:33:21.47Z" }, + { url = "https://files.pythonhosted.org/packages/5a/af/34fee85c48f8d94efc8597bc09470c9dd274c145f1c12e0fbc6ab6d38d74/cffi-2.1.0-cp313-cp313-win_arm64.whl", hash = "sha256:2282cd5e38aa8accd03e99d1256af8411c84cdbee6a89d841b563fdbd1f3e50f", size = 180114, upload-time = "2026-07-06T21:33:22.515Z" }, ] [[package]] name = "click" -version = "8.4.1" +version = "8.4.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "colorama", marker = "sys_platform == 'win32'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/9b/98/518d8e5081007684232226f475082b30087d0f585e8457db087298259f49/click-8.4.1.tar.gz", hash = "sha256:918b5633eddf6b41c32d4f454bf0de810065c74e3f7dbf8ee5452f8be88d3e96", size = 353007, upload-time = "2026-05-22T04:08:37.769Z" } +sdist = { url = "https://files.pythonhosted.org/packages/76/d4/81420972a676e8ffea40450d8c8c92943e7218a78fe9b64359836cc9876b/click-8.4.2.tar.gz", hash = "sha256:9a6cea6e60b17ebe0a44c5cc636d94f09bd66142c1cd7d8b4cd731c4917a15f6", size = 338000, upload-time = "2026-06-24T17:45:15.148Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/c7/0d/67e5b4109ea4a837e80daa87c2c696711955e40449a97e8926672534def2/click-8.4.1-py3-none-any.whl", hash = "sha256:482be17c6991b8c19c5429a1e995d9b0efdbb63172824c41f99965dc0ade8ec2", size = 116639, upload-time = "2026-05-22T04:08:35.26Z" }, + { url = "https://files.pythonhosted.org/packages/fb/e2/79c688af8b210d232694e31e59da9f6ec747bae31c3f5946e4e9b98860d5/click-8.4.2-py3-none-any.whl", hash = "sha256:e6f9f66136c816745b9d65817da91d61d957fb16e02e4dcd0552553c5a197b76", size = 119243, upload-time = "2026-06-24T17:45:13.73Z" }, ] [[package]] @@ -112,7 +114,7 @@ wheels = [ [[package]] name = "fastapi" -version = "0.136.3" +version = "0.140.13" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "annotated-doc" }, @@ -121,9 +123,9 @@ dependencies = [ { name = "typing-extensions" }, { name = "typing-inspection" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/81/2d/ff8d91d7b564d464629a0fd50a4489c97fcb836ac230bf3a7269232a9b1f/fastapi-0.136.3.tar.gz", hash = "sha256:e487fae93ad408e6f47641ee4dfe389864fd7bec92e547ea8498fc13f43e83ab", size = 396410, upload-time = "2026-05-23T18:53:15.192Z" } +sdist = { url = "https://files.pythonhosted.org/packages/2f/cb/7a4d2c2eb5a5d8a91763c05b7383d72917862e32f780daa0e27ffbb34cc6/fastapi-0.140.13.tar.gz", hash = "sha256:500172a08cf1459901f90b05c37d93060dada3b573fec8f0862445db52ba6b4b", size = 424843, upload-time = "2026-07-28T15:37:00.805Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e0/82/45359b62a067409bd929ae8a56b8ed13e5a8c8a61194b3c236920999ab83/fastapi-0.136.3-py3-none-any.whl", hash = "sha256:3d2a69bdf04b7e9f3afa292c3bc7a98816bbfafa10bc9b45f3f3700d2f761620", size = 117481, upload-time = "2026-05-23T18:53:16.924Z" }, + { url = "https://files.pythonhosted.org/packages/84/4e/f9e8c762ef5e05c40482131e3d5e8b36bca13fa127578261f1d6b35a25d4/fastapi-0.140.13-py3-none-any.whl", hash = "sha256:8b017110e1e9f30a95e8bdb8f71fbe2f0fe3af5717109e5b14f9e069df54f6d4", size = 131222, upload-time = "2026-07-28T15:37:02.124Z" }, ] [[package]] @@ -158,17 +160,17 @@ dev = [ [package.metadata] requires-dist = [ { name = "curl-cffi", specifier = ">=0.15.0" }, - { name = "fastapi", specifier = ">=0.136.3" }, + { name = "fastapi", specifier = ">=0.140.13" }, { name = "gemini-webapi", git = "https://github.com/luuquangvu/Gemini-API.git?rev=enable-guest-mode" }, { name = "httptools", specifier = ">=0.8.0" }, - { name = "lmdb", specifier = ">=2.2.0" }, + { name = "lmdb", specifier = ">=2.3.0" }, { name = "loguru", specifier = ">=0.7.3" }, { name = "orjson", specifier = ">=3.11.9" }, - { name = "pydantic-settings", extras = ["yaml"], specifier = ">=2.14.1" }, + { name = "pydantic-settings", extras = ["yaml"], specifier = ">=2.14.2" }, { name = "pyright", marker = "extra == 'dev'" }, { name = "ruff", marker = "extra == 'dev'" }, { name = "ty", marker = "extra == 'dev'" }, - { name = "uvicorn", specifier = ">=0.48.0" }, + { name = "uvicorn", specifier = ">=0.51.0" }, { name = "uvloop", marker = "sys_platform != 'win32'", specifier = ">=0.22.1" }, ] provides-extras = ["dev"] @@ -178,8 +180,8 @@ dev = [{ name = "gemini-fastapi", extras = ["dev"] }] [[package]] name = "gemini-webapi" -version = "0.0.post293" -source = { git = "https://github.com/luuquangvu/Gemini-API.git?rev=enable-guest-mode#b1a2b48a4cab4ba44c1dd3d2782822b3abf86b7e" } +version = "0.0.post258" +source = { git = "https://github.com/luuquangvu/Gemini-API.git?rev=enable-guest-mode#939256582a01fd7a9b30899fb54af39009e6be85" } dependencies = [ { name = "curl-cffi" }, { name = "loguru" }, @@ -213,25 +215,25 @@ wheels = [ [[package]] name = "idna" -version = "3.16" +version = "3.18" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/1a/88/bcf9709822fe69d02c2a6a77956c98ce6ea8ca8767a9aadcedc7eb6a2390/idna-3.16.tar.gz", hash = "sha256:d7a6da03db833450fca25d2358ac9ff06cd624577a4aea3a596d5c0f77b8e03d", size = 203770, upload-time = "2026-05-22T00:16:18.781Z" } +sdist = { url = "https://files.pythonhosted.org/packages/cd/63/9496c57188a2ee585e0f1db071d75089a11e98aa86eb99d9d7618fc1edce/idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848", size = 196711, upload-time = "2026-06-02T14:34:07.794Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/94/16/70255075a9859a0e3adb789b68ceb0e210dec03934245fd98d248226572f/idna-3.16-py3-none-any.whl", hash = "sha256:cc246e3a3f89580c3a951b5ad298ca4638078b2cdd4f115654332b5c26daded5", size = 74165, upload-time = "2026-05-22T00:16:16.698Z" }, + { url = "https://files.pythonhosted.org/packages/1e/5e/d4e9f1a599fb8e573b7b87160658329fbf28d19eac2718f51fc3def3aa5a/idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2", size = 65455, upload-time = "2026-06-02T14:34:06.319Z" }, ] [[package]] name = "lmdb" -version = "2.2.0" +version = "2.3.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/21/44/d94934efaf8f887b6959f131fde740fcaa831edfd13eb5425574637cddd5/lmdb-2.2.0.tar.gz", hash = "sha256:53020e20305c043ea6e68089bc242d744fba6073cdb268332299ba6dda2886d4", size = 933189, upload-time = "2026-03-30T01:26:19.049Z" } +sdist = { url = "https://files.pythonhosted.org/packages/aa/0b/17f271b2d2d314da9c9bc7620676ede1feca5f565f3a46045319351f7bc2/lmdb-2.3.0.tar.gz", hash = "sha256:260f443640ee2da3cfd059a84258659319ff39ca912c16bf9748c324076b9d09", size = 954381, upload-time = "2026-07-12T15:46:30.534Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/64/43/543af71e8fa4c56623bb89c358121ab806426f26685f11539fe5452deffa/lmdb-2.2.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:36e0cbe6b7d59f6e19b448942c5f9e91674f596a802743258f82e926a9a09632", size = 113550, upload-time = "2026-03-30T01:25:55.727Z" }, - { url = "https://files.pythonhosted.org/packages/22/2c/4702d36c0073737554b20d1d62e879a066df963482f8e514866588ddd82d/lmdb-2.2.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e5d7a9dfd279a5884806fd478244961e4483cc6d7eb769caed1d7019a8608c20", size = 112135, upload-time = "2026-03-30T01:25:56.809Z" }, - { url = "https://files.pythonhosted.org/packages/2f/43/d015fea326ed0a634107f29740b002170a462b6d2481e509105c685520f5/lmdb-2.2.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d0dbe7902b2cdb60bf6c893f307ef2b2a5039afd22f029515b86183f05ab1353", size = 332108, upload-time = "2026-03-30T01:25:57.907Z" }, - { url = "https://files.pythonhosted.org/packages/bb/c9/503e7f173994b514936badcbcb7fa9f89a07a3cfe596c6fb95b1b91b8d70/lmdb-2.2.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9c576cdb163ae61a7ef6eecbc20a6025a4abe085491c1dc0c667d726f4926b53", size = 336017, upload-time = "2026-03-30T01:25:59.234Z" }, - { url = "https://files.pythonhosted.org/packages/3e/94/b3b064acfd2f8acf5aaa53fff2c43963dbc1932ba8b8df4e27d75bf6a34a/lmdb-2.2.0-cp313-cp313-win_amd64.whl", hash = "sha256:746eebcd4c0aeaf0eb2f897028929d270c5bc80ef4918500eec16db6f26f3fcc", size = 109574, upload-time = "2026-03-30T01:26:00.324Z" }, - { url = "https://files.pythonhosted.org/packages/b9/10/dc7488d1effc339cd9470f9d22ec0fd7052a3d4fdfae87765ecd41cb2e59/lmdb-2.2.0-cp313-cp313-win_arm64.whl", hash = "sha256:006153aac9fb0415a5f3e8ac88789e5730dba3dd0743cd84c95e3951ff68bc3a", size = 103810, upload-time = "2026-03-30T01:26:01.559Z" }, + { url = "https://files.pythonhosted.org/packages/8b/e8/d8594c13c81652d313e5df0cbade98a3fa47de6c14e46aec5eed02a2ca5c/lmdb-2.3.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:93de120dec1ec982b80852c958d11242b78c0972a526a839cc5a65b44d22c8f7", size = 120486, upload-time = "2026-07-12T15:46:06.493Z" }, + { url = "https://files.pythonhosted.org/packages/d7/d3/c91996eb4ffeb1537710c4bf3492249b01abd56c75ec34a5b2179de4b91b/lmdb-2.3.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:d94cd8515ea767115ac2ee302ae83cde3843425901fdc3bfa9fb08980299c023", size = 120032, upload-time = "2026-07-12T15:46:07.718Z" }, + { url = "https://files.pythonhosted.org/packages/b2/0f/536d296dd90418533ccd277a20836c753f2f51d127a6e32e53c8653fbd45/lmdb-2.3.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1633f4700664436f2d71bb029bce9bdcaedcd2294c97ba853c6870c973de0bdd", size = 344681, upload-time = "2026-07-12T15:46:09.017Z" }, + { url = "https://files.pythonhosted.org/packages/79/45/1dc1ff9c998d08051728fef5f60eb31f8f9294a7bd80a786a6ae6917f2db/lmdb-2.3.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f8d815c4f1ad38d048efeee395c935b8839ed244a1d74526c83e31c05faab3ec", size = 346786, upload-time = "2026-07-12T15:46:10.535Z" }, + { url = "https://files.pythonhosted.org/packages/f3/d4/1bd0b10a0d7408f2d6a4c49be9176287bdccc8c3951a96abfd8f61eb5ec1/lmdb-2.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:f45e10949d0fc7a0cc4bc9b3bfe34d824b5a71330312289e43dc2638c26a9f13", size = 115087, upload-time = "2026-07-12T15:46:11.779Z" }, + { url = "https://files.pythonhosted.org/packages/80/f9/a4d82dedaf2a9090bb44c5ab300158a22f4c26a30355c7ffb40f52503bc6/lmdb-2.3.0-cp313-cp313-win_arm64.whl", hash = "sha256:de099ca35b010fd0c5eed957e7ffda0aee32639fb7d12c0dadbb850be9c62dee", size = 114437, upload-time = "2026-07-12T15:46:12.867Z" }, ] [[package]] @@ -351,16 +353,16 @@ wheels = [ [[package]] name = "pydantic-settings" -version = "2.14.1" +version = "2.14.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pydantic" }, { name = "python-dotenv" }, { name = "typing-inspection" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/07/60/1d1e59c9c90d54591469ada7d268251f71c24bdb765f1a8a832cee8c6653/pydantic_settings-2.14.1.tar.gz", hash = "sha256:e874d3bec7e787b0c9958277956ed9b4dd5de6a80e162188fdaff7c5e26fd5fa", size = 235551, upload-time = "2026-05-08T13:40:06.542Z" } +sdist = { url = "https://files.pythonhosted.org/packages/5c/b5/8f48e906c3e0205276e8bd8cb7512217a87b2685304d64be27cad5b3019f/pydantic_settings-2.14.2.tar.gz", hash = "sha256:c19dd64b19097f1de80184f0cc7b0272a13ae6e170cbf240a3e27e381ed14a5f", size = 237700, upload-time = "2026-06-19T13:44:56.324Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ae/8d/f1af3832f5e6eb13ba94ee809e72b8ecb5eef226d27ee0bef7d963d943c7/pydantic_settings-2.14.1-py3-none-any.whl", hash = "sha256:6e3c7edfd8277687cdc598f56e5cff0e9bfff0910a3749deaa8d4401c3a2b9de", size = 60964, upload-time = "2026-05-08T13:40:04.958Z" }, + { url = "https://files.pythonhosted.org/packages/77/c1/6e422f34e569cf8e18df68d1939c81c099d2b61e4f7d9621c8a77560799c/pydantic_settings-2.14.2-py3-none-any.whl", hash = "sha256:a20c97b37910b6550d5ea50fbcc2d4187defe58cd57070b73863d069419c9440", size = 61715, upload-time = "2026-06-19T13:44:55.02Z" }, ] [package.optional-dependencies] @@ -379,15 +381,15 @@ wheels = [ [[package]] name = "pyright" -version = "1.1.409" +version = "1.1.411" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "nodeenv" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/51/4e/3aa27f74211522dba7e9cbc3e74de779c6d4b654c54e50a4840623be8014/pyright-1.1.409.tar.gz", hash = "sha256:986ee05beca9e077c165758ad123667c679e050059a2546aa02473930394bc93", size = 4430434, upload-time = "2026-04-23T11:02:03.799Z" } +sdist = { url = "https://files.pythonhosted.org/packages/7e/ab/265f7dc69d28113ebba19092e57b075f41543b2ed048429c5f56e2b88eac/pyright-1.1.411.tar.gz", hash = "sha256:d885a0551f2e763b089a02702174e7f4ba77548cddabc972ab86d1f7f1b0f998", size = 4112861, upload-time = "2026-06-25T02:14:06.37Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/16/6b/330d8ebae582b30c2959a1ef4c3bc344ebde48c2ff0c3f113c4710735e11/pyright-1.1.409-py3-none-any.whl", hash = "sha256:aa3ea228cab90c845c7a60d28db7a844c04315356392aa09fafcee98c8c22fb3", size = 6438161, upload-time = "2026-04-23T11:02:01.309Z" }, + { url = "https://files.pythonhosted.org/packages/0a/49/385be530a6a5b78d1cbcd5c2e38debc8959a2fc6bdb716f4e581002979fc/pyright-1.1.411-py3-none-any.whl", hash = "sha256:dc7c72a8e2700c55baa127554040e067041ea53ccfd50bf96308cc4291c7d5d9", size = 6181526, upload-time = "2026-06-25T02:14:04.691Z" }, ] [[package]] @@ -432,73 +434,73 @@ wheels = [ [[package]] name = "ruff" -version = "0.15.14" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/dc/8a/8bce2894573e9dae6ff4d77fe34ad727d79b9e6238ad288c5638990d90f6/ruff-0.15.14.tar.gz", hash = "sha256:48e866b165be4a9bdbf310f7d3c9a07edef2fe8cd63ffeb4e00bb590506ebf9f", size = 4700910, upload-time = "2026-05-21T14:34:55.177Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/b9/c8/74a92c6ff9fcfb4f1f947126d3ebee8389276e161ecc85de5bda7cda51bd/ruff-0.15.14-py3-none-linux_armv6l.whl", hash = "sha256:8dd2db9416e487c8d4b01fa7056bb02c4d05969d4f8d17a08c229c2f4ff3c108", size = 10739177, upload-time = "2026-05-21T14:34:37.332Z" }, - { url = "https://files.pythonhosted.org/packages/45/91/254a35c20acc38a7223c9d2d594af12e794432464f2cdeb52af1dc4a892d/ruff-0.15.14-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:be4ff55af755bd71a00ab3dc6bd7ffc467bd76e0df6881e286c2e3d23e8fb43b", size = 11144969, upload-time = "2026-05-21T14:34:43.978Z" }, - { url = "https://files.pythonhosted.org/packages/56/9e/d13e40f83b8d0a94430e6778ce1d94a43b38cf2efe63278bdd2b4c65abbf/ruff-0.15.14-py3-none-macosx_11_0_arm64.whl", hash = "sha256:48d5909d7d06276ce7dde6d32bfa4b0d4cb2651145cd8ee4b440722cbc77832f", size = 10478207, upload-time = "2026-05-21T14:34:48.378Z" }, - { url = "https://files.pythonhosted.org/packages/8d/f1/b15a7839fa4f332f8acec78e20564f26bb2d866e3d21710b877fd0263000/ruff-0.15.14-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ca8cbfa94c4f90984a67561978602746d4cd27103568f745fa90eee3f0d4107d", size = 10818459, upload-time = "2026-05-21T14:34:22.318Z" }, - { url = "https://files.pythonhosted.org/packages/45/33/53d651177f84f94b400a0e27f8824eeada3dddc9d5ee8aeb048f4352a520/ruff-0.15.14-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9a6bbc0333f1ab053423bcbf6226477d266ca7cec7738c4c8e3f55647803f3c4", size = 10541800, upload-time = "2026-05-21T14:34:20.209Z" }, - { url = "https://files.pythonhosted.org/packages/b8/a6/868f87e0bf9786ed24b5d0d0ad8676b8a94fd1912f42cddf9cfc7857818a/ruff-0.15.14-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8a24a4f7605d7003a6674d4387651effd939dead3fddd0f36561eb77a9a2e542", size = 11342149, upload-time = "2026-05-21T14:34:46.365Z" }, - { url = "https://files.pythonhosted.org/packages/a7/8b/38cd5c19faffdcc05a408d2b78edccc69492ab9720eadb49ea15ef80d768/ruff-0.15.14-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:049b5326e53ed80978f2fc041a280603f69dd6b0c95464342a2bb4572d9d9e2f", size = 12212563, upload-time = "2026-05-21T14:34:28.579Z" }, - { url = "https://files.pythonhosted.org/packages/3e/4d/a3c5b874a556d5731e3e657aaf04311bb76f0a5c3ec220ed43051be6b64b/ruff-0.15.14-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d4ed42e6696c8dfa5f06728e6441993901f548eb92d73bc472cb5a38d1395fbf", size = 11493299, upload-time = "2026-05-21T14:34:41.836Z" }, - { url = "https://files.pythonhosted.org/packages/1e/c0/56472c251d09858a53e51efbd485b09e1995d8731668b76d52e5dd6ee0f1/ruff-0.15.14-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:715c543cf450c4888251f91c52f1942a800541d9bddd7ac060aa4e6b77ae7cba", size = 11455931, upload-time = "2026-05-21T14:34:57.276Z" }, - { url = "https://files.pythonhosted.org/packages/2c/4a/e2e7b4d8dbf233d4eace59c75bc3435fa6d8bd3bae82d351d4e4300c0fd1/ruff-0.15.14-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:72ebab6013ec887d439d8b7593737a0a4ffb06d45d209d4e4bf2e92813082d3f", size = 11400794, upload-time = "2026-05-21T14:34:39.773Z" }, - { url = "https://files.pythonhosted.org/packages/97/c7/83c0539fe34c3e09136204d1e75d6052492364e0b3cb05e9465423f567d7/ruff-0.15.14-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:49072d36abdbe97a8dd7f480afe9c675699c0c495d4c84076e2c1203c4550581", size = 10804759, upload-time = "2026-05-21T14:34:31.045Z" }, - { url = "https://files.pythonhosted.org/packages/86/a6/18f2bfc095a2ab4a78745644e428205532ce6653a5d0fa8501572891534d/ruff-0.15.14-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:958522aee105068640c2c2ceae08f413ae44d922f52a1374ac13d6a96032fc93", size = 10539517, upload-time = "2026-05-21T14:34:53.064Z" }, - { url = "https://files.pythonhosted.org/packages/54/3a/5a8b3b69c654d4e4bf1d246ac5b49cbcdac6eaab6905925f8915f31e3b80/ruff-0.15.14-py3-none-musllinux_1_2_i686.whl", hash = "sha256:f3707da619a143a2e8830e2abab8224478d69ace2d28cb6c20543ae97c36bf61", size = 11065169, upload-time = "2026-05-21T14:34:24.484Z" }, - { url = "https://files.pythonhosted.org/packages/ed/c5/8864e4e7925b836ea354b31d57641ec03830564e281a8b6f061f8c3e0ec1/ruff-0.15.14-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:bb01d645694e3ec0102105d07ef2d53703970407d59c04e59d3ba0b7a1d53553", size = 11560214, upload-time = "2026-05-21T14:34:50.975Z" }, - { url = "https://files.pythonhosted.org/packages/36/38/012bf76752e1f89ed50b77b99532d90f3a3e287bc7918e1fc0948ac866ac/ruff-0.15.14-py3-none-win32.whl", hash = "sha256:6d0c1ad2a0ab718d39b6d8fd2217981ce4d625cd96a720095f798fb47d8b13e6", size = 10805548, upload-time = "2026-05-21T14:34:33.453Z" }, - { url = "https://files.pythonhosted.org/packages/d1/b7/4ea2c170f10ad760fff2a5250beb18897719dc8b52b53a24cddbb9dd3f19/ruff-0.15.14-py3-none-win_amd64.whl", hash = "sha256:802342981e056db3851a7836e5b070f8f15f67d4a685ae2a6160939d364b2902", size = 11939523, upload-time = "2026-05-21T14:34:18.077Z" }, - { url = "https://files.pythonhosted.org/packages/62/d5/bc97ff895ec35cf3925d4bd60f3b39d822f377a446906ec9bcc87405e59b/ruff-0.15.14-py3-none-win_arm64.whl", hash = "sha256:ff47b90a9ef6a40c9e2f3b479c1fb78531adf055b94c1eba0a7ba04b31951826", size = 11208607, upload-time = "2026-05-21T14:34:26.525Z" }, +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/4d/94/1e5e4967626faf12fa56999cd6222dff6992ceb086ad7945756baf70c7a7/ruff-0.16.0.tar.gz", hash = "sha256:e460aafd5495ec89efaa6ced2e4a9a581116451e1c88b9d37ef497e0f8e93982", size = 4790557, upload-time = "2026-07-23T19:11:30.981Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4b/81/1c8818fee7ce1a04cd7d1b3172e0a8f8e4f1dc4feb7fc390e16daa8af323/ruff-0.16.0-py3-none-linux_armv6l.whl", hash = "sha256:e5115729eb08c585e5121978ba5d5b60caeae394ce21b9fb5e6cd33a1c6c9b1e", size = 10754633, upload-time = "2026-07-23T19:10:46.415Z" }, + { url = "https://files.pythonhosted.org/packages/23/df/beaf59c09d68db84304d555f188b276a77132a5d5b0b67a5c762aa143628/ruff-0.16.0-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:3c954b1d580bfa035b41654f7858cc7e71d5fc3ac5b723dd62bd9133830ed522", size = 10969164, upload-time = "2026-07-23T19:10:50.271Z" }, + { url = "https://files.pythonhosted.org/packages/42/ce/741cd197496a1abbf51352710fd15ed995d2a2be87189c1da26a450d6e83/ruff-0.16.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:e01c21d10eb1b29f47b7454e1f4056db9a3f0260c646aa88457c610291db9f81", size = 10488846, upload-time = "2026-07-23T19:10:52.639Z" }, + { url = "https://files.pythonhosted.org/packages/52/2a/a2db8e88cade358f5cdcb05674a917751074109315d014eb6352d9a893f7/ruff-0.16.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6e364e5ed22ed8dc05082fd78e35308618260907ac2d3c1d637b2e682415b6c9", size = 10889729, upload-time = "2026-07-23T19:10:54.89Z" }, + { url = "https://files.pythonhosted.org/packages/42/65/62a771694ebd63029dc953e27dbad40e1588bd4860ff9fe881018fddaa49/ruff-0.16.0-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d327b8fc113a1d4421a04f3839d3752057c8dd1ee320223a6f3f52d04ada462a", size = 10568275, upload-time = "2026-07-23T19:10:56.993Z" }, + { url = "https://files.pythonhosted.org/packages/3f/e2/ced249fe8af5f086c5c58cc21cc3356d50f32f7401c5df87050c999620a7/ruff-0.16.0-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a9b50c55e263103586b3dcf5f73d479eb8cb5fdb6098fec59a62891dab653717", size = 11385112, upload-time = "2026-07-23T19:10:59.615Z" }, + { url = "https://files.pythonhosted.org/packages/87/0b/05154977a8fd69eeb6c103271f55403bfd8711f5c0f8ed07489d95a504e7/ruff-0.16.0-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0ff4a79ce3ec0172f3241943835de1c4cb4e2dcd07f0f8c2d02603dbbbee4b17", size = 12207008, upload-time = "2026-07-23T19:11:02.154Z" }, + { url = "https://files.pythonhosted.org/packages/fb/29/98225831a3a1eab0e02f4acc6ca6559a98611dcc68b6965ff4b7234627c1/ruff-0.16.0-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e95c448fca1fb2a18372a9440926c5a6ee789639bb975c72e7ae6d0b04218ab4", size = 11650842, upload-time = "2026-07-23T19:11:04.557Z" }, + { url = "https://files.pythonhosted.org/packages/91/66/6bd3cf90500653d55dc0ffc8507aa8300bd49d0214b2e8cb4d3fef2943ba/ruff-0.16.0-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4f11a8d11010301d0a398a2fdef67691feca7294da6aef55e2150e8fa2cd520b", size = 11400718, upload-time = "2026-07-23T19:11:09.233Z" }, + { url = "https://files.pythonhosted.org/packages/8e/a2/a54eb4eae05d66364050a5d3b8a9c5ef88196531b3cbe7109d873f87f819/ruff-0.16.0-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:48044c678e9cb8698246c99b14aaccfa6601dea7379eb48a6f8f73f7a6d86cd0", size = 11426177, upload-time = "2026-07-23T19:11:11.994Z" }, + { url = "https://files.pythonhosted.org/packages/1a/be/16e3eea4b2a478a496919f5e36f17c4559e54620bd3bbac5d6affa068006/ruff-0.16.0-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:7aa0959bad8eb8bef50340154fc9b58678dae31fa4293afa38b44b6e552c0213", size = 10856126, upload-time = "2026-07-23T19:11:14.221Z" }, + { url = "https://files.pythonhosted.org/packages/a2/84/252eb8b868a16eec7257c14f504f77537e734b2d69c762e639e588e304a3/ruff-0.16.0-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:28ea2b7df8ebf7f9da6b7d47b230ab48f387c0a29be3b474c4d0740e197bb9af", size = 10571208, upload-time = "2026-07-23T19:11:16.378Z" }, + { url = "https://files.pythonhosted.org/packages/21/09/817a482f542f7570cbb4554b26e896610c7114f539b1d9e2d2145bf6bef6/ruff-0.16.0-py3-none-musllinux_1_2_i686.whl", hash = "sha256:33a3dfac8c35f81498dea9181bccc2f4c4bc8f1521a1dd9406e77643e0f0fb09", size = 11063329, upload-time = "2026-07-23T19:11:19.173Z" }, + { url = "https://files.pythonhosted.org/packages/2e/23/9403c180ca1cb9b1f7335f5c3e5305c09d49ea5b345196682a36028bde4a/ruff-0.16.0-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:a5237a0bda500d30d81b8e07a6973a5cbc772864cbf746ae2f4e8a2e01c9f4ed", size = 11489751, upload-time = "2026-07-23T19:11:21.74Z" }, + { url = "https://files.pythonhosted.org/packages/b2/1d/1b2ef7bcde851c78d7f17f1cca13fd6dc695fc4b3d6197941e72cae5b132/ruff-0.16.0-py3-none-win32.whl", hash = "sha256:7fab76fa065c873f41ff744347c6e77bcc3dfec4bcc754dc26b63d23c0f7f5fb", size = 10785885, upload-time = "2026-07-23T19:11:23.947Z" }, + { url = "https://files.pythonhosted.org/packages/b2/a3/d5e4ef7a56be3f928ffb90b94c25ba7d3cb9c7fe0736aeaaedf361770712/ruff-0.16.0-py3-none-win_amd64.whl", hash = "sha256:429c117f022bf481fabd9d551e7a3952b24c65e6ef44337ea09d90bebef14472", size = 11923141, upload-time = "2026-07-23T19:11:26.409Z" }, + { url = "https://files.pythonhosted.org/packages/cb/9a/8415f2657cbe200f41a4531ccededf135505a92d4a012229121f885b26f9/ruff-0.16.0-py3-none-win_arm64.whl", hash = "sha256:14296fedcd2705c77ab8235439278bbb38f285cf7da5528b00b3e330c3d4872d", size = 11273407, upload-time = "2026-07-23T19:11:28.705Z" }, ] [[package]] name = "starlette" -version = "1.1.0" +version = "1.3.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/95/66/4d20cdf39a8d6a51e663b7038e3b828ff211d3891a43a713fe7e4643f3a8/starlette-1.1.0.tar.gz", hash = "sha256:e83c7fe0ddecd8719c5b840080325aec0260acec86e9832899e377b91d65e90f", size = 2660060, upload-time = "2026-05-23T16:55:41.376Z" } +sdist = { url = "https://files.pythonhosted.org/packages/eb/e3/7c1dc7381d9f8ab7d854328ebfa884e62cb3f3d8549ddfd37c7814f42afa/starlette-1.3.1.tar.gz", hash = "sha256:05d0213193f2fbaae60e2ecb593b4add4262ad4e46536b54abe36f11a71724e0", size = 2703240, upload-time = "2026-06-12T09:23:11.602Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/93/79/920b8e0a8b20f793e8d64855095cb8febabf6175b8550b6f7a547d813891/starlette-1.1.0-py3-none-any.whl", hash = "sha256:7f0dfd38e428aad5cb6f9f667f0ca1d2d8ca3f3385dccac8305f79ec98458382", size = 72899, upload-time = "2026-05-23T16:55:39.201Z" }, + { url = "https://files.pythonhosted.org/packages/ec/bb/2799cc2ede3ed41131f8975621e7213dfc7ef4acbbaadfa440f32500c370/starlette-1.3.1-py3-none-any.whl", hash = "sha256:c7372aae11c3c3f26a42df7bd626cec2f47d03483d261d369516a615a53714c6", size = 73632, upload-time = "2026-06-12T09:23:10.017Z" }, ] [[package]] name = "ty" -version = "0.0.40" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/5a/f8/a754c96967b71de8723f88be17df8738216bd382ffed229cd500b7a24d13/ty-0.0.40.tar.gz", hash = "sha256:883b53dd98f6e5b33ab1c8e1a3cd94b0f29c762ef22cdf1e86aaffb4fd711c67", size = 5726484, upload-time = "2026-05-27T17:55:43.615Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/2c/42/d029a72165ad39f95228b67355927fbd35c821dc8e3e475d49f47c2eeb1e/ty-0.0.40-py3-none-linux_armv6l.whl", hash = "sha256:9defb4742450e569a6a09de286a04008d6c2e815112da4362c88b6eaa2f52a36", size = 11406372, upload-time = "2026-05-27T17:55:49.633Z" }, - { url = "https://files.pythonhosted.org/packages/23/99/7f8ea09b7e49afbf795cb3341a3217f30f228db7e62a2268ed8cbbf813d6/ty-0.0.40-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:868258a3330db88b683fcafe2c4e936d6226a6312799bf15b585d93557b2d38c", size = 11159782, upload-time = "2026-05-27T17:55:47.405Z" }, - { url = "https://files.pythonhosted.org/packages/04/d8/1ea745ee97a98b26ae9564d19a430a76a35297cd450e84dcaad22e1f7ee8/ty-0.0.40-py3-none-macosx_11_0_arm64.whl", hash = "sha256:589c81060cf1e7a9ffa2f45bfa35ffd9b9fbd214104e3f13959f113627efcd91", size = 10594139, upload-time = "2026-05-27T17:55:37.206Z" }, - { url = "https://files.pythonhosted.org/packages/39/1a/fbef21273c6617ff4715b4827ee1c0b6550aa7d1df4b8c43b325545c1cf4/ty-0.0.40-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7b06108990cb338d941c315ae6e9ba2fff8f518bc15d3f33e5619ff6a6c9beab", size = 11114156, upload-time = "2026-05-27T17:55:56.11Z" }, - { url = "https://files.pythonhosted.org/packages/3c/f9/389fc4976d7ec016a7473cf1274bf9c4f491bb54c66649bd022bff9f2b6a/ty-0.0.40-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3913ef37336bec4f96bd2512f8c3a543ca34c259b7170f7eb5adf75b3ed7f04c", size = 11189050, upload-time = "2026-05-27T17:55:54.099Z" }, - { url = "https://files.pythonhosted.org/packages/fa/a9/4ecabbf4bdda7df0d99d8d3892c6edac0efc8c4cae756a5109178a3d0e86/ty-0.0.40-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8fd1486bd5fe48779a8aa857137f3642a0a9161f5cf57d4380f4a0ecea01c8f3", size = 11664266, upload-time = "2026-05-27T17:55:28.17Z" }, - { url = "https://files.pythonhosted.org/packages/45/02/0aa78730116507c265afb1d6d5961c583b49d4c2e368c4a49fd81bcae6dc/ty-0.0.40-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1668364d5254a734329917ee66c2c5fdd5665389d41043f6fce0f22ddb32b749", size = 12187743, upload-time = "2026-05-27T17:56:04.337Z" }, - { url = "https://files.pythonhosted.org/packages/e6/68/ccabf2d173523598271a385c1d3f864dbda23e5ebdc67f5969b9e830ea05/ty-0.0.40-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:43f77a73edb91e5dfa2ab9af7c4cac64614f8cc121f38a8875f22e830d3aba6a", size = 11862999, upload-time = "2026-05-27T17:55:58.087Z" }, - { url = "https://files.pythonhosted.org/packages/03/8d/6d7ec22771bb23d534797cdb446eb644bccfe7a62b729bb99e7235a02fc3/ty-0.0.40-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1274ce0212ecbfed01bda7c3659c46e8bd0068e32d00c46c790466a95274c3df", size = 11743896, upload-time = "2026-05-27T17:56:00.017Z" }, - { url = "https://files.pythonhosted.org/packages/cd/a4/f9fa076b010c91cb249b1fcc3476569b7b8462cb4b688da2d04c23a0622f/ty-0.0.40-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:5ee1261dbc363e5cc1a0c5bb0c8612c192bfe53491214df8bc85a540835685f9", size = 11883581, upload-time = "2026-05-27T17:56:02.319Z" }, - { url = "https://files.pythonhosted.org/packages/fd/0f/5b776a2328c756d574dd4d6afbd30fc24e1ab4b76935c7c3c23f27ebbcb9/ty-0.0.40-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:6220e2cd5cdc4683dd87fb150d195bbd9f1a021395e04cb08bd3c66ea6da6ef8", size = 11093946, upload-time = "2026-05-27T17:55:33.284Z" }, - { url = "https://files.pythonhosted.org/packages/64/c4/eb23154bae83ad7c2935e9e5916660fb3e31598a92ee232aebd79410480c/ty-0.0.40-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:46b9ed69d01d98ef046afac9983c68336f572605ea2a27b90fbe6f80bfc8d6b7", size = 11210737, upload-time = "2026-05-27T17:55:45.523Z" }, - { url = "https://files.pythonhosted.org/packages/ff/19/1fb2529703f708cacfd13a89f98613cae2907dfa941b26976467e6119803/ty-0.0.40-py3-none-musllinux_1_2_i686.whl", hash = "sha256:ddbca9fab4406260f141674ab5efcfe7b02bd468e6985e4cdde0a21626e69ffe", size = 11332563, upload-time = "2026-05-27T17:55:41.674Z" }, - { url = "https://files.pythonhosted.org/packages/87/69/b3f5a8ef26c31204e0391147b3adcdb0674eda3e7d99868478ef168a41c6/ty-0.0.40-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:b1fcc082a749e6dc11b68fe9aab0420238bbf2a2374c2c7aa3c22e8c1618b136", size = 11843216, upload-time = "2026-05-27T17:55:35.367Z" }, - { url = "https://files.pythonhosted.org/packages/ac/e8/20193069d32787f3e1a6ec8940aaa3759d3de8f48f9281bcc0c5cb0939da/ty-0.0.40-py3-none-win32.whl", hash = "sha256:75feb115b3587824c5bdf8f8305e9547b0d1e398e3077b0addc7a1988ea9bb50", size = 10670731, upload-time = "2026-05-27T17:55:31.316Z" }, - { url = "https://files.pythonhosted.org/packages/a3/f9/8b2aa4da61db81322d4a2f9db227afeb48110ca15ae31d380f64c64ceb63/ty-0.0.40-py3-none-win_amd64.whl", hash = "sha256:b0f905edaad788bd61f779a85801b60a267a25ed57fca05aaddd168d9d8896be", size = 11766211, upload-time = "2026-05-27T17:55:51.898Z" }, - { url = "https://files.pythonhosted.org/packages/04/87/369056ed46f1b235130ec0595393262f9cd2061ca3dab276d490980f9343/ty-0.0.40-py3-none-win_arm64.whl", hash = "sha256:07da2b09d9130e2c9a257d2a29beb53105835b0256ee5fdb288fe1aab83fee47", size = 11117369, upload-time = "2026-05-27T17:55:39.329Z" }, +version = "0.0.64" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8e/aa/14c9965d3b173105692473897cc89c34cd91241368b2044e43167e1c17ff/ty-0.0.64.tar.gz", hash = "sha256:d12ddbb05f15158bb518af619378b385486450def95fb06f8ab98037febe9f2c", size = 6350966, upload-time = "2026-07-27T18:32:45.403Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ae/4c/c54937e4ff3fa7b34a99ea3387ec766bf0ad98dc8df8d792e89b388e658e/ty-0.0.64-py3-none-linux_armv6l.whl", hash = "sha256:3830a6675ab43635ced1c4c557f380ac4a49e9414e03975e4e4e8db644c64944", size = 12118357, upload-time = "2026-07-27T18:32:08.702Z" }, + { url = "https://files.pythonhosted.org/packages/ce/aa/a839ee2bc78e943d079e6abe199a97b4eeffb7e5c9a57326d69de452186a/ty-0.0.64-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:3ff07d7bc32a2135f58afe57393789a32ea2fed1a66216129a8e535e76043903", size = 11790882, upload-time = "2026-07-27T18:32:10.997Z" }, + { url = "https://files.pythonhosted.org/packages/4c/de/19f14357888a7198438926303753cf749428e3d62e8980ff1e9a72a78402/ty-0.0.64-py3-none-macosx_11_0_arm64.whl", hash = "sha256:4f6d1c7f897cca05d12bacbf1435150d5ffa496099515aa6ed303c8b29e1d0bb", size = 11317394, upload-time = "2026-07-27T18:32:13.162Z" }, + { url = "https://files.pythonhosted.org/packages/08/2f/f54462300535ab99b551eda733177be2eef5dbc2997d3fdb357c4ddd760a/ty-0.0.64-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:138d6c37ad4bf8583aa7a9b29d90954151d7856f9910a03eae3eb34b34c57215", size = 11863042, upload-time = "2026-07-27T18:32:15.307Z" }, + { url = "https://files.pythonhosted.org/packages/5e/95/dbecf745520ebe8bd7b02fc55eee6441c9be312ebf6addce605eb52740dd/ty-0.0.64-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:1ed9719d1b7b66fb8efe073d860208a44c40af1f6cd5c2364aa9b323a1e579b4", size = 11910730, upload-time = "2026-07-27T18:32:17.467Z" }, + { url = "https://files.pythonhosted.org/packages/3b/26/12cfd40028e51ceed7b3cb645281c61c02eb64ff9fb0c09231d65c30ff25/ty-0.0.64-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d68b23e5169e2137b5f1de7169ab0cebecab6c8eda374c34c1f6394308f58242", size = 12631936, upload-time = "2026-07-27T18:32:19.533Z" }, + { url = "https://files.pythonhosted.org/packages/a0/d0/65ffc2b0a686347193c6f98e9421a7fc2a96fc3cd0b1001cf7cf284baff9/ty-0.0.64-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f41cb07d89d32626fcaf3ed4d262778fcb28b2628b6ed1e172cd7b18820668d8", size = 13171049, upload-time = "2026-07-27T18:32:22.026Z" }, + { url = "https://files.pythonhosted.org/packages/f6/a4/975a5961842dcd6fa60f0770a102c0bba7509da909ab652919bfcdcd4fc7/ty-0.0.64-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5f24e1504ab9e212f92356b82fe088fdeb3a39f9a2f4ff25e505d2e1d0db9056", size = 12826438, upload-time = "2026-07-27T18:32:24.178Z" }, + { url = "https://files.pythonhosted.org/packages/af/ef/dfb9b7f9bcc032d3b540b0d1f55f532a336e2fb41b1bd539c05ae81a151a/ty-0.0.64-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:86db830cb914bb33bb8247b66ccea58de4496c2391bd42658aba744b439f3290", size = 12440880, upload-time = "2026-07-27T18:32:26.341Z" }, + { url = "https://files.pythonhosted.org/packages/ee/d0/bedac20505e8a8f5501ad73d7d15d8e421a563fef59993909a23036929a4/ty-0.0.64-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:652ee3d6d03bea76cd2fe8949c78bb5970394ebd7c5fd270e9518c0dee1b931d", size = 12782439, upload-time = "2026-07-27T18:32:28.642Z" }, + { url = "https://files.pythonhosted.org/packages/79/d5/795733f13ceff1378f08b3de0c49d0f518df220ed856b0dfac869f3b7c81/ty-0.0.64-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:4838768295774a86e95f9ec5633e739d016adbbb69dcbdc62f3549578a11f624", size = 11814821, upload-time = "2026-07-27T18:32:30.632Z" }, + { url = "https://files.pythonhosted.org/packages/52/3e/9d99cd1e1831003434f508ed9f258a56543194afc3bbe051eba2545fa676/ty-0.0.64-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:5a3d700669868599edf39ce5125196682f15a8880cc8c669bc2a83b99984d99b", size = 11928678, upload-time = "2026-07-27T18:32:32.888Z" }, + { url = "https://files.pythonhosted.org/packages/b8/3d/448f49a3503fb119a34348a5714bb92001f252fadbbb12d645f2e744b557/ty-0.0.64-py3-none-musllinux_1_2_i686.whl", hash = "sha256:b161f0a82a8e2f2432db3bf7702b4d3924fa9486ba0014f6710a160fc157df0d", size = 12202249, upload-time = "2026-07-27T18:32:34.905Z" }, + { url = "https://files.pythonhosted.org/packages/dd/9b/75768e562cec990d189dc05807ae72890b20ffbd1e1f43597bb98db63c60/ty-0.0.64-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:39b9dd42908df47c2dc57dda87e656fab97097ffd2618474bbdea986af0d6a9d", size = 12548817, upload-time = "2026-07-27T18:32:36.995Z" }, + { url = "https://files.pythonhosted.org/packages/34/89/44cc276ea6ca0245495014758ffeadde1798fb0b5840c9cf36d8d2ed3250/ty-0.0.64-py3-none-win32.whl", hash = "sha256:d0676ab0e0935795e5843baa28dd5e366dc343d7c0945a996df9eab8e0644885", size = 11545474, upload-time = "2026-07-27T18:32:39.389Z" }, + { url = "https://files.pythonhosted.org/packages/01/7e/d1c8a871a38d17c8f168b9a6975f6247f7660f8334e517656a5e4b4a4858/ty-0.0.64-py3-none-win_amd64.whl", hash = "sha256:dcb9bd31f54097e362b776c26ab4564d4564cdd1355cb883481167a26c03cc3f", size = 12542987, upload-time = "2026-07-27T18:32:41.412Z" }, + { url = "https://files.pythonhosted.org/packages/35/4d/6d18640d0204cacd69abbaca95ad6a34c6d7e9169e9051d0117b17b827ec/ty-0.0.64-py3-none-win_arm64.whl", hash = "sha256:82cc34c1ad9a8feb6059aef193bebcecc656e548f6fab3d518bcd8b57d198d39", size = 11899263, upload-time = "2026-07-27T18:32:43.366Z" }, ] [[package]] name = "typing-extensions" -version = "4.15.0" +version = "4.16.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } +sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload-time = "2026-07-02T08:40:05.92Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, + { url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" }, ] [[package]] @@ -515,15 +517,15 @@ wheels = [ [[package]] name = "uvicorn" -version = "0.48.0" +version = "0.51.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "click" }, { name = "h11" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/e6/bf/f6544ba992ddb9a6077343a576f9844f7f8f06ab819aefd00206e9255f18/uvicorn-0.48.0.tar.gz", hash = "sha256:a5504207195d08c2511bf9125ede5ac4a4b71725d519e758d01dcf0bc2d31c37", size = 91074, upload-time = "2026-05-24T12:08:41.925Z" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/65/b7c6c443ccc58678c91e1e973bbe2a878591538655d6e1d47f24ba1c51f3/uvicorn-0.51.0.tar.gz", hash = "sha256:f6f4b69b657c312f516dd2d268ab9ae6f254b11e4bac504f37b2ab58b24dd0b0", size = 94412, upload-time = "2026-07-08T10:59:05.962Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/01/be/72532be3da7acc5fdfbccdb95215cd04f995a0886532a5b423f929cda4cc/uvicorn-0.48.0-py3-none-any.whl", hash = "sha256:48097851328b87ec36117d3d575234519eb58c2b22d79666e9bbc6c49a761dad", size = 71410, upload-time = "2026-05-24T12:08:40.258Z" }, + { url = "https://files.pythonhosted.org/packages/45/ec/dbb7e5a6b91f86bfb9eb7d2988a2730907b6a729875b949c7f022e8b88fa/uvicorn-0.51.0-py3-none-any.whl", hash = "sha256:5d38af6cd620f2ae3849fb44fd4879e0890aa1febe8d47eb355fb45d93fe6a5b", size = 73219, upload-time = "2026-07-08T10:59:04.44Z" }, ] [[package]] From 0327f99030acc76fdefaf35dd32752c3c3cdaf0b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C6=B0u=20Quang=20V=C5=A9?= Date: Wed, 29 Jul 2026 13:35:39 +0700 Subject: [PATCH 271/291] Implement fully dynamic models loading from RPC and update dependencies --- uv.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/uv.lock b/uv.lock index 4b1eeac..2fdbc21 100644 --- a/uv.lock +++ b/uv.lock @@ -181,7 +181,7 @@ dev = [{ name = "gemini-fastapi", extras = ["dev"] }] [[package]] name = "gemini-webapi" version = "0.0.post258" -source = { git = "https://github.com/luuquangvu/Gemini-API.git?rev=enable-guest-mode#939256582a01fd7a9b30899fb54af39009e6be85" } +source = { git = "https://github.com/luuquangvu/Gemini-API.git?rev=enable-guest-mode#b016b81e2a183d2b49935b7967d1137554fe72e4" } dependencies = [ { name = "curl-cffi" }, { name = "loguru" }, From 62f159bb9a93868ec68c6d211acc79a8ca73c01a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C6=B0u=20Quang=20V=C5=A9?= Date: Wed, 29 Jul 2026 15:19:26 +0700 Subject: [PATCH 272/291] Implement fully dynamic models loading from RPC --- uv.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/uv.lock b/uv.lock index 2fdbc21..92e0ae2 100644 --- a/uv.lock +++ b/uv.lock @@ -181,7 +181,7 @@ dev = [{ name = "gemini-fastapi", extras = ["dev"] }] [[package]] name = "gemini-webapi" version = "0.0.post258" -source = { git = "https://github.com/luuquangvu/Gemini-API.git?rev=enable-guest-mode#b016b81e2a183d2b49935b7967d1137554fe72e4" } +source = { git = "https://github.com/luuquangvu/Gemini-API.git?rev=enable-guest-mode#d9663eab37bfa31e41ef77a432ab046a0652e940" } dependencies = [ { name = "curl-cffi" }, { name = "loguru" }, From cc837f97169b56f6c8a6ae0f7f9c1a0a153cf084 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C6=B0u=20Quang=20V=C5=A9?= Date: Fri, 31 Jul 2026 22:56:36 +0700 Subject: [PATCH 273/291] Resolve QUIC Idle Timeout --- pyproject.toml | 4 +- uv.lock | 102 ++++++++++++++++++++++++------------------------- 2 files changed, 53 insertions(+), 53 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 88b8d1b..8f8f6e8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,14 +6,14 @@ readme = "README.md" requires-python = "==3.13.*" dependencies = [ "curl-cffi>=0.15.0", - "fastapi>=0.140.13", + "fastapi>=0.141.1", "gemini-webapi>=2.0.0", "httptools>=0.8.0", "lmdb>=2.3.0", "loguru>=0.7.3", "orjson>=3.11.9", "pydantic-settings[yaml]>=2.14.2", - "uvicorn>=0.51.0", + "uvicorn>=0.52.0", "uvloop>=0.22.1; sys_platform != 'win32'", ] diff --git a/uv.lock b/uv.lock index 92e0ae2..60ae95e 100644 --- a/uv.lock +++ b/uv.lock @@ -114,7 +114,7 @@ wheels = [ [[package]] name = "fastapi" -version = "0.140.13" +version = "0.141.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "annotated-doc" }, @@ -123,9 +123,9 @@ dependencies = [ { name = "typing-extensions" }, { name = "typing-inspection" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/2f/cb/7a4d2c2eb5a5d8a91763c05b7383d72917862e32f780daa0e27ffbb34cc6/fastapi-0.140.13.tar.gz", hash = "sha256:500172a08cf1459901f90b05c37d93060dada3b573fec8f0862445db52ba6b4b", size = 424843, upload-time = "2026-07-28T15:37:00.805Z" } +sdist = { url = "https://files.pythonhosted.org/packages/8a/02/91e3416a8fdd715abb903a952a6bec7cdd8d14eed55d415fc8595524c319/fastapi-0.141.1.tar.gz", hash = "sha256:e8822fc40db1e1858054d7a949a888695bc9bdce70139178e33bd2871a453ca1", size = 425799, upload-time = "2026-07-29T17:18:05.568Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/84/4e/f9e8c762ef5e05c40482131e3d5e8b36bca13fa127578261f1d6b35a25d4/fastapi-0.140.13-py3-none-any.whl", hash = "sha256:8b017110e1e9f30a95e8bdb8f71fbe2f0fe3af5717109e5b14f9e069df54f6d4", size = 131222, upload-time = "2026-07-28T15:37:02.124Z" }, + { url = "https://files.pythonhosted.org/packages/cb/03/10388a42375ee7e4ac9b94eb2c5c569c8b5795e377e701c9ac3ad63de890/fastapi-0.141.1-py3-none-any.whl", hash = "sha256:bfb91aa2d334c61cb35ba9a116fc123b3d3df31640b801cf57a7a78ec3f603b3", size = 131954, upload-time = "2026-07-29T17:18:04.364Z" }, ] [[package]] @@ -160,7 +160,7 @@ dev = [ [package.metadata] requires-dist = [ { name = "curl-cffi", specifier = ">=0.15.0" }, - { name = "fastapi", specifier = ">=0.140.13" }, + { name = "fastapi", specifier = ">=0.141.1" }, { name = "gemini-webapi", git = "https://github.com/luuquangvu/Gemini-API.git?rev=enable-guest-mode" }, { name = "httptools", specifier = ">=0.8.0" }, { name = "lmdb", specifier = ">=2.3.0" }, @@ -170,7 +170,7 @@ requires-dist = [ { name = "pyright", marker = "extra == 'dev'" }, { name = "ruff", marker = "extra == 'dev'" }, { name = "ty", marker = "extra == 'dev'" }, - { name = "uvicorn", specifier = ">=0.51.0" }, + { name = "uvicorn", specifier = ">=0.52.0" }, { name = "uvloop", marker = "sys_platform != 'win32'", specifier = ">=0.22.1" }, ] provides-extras = ["dev"] @@ -181,7 +181,7 @@ dev = [{ name = "gemini-fastapi", extras = ["dev"] }] [[package]] name = "gemini-webapi" version = "0.0.post258" -source = { git = "https://github.com/luuquangvu/Gemini-API.git?rev=enable-guest-mode#d9663eab37bfa31e41ef77a432ab046a0652e940" } +source = { git = "https://github.com/luuquangvu/Gemini-API.git?rev=enable-guest-mode#2b988bfb03dc4b54e2d4589f0a6ed8ebaff5a668" } dependencies = [ { name = "curl-cffi" }, { name = "loguru" }, @@ -434,27 +434,27 @@ wheels = [ [[package]] name = "ruff" -version = "0.16.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/4d/94/1e5e4967626faf12fa56999cd6222dff6992ceb086ad7945756baf70c7a7/ruff-0.16.0.tar.gz", hash = "sha256:e460aafd5495ec89efaa6ced2e4a9a581116451e1c88b9d37ef497e0f8e93982", size = 4790557, upload-time = "2026-07-23T19:11:30.981Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/4b/81/1c8818fee7ce1a04cd7d1b3172e0a8f8e4f1dc4feb7fc390e16daa8af323/ruff-0.16.0-py3-none-linux_armv6l.whl", hash = "sha256:e5115729eb08c585e5121978ba5d5b60caeae394ce21b9fb5e6cd33a1c6c9b1e", size = 10754633, upload-time = "2026-07-23T19:10:46.415Z" }, - { url = "https://files.pythonhosted.org/packages/23/df/beaf59c09d68db84304d555f188b276a77132a5d5b0b67a5c762aa143628/ruff-0.16.0-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:3c954b1d580bfa035b41654f7858cc7e71d5fc3ac5b723dd62bd9133830ed522", size = 10969164, upload-time = "2026-07-23T19:10:50.271Z" }, - { url = "https://files.pythonhosted.org/packages/42/ce/741cd197496a1abbf51352710fd15ed995d2a2be87189c1da26a450d6e83/ruff-0.16.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:e01c21d10eb1b29f47b7454e1f4056db9a3f0260c646aa88457c610291db9f81", size = 10488846, upload-time = "2026-07-23T19:10:52.639Z" }, - { url = "https://files.pythonhosted.org/packages/52/2a/a2db8e88cade358f5cdcb05674a917751074109315d014eb6352d9a893f7/ruff-0.16.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6e364e5ed22ed8dc05082fd78e35308618260907ac2d3c1d637b2e682415b6c9", size = 10889729, upload-time = "2026-07-23T19:10:54.89Z" }, - { url = "https://files.pythonhosted.org/packages/42/65/62a771694ebd63029dc953e27dbad40e1588bd4860ff9fe881018fddaa49/ruff-0.16.0-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d327b8fc113a1d4421a04f3839d3752057c8dd1ee320223a6f3f52d04ada462a", size = 10568275, upload-time = "2026-07-23T19:10:56.993Z" }, - { url = "https://files.pythonhosted.org/packages/3f/e2/ced249fe8af5f086c5c58cc21cc3356d50f32f7401c5df87050c999620a7/ruff-0.16.0-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a9b50c55e263103586b3dcf5f73d479eb8cb5fdb6098fec59a62891dab653717", size = 11385112, upload-time = "2026-07-23T19:10:59.615Z" }, - { url = "https://files.pythonhosted.org/packages/87/0b/05154977a8fd69eeb6c103271f55403bfd8711f5c0f8ed07489d95a504e7/ruff-0.16.0-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0ff4a79ce3ec0172f3241943835de1c4cb4e2dcd07f0f8c2d02603dbbbee4b17", size = 12207008, upload-time = "2026-07-23T19:11:02.154Z" }, - { url = "https://files.pythonhosted.org/packages/fb/29/98225831a3a1eab0e02f4acc6ca6559a98611dcc68b6965ff4b7234627c1/ruff-0.16.0-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e95c448fca1fb2a18372a9440926c5a6ee789639bb975c72e7ae6d0b04218ab4", size = 11650842, upload-time = "2026-07-23T19:11:04.557Z" }, - { url = "https://files.pythonhosted.org/packages/91/66/6bd3cf90500653d55dc0ffc8507aa8300bd49d0214b2e8cb4d3fef2943ba/ruff-0.16.0-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4f11a8d11010301d0a398a2fdef67691feca7294da6aef55e2150e8fa2cd520b", size = 11400718, upload-time = "2026-07-23T19:11:09.233Z" }, - { url = "https://files.pythonhosted.org/packages/8e/a2/a54eb4eae05d66364050a5d3b8a9c5ef88196531b3cbe7109d873f87f819/ruff-0.16.0-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:48044c678e9cb8698246c99b14aaccfa6601dea7379eb48a6f8f73f7a6d86cd0", size = 11426177, upload-time = "2026-07-23T19:11:11.994Z" }, - { url = "https://files.pythonhosted.org/packages/1a/be/16e3eea4b2a478a496919f5e36f17c4559e54620bd3bbac5d6affa068006/ruff-0.16.0-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:7aa0959bad8eb8bef50340154fc9b58678dae31fa4293afa38b44b6e552c0213", size = 10856126, upload-time = "2026-07-23T19:11:14.221Z" }, - { url = "https://files.pythonhosted.org/packages/a2/84/252eb8b868a16eec7257c14f504f77537e734b2d69c762e639e588e304a3/ruff-0.16.0-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:28ea2b7df8ebf7f9da6b7d47b230ab48f387c0a29be3b474c4d0740e197bb9af", size = 10571208, upload-time = "2026-07-23T19:11:16.378Z" }, - { url = "https://files.pythonhosted.org/packages/21/09/817a482f542f7570cbb4554b26e896610c7114f539b1d9e2d2145bf6bef6/ruff-0.16.0-py3-none-musllinux_1_2_i686.whl", hash = "sha256:33a3dfac8c35f81498dea9181bccc2f4c4bc8f1521a1dd9406e77643e0f0fb09", size = 11063329, upload-time = "2026-07-23T19:11:19.173Z" }, - { url = "https://files.pythonhosted.org/packages/2e/23/9403c180ca1cb9b1f7335f5c3e5305c09d49ea5b345196682a36028bde4a/ruff-0.16.0-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:a5237a0bda500d30d81b8e07a6973a5cbc772864cbf746ae2f4e8a2e01c9f4ed", size = 11489751, upload-time = "2026-07-23T19:11:21.74Z" }, - { url = "https://files.pythonhosted.org/packages/b2/1d/1b2ef7bcde851c78d7f17f1cca13fd6dc695fc4b3d6197941e72cae5b132/ruff-0.16.0-py3-none-win32.whl", hash = "sha256:7fab76fa065c873f41ff744347c6e77bcc3dfec4bcc754dc26b63d23c0f7f5fb", size = 10785885, upload-time = "2026-07-23T19:11:23.947Z" }, - { url = "https://files.pythonhosted.org/packages/b2/a3/d5e4ef7a56be3f928ffb90b94c25ba7d3cb9c7fe0736aeaaedf361770712/ruff-0.16.0-py3-none-win_amd64.whl", hash = "sha256:429c117f022bf481fabd9d551e7a3952b24c65e6ef44337ea09d90bebef14472", size = 11923141, upload-time = "2026-07-23T19:11:26.409Z" }, - { url = "https://files.pythonhosted.org/packages/cb/9a/8415f2657cbe200f41a4531ccededf135505a92d4a012229121f885b26f9/ruff-0.16.0-py3-none-win_arm64.whl", hash = "sha256:14296fedcd2705c77ab8235439278bbb38f285cf7da5528b00b3e330c3d4872d", size = 11273407, upload-time = "2026-07-23T19:11:28.705Z" }, +version = "0.16.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/70/25/7113f6d5498888c5fb7db34081cba7d5971c4cb1bfb26819966eee68f003/ruff-0.16.1.tar.gz", hash = "sha256:fedad7c801dabd3fb9741d76aca39246e6ddd9ca446a015875207bf19f1e6bc7", size = 4877500, upload-time = "2026-07-30T19:37:01.379Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1b/bd/694da69368e0973de65df2ddc73ab18d43c469d5963d9b150911de6bc513/ruff-0.16.1-py3-none-linux_armv6l.whl", hash = "sha256:58edb313b88f0c5460a26adf5f39a37a3be789494a15e3e411e35fa78b89f9a0", size = 10839126, upload-time = "2026-07-30T19:36:13.697Z" }, + { url = "https://files.pythonhosted.org/packages/3f/f0/b626e5d5bd0dd9576263658ef12885e2288afd1029a48e26ffed65ec1ac1/ruff-0.16.1-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:fde5a99e2f97479af66edd6622c6d5a2a7592c77cf4153d9e4428f5eeb55b60c", size = 11070253, upload-time = "2026-07-30T19:36:17.14Z" }, + { url = "https://files.pythonhosted.org/packages/83/63/f40acfb6b35b88623e71684942b552c3edd96035f5d98f313815f7b277de/ruff-0.16.1-py3-none-macosx_11_0_arm64.whl", hash = "sha256:e0d4c20532fca4f7fa609369161d968dd28f65d83dabbd61d8e9c7edbf7001f6", size = 10561425, upload-time = "2026-07-30T19:36:20.04Z" }, + { url = "https://files.pythonhosted.org/packages/aa/dd/14ec0e9c2b4d315547dd38765004b4863e354e1b52cb308272215d9f6f6d/ruff-0.16.1-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:30affbcedf59ad5703d9c91f82266e02b47739f797e1a7b6e158e5526a6dae38", size = 10948879, upload-time = "2026-07-30T19:36:22.476Z" }, + { url = "https://files.pythonhosted.org/packages/33/e9/9d870cbae575030fdef595f04b4b97573c525b5497cce4f4498cf2f85446/ruff-0.16.1-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:24e9c631573cbca9d20f1283f8f479b2afa4a8503504822bd71a293889f16743", size = 10643691, upload-time = "2026-07-30T19:36:24.914Z" }, + { url = "https://files.pythonhosted.org/packages/c4/09/12743d544e2173f53ecd27217c65f90d2bc0f8424a66a60339e56bbc0457/ruff-0.16.1-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b41bdd48fb420987a9b5212e4957c26ad4abce401fa9ea9d4d85843727945f4f", size = 11435354, upload-time = "2026-07-30T19:36:28.447Z" }, + { url = "https://files.pythonhosted.org/packages/7f/89/a1652b2daee52083c9554a6333b678a8b01d0400f976827bb87857f9449a/ruff-0.16.1-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b0d1e1393b7648079e13669de1c1f4fde06d4583e84d8fd5c1551e0a77a2aa75", size = 12259033, upload-time = "2026-07-30T19:36:31.326Z" }, + { url = "https://files.pythonhosted.org/packages/16/96/ecdcb8c54ee7b123b487f807eb014e6e019155a0b81dfb669acd52f28ce3/ruff-0.16.1-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:07bf434b1c95f4e093be4532068ef4fcf00924eb2ade8796075980902d6fd54a", size = 11667981, upload-time = "2026-07-30T19:36:34.394Z" }, + { url = "https://files.pythonhosted.org/packages/cd/90/c52e12e0d862e9572f2a33aa227409143520abe53111e9a6babbac7b4af8/ruff-0.16.1-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:39897739f112253ee4fdd2e8aa9a4f9ded99fb2be367d5f31dfa4ded6025584c", size = 11468183, upload-time = "2026-07-30T19:36:37.339Z" }, + { url = "https://files.pythonhosted.org/packages/2c/6b/4ffb7ad1d83eb16cf8cbb3c8815d3f11c88460fd162d4b372a2059be1c2a/ruff-0.16.1-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:82ae3c0c0d74daf17b968a10b7b3bb3ef297ab7de0c1f749646b25e690ccb150", size = 11470071, upload-time = "2026-07-30T19:36:39.91Z" }, + { url = "https://files.pythonhosted.org/packages/9c/72/32ae7db4c0b5e32ab611787caa19d1546800676d79f7483b7100a3561bf4/ruff-0.16.1-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:4d5f2ed10f8242d83fc08d521301089364e3375375705356f20c0e31606ef3ef", size = 10919503, upload-time = "2026-07-30T19:36:42.65Z" }, + { url = "https://files.pythonhosted.org/packages/f7/ca/3d901ba6ad6fc38da39c3448fc6c59ac945679293a17c3ceb6d6c1cba13e/ruff-0.16.1-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:a4665b309891f83f3e3c25447935f1213e9abbd4b5640af7a1f2def9f8d413c1", size = 10649861, upload-time = "2026-07-30T19:36:45.18Z" }, + { url = "https://files.pythonhosted.org/packages/92/79/894ef1ced26552d5f8c9cf6d85b0687840e1128c55aeab7b9c2d54a0d880/ruff-0.16.1-py3-none-musllinux_1_2_i686.whl", hash = "sha256:26e9ca5c9bc3971f20d3cf18a957f52ffd6a5f6564ff15c4912a144dcac22494", size = 11148137, upload-time = "2026-07-30T19:36:47.936Z" }, + { url = "https://files.pythonhosted.org/packages/2d/69/3609a09fa1cb46cc28b762363e440a354204e5dff01bd0c8d7437874d6b9/ruff-0.16.1-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:67e1e1e3fa4f0c82f0e36d4cd61e661f6e7a6196cb1aa92fe0828fa7b8f257cd", size = 11559211, upload-time = "2026-07-30T19:36:50.448Z" }, + { url = "https://files.pythonhosted.org/packages/fc/8a/fb22af2fd78a736e241fabf67e30ce1799a64244026377a49e133af90762/ruff-0.16.1-py3-none-win32.whl", hash = "sha256:d31765e131295b8445caf301e3e8a85b34d1b9b211b4109b7ba457888b051806", size = 10838258, upload-time = "2026-07-30T19:36:53.298Z" }, + { url = "https://files.pythonhosted.org/packages/d4/35/e57fd9fb5d423961df087a00b12d42c0a830288dc2f3b45ecca299158b4f/ruff-0.16.1-py3-none-win_amd64.whl", hash = "sha256:09b05e8b90c2cb06ad63464350e7a45e8e44a2dfe52072ebfba6666ca8d3f596", size = 11961111, upload-time = "2026-07-30T19:36:56.107Z" }, + { url = "https://files.pythonhosted.org/packages/cb/46/240ea004bf6dc4feb40e9832f2205a476a47dd5b8a3f8211a5fc5f95e20e/ruff-0.16.1-py3-none-win_arm64.whl", hash = "sha256:dbaadaac38c70239f056d306b7476f246b0bf000fa6b3876402acbf5b227eaf8", size = 11309414, upload-time = "2026-07-30T19:36:58.79Z" }, ] [[package]] @@ -471,27 +471,27 @@ wheels = [ [[package]] name = "ty" -version = "0.0.64" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/8e/aa/14c9965d3b173105692473897cc89c34cd91241368b2044e43167e1c17ff/ty-0.0.64.tar.gz", hash = "sha256:d12ddbb05f15158bb518af619378b385486450def95fb06f8ab98037febe9f2c", size = 6350966, upload-time = "2026-07-27T18:32:45.403Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/ae/4c/c54937e4ff3fa7b34a99ea3387ec766bf0ad98dc8df8d792e89b388e658e/ty-0.0.64-py3-none-linux_armv6l.whl", hash = "sha256:3830a6675ab43635ced1c4c557f380ac4a49e9414e03975e4e4e8db644c64944", size = 12118357, upload-time = "2026-07-27T18:32:08.702Z" }, - { url = "https://files.pythonhosted.org/packages/ce/aa/a839ee2bc78e943d079e6abe199a97b4eeffb7e5c9a57326d69de452186a/ty-0.0.64-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:3ff07d7bc32a2135f58afe57393789a32ea2fed1a66216129a8e535e76043903", size = 11790882, upload-time = "2026-07-27T18:32:10.997Z" }, - { url = "https://files.pythonhosted.org/packages/4c/de/19f14357888a7198438926303753cf749428e3d62e8980ff1e9a72a78402/ty-0.0.64-py3-none-macosx_11_0_arm64.whl", hash = "sha256:4f6d1c7f897cca05d12bacbf1435150d5ffa496099515aa6ed303c8b29e1d0bb", size = 11317394, upload-time = "2026-07-27T18:32:13.162Z" }, - { url = "https://files.pythonhosted.org/packages/08/2f/f54462300535ab99b551eda733177be2eef5dbc2997d3fdb357c4ddd760a/ty-0.0.64-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:138d6c37ad4bf8583aa7a9b29d90954151d7856f9910a03eae3eb34b34c57215", size = 11863042, upload-time = "2026-07-27T18:32:15.307Z" }, - { url = "https://files.pythonhosted.org/packages/5e/95/dbecf745520ebe8bd7b02fc55eee6441c9be312ebf6addce605eb52740dd/ty-0.0.64-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:1ed9719d1b7b66fb8efe073d860208a44c40af1f6cd5c2364aa9b323a1e579b4", size = 11910730, upload-time = "2026-07-27T18:32:17.467Z" }, - { url = "https://files.pythonhosted.org/packages/3b/26/12cfd40028e51ceed7b3cb645281c61c02eb64ff9fb0c09231d65c30ff25/ty-0.0.64-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d68b23e5169e2137b5f1de7169ab0cebecab6c8eda374c34c1f6394308f58242", size = 12631936, upload-time = "2026-07-27T18:32:19.533Z" }, - { url = "https://files.pythonhosted.org/packages/a0/d0/65ffc2b0a686347193c6f98e9421a7fc2a96fc3cd0b1001cf7cf284baff9/ty-0.0.64-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f41cb07d89d32626fcaf3ed4d262778fcb28b2628b6ed1e172cd7b18820668d8", size = 13171049, upload-time = "2026-07-27T18:32:22.026Z" }, - { url = "https://files.pythonhosted.org/packages/f6/a4/975a5961842dcd6fa60f0770a102c0bba7509da909ab652919bfcdcd4fc7/ty-0.0.64-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5f24e1504ab9e212f92356b82fe088fdeb3a39f9a2f4ff25e505d2e1d0db9056", size = 12826438, upload-time = "2026-07-27T18:32:24.178Z" }, - { url = "https://files.pythonhosted.org/packages/af/ef/dfb9b7f9bcc032d3b540b0d1f55f532a336e2fb41b1bd539c05ae81a151a/ty-0.0.64-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:86db830cb914bb33bb8247b66ccea58de4496c2391bd42658aba744b439f3290", size = 12440880, upload-time = "2026-07-27T18:32:26.341Z" }, - { url = "https://files.pythonhosted.org/packages/ee/d0/bedac20505e8a8f5501ad73d7d15d8e421a563fef59993909a23036929a4/ty-0.0.64-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:652ee3d6d03bea76cd2fe8949c78bb5970394ebd7c5fd270e9518c0dee1b931d", size = 12782439, upload-time = "2026-07-27T18:32:28.642Z" }, - { url = "https://files.pythonhosted.org/packages/79/d5/795733f13ceff1378f08b3de0c49d0f518df220ed856b0dfac869f3b7c81/ty-0.0.64-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:4838768295774a86e95f9ec5633e739d016adbbb69dcbdc62f3549578a11f624", size = 11814821, upload-time = "2026-07-27T18:32:30.632Z" }, - { url = "https://files.pythonhosted.org/packages/52/3e/9d99cd1e1831003434f508ed9f258a56543194afc3bbe051eba2545fa676/ty-0.0.64-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:5a3d700669868599edf39ce5125196682f15a8880cc8c669bc2a83b99984d99b", size = 11928678, upload-time = "2026-07-27T18:32:32.888Z" }, - { url = "https://files.pythonhosted.org/packages/b8/3d/448f49a3503fb119a34348a5714bb92001f252fadbbb12d645f2e744b557/ty-0.0.64-py3-none-musllinux_1_2_i686.whl", hash = "sha256:b161f0a82a8e2f2432db3bf7702b4d3924fa9486ba0014f6710a160fc157df0d", size = 12202249, upload-time = "2026-07-27T18:32:34.905Z" }, - { url = "https://files.pythonhosted.org/packages/dd/9b/75768e562cec990d189dc05807ae72890b20ffbd1e1f43597bb98db63c60/ty-0.0.64-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:39b9dd42908df47c2dc57dda87e656fab97097ffd2618474bbdea986af0d6a9d", size = 12548817, upload-time = "2026-07-27T18:32:36.995Z" }, - { url = "https://files.pythonhosted.org/packages/34/89/44cc276ea6ca0245495014758ffeadde1798fb0b5840c9cf36d8d2ed3250/ty-0.0.64-py3-none-win32.whl", hash = "sha256:d0676ab0e0935795e5843baa28dd5e366dc343d7c0945a996df9eab8e0644885", size = 11545474, upload-time = "2026-07-27T18:32:39.389Z" }, - { url = "https://files.pythonhosted.org/packages/01/7e/d1c8a871a38d17c8f168b9a6975f6247f7660f8334e517656a5e4b4a4858/ty-0.0.64-py3-none-win_amd64.whl", hash = "sha256:dcb9bd31f54097e362b776c26ab4564d4564cdd1355cb883481167a26c03cc3f", size = 12542987, upload-time = "2026-07-27T18:32:41.412Z" }, - { url = "https://files.pythonhosted.org/packages/35/4d/6d18640d0204cacd69abbaca95ad6a34c6d7e9169e9051d0117b17b827ec/ty-0.0.64-py3-none-win_arm64.whl", hash = "sha256:82cc34c1ad9a8feb6059aef193bebcecc656e548f6fab3d518bcd8b57d198d39", size = 11899263, upload-time = "2026-07-27T18:32:43.366Z" }, +version = "0.0.65" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cf561927e8e9ab5c1892a833b664aa9cd6f051a75f6280c66d8047246bda/ty-0.0.65.tar.gz", hash = "sha256:b7134bffcc00b715fa8291e84d845782ced810a998dc1f7f11d71c85c4046325", size = 6460098, upload-time = "2026-07-29T18:31:03.27Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7b/4e/71e2d325d2b53a1afad81624ad076b2ede413213fc4a18cb05b78c568571/ty-0.0.65-py3-none-linux_armv6l.whl", hash = "sha256:dc556c9f05408bef4c4ef02b2cc382e4e5f797b4b20d64410289848f0d76705f", size = 12298466, upload-time = "2026-07-29T18:30:12.744Z" }, + { url = "https://files.pythonhosted.org/packages/57/77/fec8f29647c55794efa430a7f365e44f5ce7ffb6459d9445a87fac569bec/ty-0.0.65-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:29d2e0d34cc0a28a17ef0cf81135c5ebabc3562131f9079138ba5e7bae0f56bd", size = 11942421, upload-time = "2026-07-29T18:30:16.076Z" }, + { url = "https://files.pythonhosted.org/packages/13/09/7f3766aef9dc627e2698cf4e3e59cf53389dcae3812040d33c1aa931230f/ty-0.0.65-py3-none-macosx_11_0_arm64.whl", hash = "sha256:685f49a9312bbf69d5b65bbb66384fed1f927403ea030c217b9289092d7e46c4", size = 11451922, upload-time = "2026-07-29T18:30:19.155Z" }, + { url = "https://files.pythonhosted.org/packages/cb/7b/1a77cd50e0befb50f55b8bf9bd3ed3eddf184bf28c61b56727039e0774fc/ty-0.0.65-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6f564b5ebe78e2f3a8e7b8eacb1292eb88b7c0f3c8630671cfca31abc0709cd9", size = 11994999, upload-time = "2026-07-29T18:30:22.315Z" }, + { url = "https://files.pythonhosted.org/packages/63/7b/feda16f3a4a0a99be27431e0c9598eeeec0db1eb2fec9a15976698209418/ty-0.0.65-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c983e156fe9e113fb56389e13d327b6b8549fe866de9b269684723a88e9b732d", size = 12090662, upload-time = "2026-07-29T18:30:24.93Z" }, + { url = "https://files.pythonhosted.org/packages/ed/3e/3f69bf9c9307dbdc0771719f65ce5b556e7bdeeaccbdd599d4f57866d801/ty-0.0.65-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e3663b7396e8b1a9954e20e732de7ccb0192bf4118473069b4945920d6923921", size = 12822094, upload-time = "2026-07-29T18:30:28.012Z" }, + { url = "https://files.pythonhosted.org/packages/90/38/8fa791b3bb503ee2b46ad81690cd1bdd54519582df6d805cee57fe143e85/ty-0.0.65-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:306ed01f29d6e108e98feb233dbbf5878a027603b71bd3743b343977933a9f16", size = 13357833, upload-time = "2026-07-29T18:30:31.122Z" }, + { url = "https://files.pythonhosted.org/packages/c1/73/4dda396a201e1dd0ed3594a9b48e559cb41c4bc048c6cd4c4d1b39eb4313/ty-0.0.65-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:28bcfc8898c94f079a9100e684bcf312b6a64ad3a7d4ebb35a4591546030a2cd", size = 12977303, upload-time = "2026-07-29T18:30:33.944Z" }, + { url = "https://files.pythonhosted.org/packages/a5/26/c250c2c569adc53a8591716641388397bcb2a442e4a30b952ae81b50c0e0/ty-0.0.65-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5a75bd0c245c38802a8f488378e74f92feb7dd33db7d63fbdd6fdf82791ba730", size = 12579338, upload-time = "2026-07-29T18:30:37.199Z" }, + { url = "https://files.pythonhosted.org/packages/d3/94/4a5647d44753ca218fc930d7e4d9bf468d0ed4a0ad4b3d57588bc1bbacf7/ty-0.0.65-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:9e5e1bdea9662d2b5312b4e99f319f4e6e2ea427511b5fbc546141b79ec53f76", size = 12957731, upload-time = "2026-07-29T18:30:39.937Z" }, + { url = "https://files.pythonhosted.org/packages/36/b6/1e22fa11a1e0dfb20b1c7f3cbfd8170273aada2a82f9ecd3055275370c44/ty-0.0.65-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:03a88493d4842889f65280ae241e06b399d57eb3c63571054cad21a4c33b3b69", size = 11938625, upload-time = "2026-07-29T18:30:42.603Z" }, + { url = "https://files.pythonhosted.org/packages/5c/0a/fe5f22ef62b193201bc5566762e22049762cd485bfafb5095a7050760054/ty-0.0.65-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:600b8bf6f4940cf7ffb2f43d3716faaf38dcb97cd8617c55771451bc0276408f", size = 12105592, upload-time = "2026-07-29T18:30:45.419Z" }, + { url = "https://files.pythonhosted.org/packages/76/fd/922b3a6e9d697452cdbb4b7e3f636868add5ec652154518a736e4364f3b7/ty-0.0.65-py3-none-musllinux_1_2_i686.whl", hash = "sha256:0c28007bc79d648c1ddaf1e65885d07baec48eb87240da442f608e4107c1b7d8", size = 12387335, upload-time = "2026-07-29T18:30:48.405Z" }, + { url = "https://files.pythonhosted.org/packages/77/22/a1a08ebc84c083db2fb55e3b5cd186db0c067692f4921146f601360231e2/ty-0.0.65-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:c852da96091ad22361e6586b7c7ba98e1334dcd4d8ffb67e47f4fb673de33f77", size = 12682710, upload-time = "2026-07-29T18:30:51.364Z" }, + { url = "https://files.pythonhosted.org/packages/81/14/eaaa410a25bbdea19722109b5422380a0e211b3afcf3071d15953ddbd5db/ty-0.0.65-py3-none-win32.whl", hash = "sha256:cf529d538f1403b14b0511e6ec3cdb95d3d974adabf24cc76cedc533368c3edc", size = 11692341, upload-time = "2026-07-29T18:30:54.35Z" }, + { url = "https://files.pythonhosted.org/packages/bc/0f/6d48f206dce9d7e53fe3b5ea0f0ab5800dd9d2365b2b48f736783436c43f/ty-0.0.65-py3-none-win_amd64.whl", hash = "sha256:234a321e33c7cbbfbd67bfa0b01b685dd9c21f1841781a21e5ca1fa0b25f1d5d", size = 12729355, upload-time = "2026-07-29T18:30:57.275Z" }, + { url = "https://files.pythonhosted.org/packages/96/aa/7446f7725e303cf78e058c893af1f0552b9451895454908706f4c6c3494b/ty-0.0.65-py3-none-win_arm64.whl", hash = "sha256:b9424be1ec56d93ff18609fb1c0a0a2283fe1282cd6d1c7604f97d73b94d61f2", size = 12051375, upload-time = "2026-07-29T18:31:00.579Z" }, ] [[package]] @@ -517,15 +517,15 @@ wheels = [ [[package]] name = "uvicorn" -version = "0.51.0" +version = "0.52.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "click" }, { name = "h11" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/a2/65/b7c6c443ccc58678c91e1e973bbe2a878591538655d6e1d47f24ba1c51f3/uvicorn-0.51.0.tar.gz", hash = "sha256:f6f4b69b657c312f516dd2d268ab9ae6f254b11e4bac504f37b2ab58b24dd0b0", size = 94412, upload-time = "2026-07-08T10:59:05.962Z" } +sdist = { url = "https://files.pythonhosted.org/packages/05/c8/2d307868453a4bca6e64fa3581d122ae0748a0869c53f159339def179c7c/uvicorn-0.52.0.tar.gz", hash = "sha256:ca8876ad6c1983f394157c168b39d52f6dd56dabf5602fa0982751cffc2293ae", size = 97504, upload-time = "2026-07-29T08:45:34.065Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/45/ec/dbb7e5a6b91f86bfb9eb7d2988a2730907b6a729875b949c7f022e8b88fa/uvicorn-0.51.0-py3-none-any.whl", hash = "sha256:5d38af6cd620f2ae3849fb44fd4879e0890aa1febe8d47eb355fb45d93fe6a5b", size = 73219, upload-time = "2026-07-08T10:59:04.44Z" }, + { url = "https://files.pythonhosted.org/packages/39/e6/b5c0630ace9757232aec07112be8146b812787db52141ff9d50674aa7634/uvicorn-0.52.0-py3-none-any.whl", hash = "sha256:3d887809810b89ed33501bcf0a9aba469b06ecd608158efce04bd6b48d8c9b08", size = 79058, upload-time = "2026-07-29T08:45:32.492Z" }, ] [[package]] From 0a88e6730b79b8d3c9f53e361dd0e956c2a68d3d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C6=B0u=20Quang=20V=C5=A9?= Date: Sat, 1 Aug 2026 00:12:16 +0700 Subject: [PATCH 274/291] Fix the incorrect implementation of the Responses API --- app/models/models.py | 36 ++++++++++++++- app/server/chat.py | 108 ++++++++++++++++++++++++++++++------------- 2 files changed, 111 insertions(+), 33 deletions(-) diff --git a/app/models/models.py b/app/models/models.py index 0b8586f..e0ddaa7 100644 --- a/app/models/models.py +++ b/app/models/models.py @@ -85,6 +85,21 @@ class ChatCompletionFunctionTool(BaseModel): type: Literal["function"] function: FunctionDefinition + @model_validator(mode="before") + @classmethod + def _nest_flat_function(cls, data: Any) -> Any: + if isinstance(data, dict) and "function" not in data and "name" in data: + return { + "type": data.get("type", "function"), + "function": { + "name": data.get("name"), + "description": data.get("description"), + "parameters": data.get("parameters"), + "strict": data.get("strict"), + }, + } + return data + class ChatCompletionNamedToolChoiceFunction(BaseModel): name: str @@ -231,6 +246,19 @@ class FunctionTool(BaseModel): parameters: dict[str, Any] | None = Field(default=None) strict: bool | None = Field(default=None) + @model_validator(mode="before") + @classmethod + def _flatten_nested_function(cls, data: Any) -> Any: + if isinstance(data, dict) and "function" in data and isinstance(data["function"], dict): + fn = data["function"] + res = dict(data) + res.setdefault("name", fn.get("name")) + res.setdefault("description", fn.get("description")) + res.setdefault("parameters", fn.get("parameters")) + res.setdefault("strict", fn.get("strict")) + return res + return data + class ImageGeneration(BaseModel): """Image-generation built-in tool for the Responses API.""" @@ -394,7 +422,9 @@ class ResponseCreateRequest(BaseModel): tool_choice: ( Literal["none", "auto", "required"] | ToolChoiceFunction | ToolChoiceTypes | None ) = Field(default=None) - tools: list[FunctionTool | ImageGeneration] | None = Field(default=None) + tools: list[FunctionTool | ChatCompletionFunctionTool | ImageGeneration] | None = Field( + default=None + ) store: bool | None = Field(default=None) prompt_cache_key: str | None = Field(default=None) response_format: dict[str, Any] | None = Field(default=None) @@ -422,7 +452,9 @@ class ResponseCreateResponse(BaseModel): tool_choice: ( Literal["none", "auto", "required"] | ToolChoiceFunction | ToolChoiceTypes | None ) = Field(default=None) - tools: list[FunctionTool | ImageGeneration] = Field(default_factory=list) + tools: list[FunctionTool | ChatCompletionFunctionTool | ImageGeneration] = Field( + default_factory=list + ) usage: ResponseUsage | None = Field(default=None) error: dict[str, Any] | None = Field(default=None) metadata: dict[str, Any] = Field(default_factory=dict) diff --git a/app/server/chat.py b/app/server/chat.py index 7656487..1c276f2 100644 --- a/app/server/chat.py +++ b/app/server/chat.py @@ -4,7 +4,7 @@ import io import reprlib import uuid -from collections.abc import AsyncGenerator +from collections.abc import AsyncGenerator, Sequence from dataclasses import dataclass from datetime import UTC, datetime from pathlib import Path @@ -26,7 +26,6 @@ AppToolCall, AppToolCallFunction, ChatCompletionChoice, - ChatCompletionFunctionTool, ChatCompletionMessage, ChatCompletionMessageToolCall, ChatCompletionNamedToolChoice, @@ -396,11 +395,23 @@ def _process_llm_output( return thoughts, visible_output, storage_output, tool_calls +def _normalize_app_message_role(role_name: str) -> Literal["system", "user", "assistant", "tool"]: + """Normalize and validate input role string to a valid AppMessage role.""" + mapped = {"developer": "system", "function": "tool"}.get(role_name, role_name) + if mapped == "user": + return "user" + if mapped == "assistant": + return "assistant" + if mapped == "tool": + return "tool" + return "system" + + def _convert_to_app_messages(messages: list[ChatCompletionMessage]) -> list[AppMessage]: - """Convert ChatCompletionMessage (OpenAI format) to generic internal AppMessage.""" - app_messages = [] + """Convert OpenAI ChatCompletionMessage list into AppMessage format.""" + app_messages: list[AppMessage] = [] for msg in messages: - app_content = None + app_content: str | list[AppContentItem] | None = None if isinstance(msg.content, str): app_content = msg.content elif isinstance(msg.content, list): @@ -411,11 +422,7 @@ def _convert_to_app_messages(messages: list[ChatCompletionMessage]) -> list[AppM elif item.type == "image_url": media_dict = getattr(item, "image_url", None) url = media_dict.get("url") if media_dict else None - if url and url.startswith("data:"): - # image_url can be either a regular url or base64 data url - app_content.append(AppContentItem(type="image_url", url=url)) - else: - app_content.append(AppContentItem(type="image_url", url=url)) + app_content.append(AppContentItem(type="image_url", url=url)) elif item.type == "file": file_dict = getattr(item, "file", None) filename = file_dict.get("filename") if file_dict else None @@ -451,9 +458,7 @@ def _convert_to_app_messages(messages: list[ChatCompletionMessage]) -> list[AppM for tc in msg.tool_calls ] - role = {"developer": "system", "function": "tool"}.get(msg.role, msg.role) - if role not in ("system", "user", "assistant", "tool"): - role = "system" + role = _normalize_app_message_role(msg.role) app_messages.append( AppMessage( @@ -552,8 +557,55 @@ def _build_structured_requirement( ) +def _extract_tool_info(tool: Any) -> tuple[str, str, dict[str, Any] | None]: + """Extract (name, description, parameters) from any tool representation.""" + if hasattr(tool, "function") and tool.function is not None: + fn = tool.function + if isinstance(fn, dict): + name = fn.get("name", "") + description = fn.get("description") or "No description provided." + parameters = fn.get("parameters") + else: + name = getattr(fn, "name", "") + description = getattr(fn, "description", None) or "No description provided." + parameters = getattr(fn, "parameters", None) + return name, description, parameters + + if isinstance(tool, dict): + if "function" in tool and isinstance(tool["function"], dict): + fn = tool["function"] + return ( + fn.get("name", ""), + fn.get("description") or "No description provided.", + fn.get("parameters"), + ) + return ( + tool.get("name", ""), + tool.get("description") or "No description provided.", + tool.get("parameters"), + ) + + name = getattr(tool, "name", "") + description = getattr(tool, "description", None) or "No description provided." + parameters = getattr(tool, "parameters", None) + return name, description, parameters + + +def _extract_named_tool_choice(tool_choice: Any) -> str | None: + """Extract target function name from any named tool choice representation.""" + if isinstance(tool_choice, ChatCompletionNamedToolChoice): + return tool_choice.function.name + if isinstance(tool_choice, ToolChoiceFunction): + return tool_choice.name + if isinstance(tool_choice, dict): + if "function" in tool_choice and isinstance(tool_choice["function"], dict): + return tool_choice["function"].get("name") + return tool_choice.get("name") + return None + + def _build_tool_prompt( - tools: list[ChatCompletionFunctionTool], + tools: Sequence[Any], tool_choice: ( Literal["none", "auto", "required"] | ChatCompletionNamedToolChoice @@ -571,13 +623,12 @@ def _build_tool_prompt( ] for tool in tools: - function = tool.function - description = function.description or "No description provided." - lines.append(f"Tool `{function.name}`: {description}") - if function.parameters: - schema_text = orjson.dumps(function.parameters, option=orjson.OPT_SORT_KEYS).decode( - "utf-8" - ) + name, description, parameters = _extract_tool_info(tool) + if not name: + continue + lines.append(f"Tool `{name}`: {description}") + if parameters: + schema_text = orjson.dumps(parameters, option=orjson.OPT_SORT_KEYS).decode("utf-8") lines.extend(("Arguments JSON schema:", schema_text)) else: lines.append("Arguments JSON schema: {}") @@ -590,10 +641,9 @@ def _build_tool_prompt( lines.append( "You must call at least one tool before responding to the user. Do not provide a final user-facing answer until a tool call has been issued." ) - elif isinstance(tool_choice, ChatCompletionNamedToolChoice): - target = tool_choice.function.name + elif (target_name := _extract_named_tool_choice(tool_choice)) is not None: lines.append( - f"You are required to call the tool named `{target}`. Do not call any other tool." + f"You are required to call the tool named `{target_name}`. Do not call any other tool." ) lines.append(TOOL_WRAP_HINT) @@ -656,7 +706,7 @@ def _append_tool_hint_to_last_user_message(messages: list[AppMessage]) -> None: def _prepare_messages_for_model( source_messages: list[AppMessage], - tools: list[ChatCompletionFunctionTool] | None, + tools: Sequence[Any] | None, tool_choice: Literal["none", "auto", "required"] | ChatCompletionNamedToolChoice | ToolChoiceFunction @@ -885,11 +935,7 @@ def _convert_instructions_to_app_messages( if instruction.type and instruction.type != "message": continue - raw_role = instruction.role - normalized_role = {"developer": "system", "function": "tool"}.get(raw_role, raw_role) - if normalized_role not in ("system", "user", "assistant", "tool"): - normalized_role = "system" - role = normalized_role + role = _normalize_app_message_role(instruction.role) content = instruction.content if isinstance(content, str): @@ -2556,7 +2602,7 @@ async def create_response( if session: msgs = _prepare_messages_for_model( remain, - request.tools, # type: ignore + request.tools, request.tool_choice, None, False, From 926befc1aef3e96f6a35eea576fc1fa7b1bb02eb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C6=B0u=20Quang=20V=C5=A9?= Date: Sat, 1 Aug 2026 08:42:36 +0700 Subject: [PATCH 275/291] Fix strict schema validator rejecting null fields --- app/models/models.py | 21 +- app/server/chat.py | 576 +++++++++++-------------------------------- app/utils/helper.py | 354 +++++++++++++++++++++++++- 3 files changed, 509 insertions(+), 442 deletions(-) diff --git a/app/models/models.py b/app/models/models.py index e0ddaa7..9b7b2c8 100644 --- a/app/models/models.py +++ b/app/models/models.py @@ -1,11 +1,22 @@ from __future__ import annotations from collections.abc import Mapping +from dataclasses import dataclass from typing import Any, Literal from pydantic import BaseModel, Field, model_validator +@dataclass +class StructuredOutputRequirement: + """Represents a structured response request from the client.""" + + schema_name: str + schema: dict[str, Any] + instruction: str + raw_format: dict[str, Any] + + class FunctionCall(BaseModel): """Executed function call payload.""" @@ -450,11 +461,13 @@ class ResponseCreateResponse(BaseModel): Field(default="completed") ) tool_choice: ( - Literal["none", "auto", "required"] | ToolChoiceFunction | ToolChoiceTypes | None + Literal["none", "auto", "required"] + | ToolChoiceFunction + | ToolChoiceTypes + | dict[str, Any] + | None ) = Field(default=None) - tools: list[FunctionTool | ChatCompletionFunctionTool | ImageGeneration] = Field( - default_factory=list - ) + tools: list[dict[str, Any]] = Field(default_factory=list) usage: ResponseUsage | None = Field(default=None) error: dict[str, Any] | None = Field(default=None) metadata: dict[str, Any] = Field(default_factory=dict) diff --git a/app/server/chat.py b/app/server/chat.py index 1c276f2..9922823 100644 --- a/app/server/chat.py +++ b/app/server/chat.py @@ -5,7 +5,6 @@ import reprlib import uuid from collections.abc import AsyncGenerator, Sequence -from dataclasses import dataclass from datetime import UTC, datetime from pathlib import Path from typing import Any, Literal, cast @@ -50,6 +49,7 @@ ResponseReasoningItem, ResponseTextConfig, ResponseUsage, + StructuredOutputRequirement, SummaryTextContent, ToolChoiceFunction, ToolChoiceTypes, @@ -66,16 +66,20 @@ STREAM_MASTER_RE, STREAM_TAIL_RE, STRUCTURED_JSON_WRAP_HINT, - TOOL_HINT_STRIPPED, - TOOL_WRAP_HINT, + append_tool_hint_to_last_user_message, + build_image_generation_instruction, + build_tool_prompt, + calculate_usage, + convert_to_app_messages, detect_image_extension, - estimate_tokens, + dump_model, extract_image_dimensions, - extract_tool_calls, + normalize_app_message_role, normalize_llm_text, - strip_markdown_fence, + process_llm_output, + serialize_tool_choice_for_response, + serialize_tools_for_response, strip_system_hints, - text_from_message, ) MAX_CHARS_PER_REQUEST = int(g_config.gemini.max_chars_per_request * 0.9) @@ -85,16 +89,6 @@ _AVAILABLE_MODELS_CACHE_LOCK = asyncio.Lock() -@dataclass -class StructuredOutputRequirement: - """Represents a structured response request from the client.""" - - schema_name: str - schema: dict[str, Any] - instruction: str - raw_format: dict[str, Any] - - type ProcessedImageData = tuple[str, int | None, int | None, str, str] type ProcessedMediaData = dict[str, tuple[str, str]] type ProcessedImageResult = tuple[Literal["image"], Image, ProcessedImageData] @@ -197,37 +191,6 @@ async def _media_to_local_file( return results -def _calculate_usage( - messages: list[AppMessage], - assistant_text: str | None, - tool_calls: list[AppToolCall] | None, - thoughts: str | None = None, -) -> tuple[int, int, int, int]: - """Calculate prompt, completion, total and reasoning tokens consistently.""" - prompt_tokens = sum(estimate_tokens(text_from_message(msg)) for msg in messages) - tool_args_text = "" - if tool_calls: - for call in tool_calls: - tool_args_text += call.function.arguments or "" - - completion_basis = assistant_text or "" - if tool_args_text: - completion_basis = ( - f"{completion_basis}\n{tool_args_text}" if completion_basis else tool_args_text - ) - - completion_tokens = estimate_tokens(completion_basis) - reasoning_tokens = estimate_tokens(thoughts) if thoughts else 0 - total_completion_tokens = completion_tokens + reasoning_tokens - - return ( - prompt_tokens, - total_completion_tokens, - prompt_tokens + total_completion_tokens, - reasoning_tokens, - ) - - def _create_responses_standard_payload( response_id: str, created_time: int, @@ -298,8 +261,8 @@ def _create_responses_standard_payload( status="completed", usage=usage, metadata=request.metadata or {}, - tools=request.tools or [], - tool_choice=request.tool_choice if request.tool_choice is not None else "auto", + tools=serialize_tools_for_response(request.tools), + tool_choice=serialize_tool_choice_for_response(request.tool_choice), text=text_config, ) @@ -349,130 +312,6 @@ def _create_chat_completion_standard_payload( ) -def _canonicalize_structured_output( - visible_output: str, structured_requirement: StructuredOutputRequirement -) -> str | None: - """Parse raw or fenced structured JSON and return its canonical JSON representation.""" - candidate = strip_markdown_fence(visible_output) - try: - structured_payload = orjson.loads(candidate) - except orjson.JSONDecodeError: - logger.warning( - f"Failed to decode JSON for structured response (schema={structured_requirement.schema_name})." - ) - return None - - canonical_output = orjson.dumps(structured_payload).decode("utf-8") - logger.debug(f"Structured response fulfilled (schema={structured_requirement.schema_name}).") - return canonical_output - - -def _process_llm_output( - thoughts: str | None, - raw_text: str, - structured_requirement: StructuredOutputRequirement | None, -) -> tuple[str | None, str, str, list[AppToolCall]]: - """ - Post-process Gemini output to extract tool calls, unwrap structured JSON fences, and prepare clean text for display and storage. - Returns: (thoughts, visible_text, storage_output, tool_calls) - """ - if thoughts: - thoughts = thoughts.strip() - - visible_output, tool_calls = extract_tool_calls(raw_text) - if tool_calls: - logger.debug(f"Detected {len(tool_calls)} tool call(s) in model output.") - - visible_output = visible_output.strip() - storage_output = visible_output - - if structured_requirement and visible_output: - canonical_output = _canonicalize_structured_output(visible_output, structured_requirement) - if canonical_output: - visible_output = canonical_output - storage_output = canonical_output - - return thoughts, visible_output, storage_output, tool_calls - - -def _normalize_app_message_role(role_name: str) -> Literal["system", "user", "assistant", "tool"]: - """Normalize and validate input role string to a valid AppMessage role.""" - mapped = {"developer": "system", "function": "tool"}.get(role_name, role_name) - if mapped == "user": - return "user" - if mapped == "assistant": - return "assistant" - if mapped == "tool": - return "tool" - return "system" - - -def _convert_to_app_messages(messages: list[ChatCompletionMessage]) -> list[AppMessage]: - """Convert OpenAI ChatCompletionMessage list into AppMessage format.""" - app_messages: list[AppMessage] = [] - for msg in messages: - app_content: str | list[AppContentItem] | None = None - if isinstance(msg.content, str): - app_content = msg.content - elif isinstance(msg.content, list): - app_content = [] - for item in msg.content: - if item.type == "text": - app_content.append(AppContentItem(type="text", text=item.text)) - elif item.type == "image_url": - media_dict = getattr(item, "image_url", None) - url = media_dict.get("url") if media_dict else None - app_content.append(AppContentItem(type="image_url", url=url)) - elif item.type == "file": - file_dict = getattr(item, "file", None) - filename = file_dict.get("filename") if file_dict else None - file_data = file_dict.get("file_data") if file_dict else None - app_content.append( - AppContentItem(type="file", filename=filename, file_data=file_data) - ) - elif item.type == "input_audio": - audio_dict = getattr(item, "input_audio", None) - audio_data = audio_dict.get("data") if audio_dict else None - app_content.append( - AppContentItem( - type="input_audio", - file_data=audio_data, - raw_data=audio_dict, - ) - ) - elif item.type in ("refusal", "reasoning"): - text_val = getattr(item, "text", None) or getattr(item, item.type, None) - app_content.append(AppContentItem(type=item.type, text=text_val)) - - tool_calls = None - if msg.tool_calls: - tool_calls = [ - AppToolCall( - id=tc.id, - type="function", - function=AppToolCallFunction( - name=tc.function.name, - arguments=tc.function.arguments, - ), - ) - for tc in msg.tool_calls - ] - - role = _normalize_app_message_role(msg.role) - - app_messages.append( - AppMessage( - role=role, - content=app_content, - tool_calls=tool_calls, - tool_call_id=msg.tool_call_id, - name=msg.name, - reasoning_content=getattr(msg, "reasoning_content", None), - ) - ) - return app_messages - - def _persist_conversation( db: LMDBConversationStore, model_name: str, @@ -557,153 +396,6 @@ def _build_structured_requirement( ) -def _extract_tool_info(tool: Any) -> tuple[str, str, dict[str, Any] | None]: - """Extract (name, description, parameters) from any tool representation.""" - if hasattr(tool, "function") and tool.function is not None: - fn = tool.function - if isinstance(fn, dict): - name = fn.get("name", "") - description = fn.get("description") or "No description provided." - parameters = fn.get("parameters") - else: - name = getattr(fn, "name", "") - description = getattr(fn, "description", None) or "No description provided." - parameters = getattr(fn, "parameters", None) - return name, description, parameters - - if isinstance(tool, dict): - if "function" in tool and isinstance(tool["function"], dict): - fn = tool["function"] - return ( - fn.get("name", ""), - fn.get("description") or "No description provided.", - fn.get("parameters"), - ) - return ( - tool.get("name", ""), - tool.get("description") or "No description provided.", - tool.get("parameters"), - ) - - name = getattr(tool, "name", "") - description = getattr(tool, "description", None) or "No description provided." - parameters = getattr(tool, "parameters", None) - return name, description, parameters - - -def _extract_named_tool_choice(tool_choice: Any) -> str | None: - """Extract target function name from any named tool choice representation.""" - if isinstance(tool_choice, ChatCompletionNamedToolChoice): - return tool_choice.function.name - if isinstance(tool_choice, ToolChoiceFunction): - return tool_choice.name - if isinstance(tool_choice, dict): - if "function" in tool_choice and isinstance(tool_choice["function"], dict): - return tool_choice["function"].get("name") - return tool_choice.get("name") - return None - - -def _build_tool_prompt( - tools: Sequence[Any], - tool_choice: ( - Literal["none", "auto", "required"] - | ChatCompletionNamedToolChoice - | ToolChoiceFunction - | ToolChoiceTypes - | None - ), -) -> str: - """Generate a system prompt describing available tools and the PascalCase protocol.""" - if not tools: - return "" - - lines: list[str] = [ - "SYSTEM INTERFACE: You have access to the following technical tools. You MUST invoke them when necessary to fulfill the request, strictly adhering to the provided JSON schemas." - ] - - for tool in tools: - name, description, parameters = _extract_tool_info(tool) - if not name: - continue - lines.append(f"Tool `{name}`: {description}") - if parameters: - schema_text = orjson.dumps(parameters, option=orjson.OPT_SORT_KEYS).decode("utf-8") - lines.extend(("Arguments JSON schema:", schema_text)) - else: - lines.append("Arguments JSON schema: {}") - - if tool_choice == "none": - lines.append( - "For this request you must not call any tool. Provide the best possible natural language answer." - ) - elif tool_choice == "required": - lines.append( - "You must call at least one tool before responding to the user. Do not provide a final user-facing answer until a tool call has been issued." - ) - elif (target_name := _extract_named_tool_choice(tool_choice)) is not None: - lines.append( - f"You are required to call the tool named `{target_name}`. Do not call any other tool." - ) - - lines.append(TOOL_WRAP_HINT) - - return "\n".join(lines) - - -def _build_image_generation_instruction( - tools: list[ImageGeneration] | None, - tool_choice: ToolChoiceFunction | None, -) -> str | None: - """Construct explicit guidance so Gemini emits images when requested.""" - has_forced_choice = tool_choice is not None and tool_choice.type == "image_generation" - primary = tools[0] if tools else None - - if not has_forced_choice and primary is None: - return None - - instructions: list[str] = [ - "IMAGE GENERATION ENABLED: When an image is requested, you MUST return a real generated image directly.", - "1. For new requests, generate new images matching the description immediately.", - "2. For edits to existing images, apply changes and return a new generated version.", - "3. CRITICAL: Provide ZERO text explanation, prologue, or apologies. Do not describe the creation process.", - "4. NEVER send placeholder text or descriptions like 'Generating image...' without an actual image attachment.", - ] - - if has_forced_choice: - instructions.append( - "Image generation was explicitly requested. You MUST return at least one generated image. Any response without an image will be treated as a failure." - ) - - return "\n\n".join(instructions) - - -def _append_tool_hint_to_last_user_message(messages: list[AppMessage]) -> None: - """Ensure the last user message carries the tool wrap hint.""" - for msg in reversed(messages): - if msg.role != "user" or msg.content is None: - continue - - if isinstance(msg.content, str): - if TOOL_HINT_STRIPPED not in msg.content: - msg.content = f"{msg.content}\n{TOOL_WRAP_HINT}" - return - - if isinstance(msg.content, list): - for part in reversed(msg.content): - if getattr(part, "type", None) != "text": - continue - text_value = getattr(part, "text", "") or "" - if TOOL_HINT_STRIPPED in text_value: - return - part.text = f"{text_value}\n{TOOL_WRAP_HINT}" - return - - messages_text = TOOL_WRAP_HINT.strip() - msg.content.append(AppContentItem(type="text", text=messages_text)) - return - - def _prepare_messages_for_model( source_messages: list[AppMessage], tools: Sequence[Any] | None, @@ -731,7 +423,7 @@ def _prepare_messages_for_model( instructions: list[str] = [] tool_prompt_injected = False - if inject_system_defaults and tools and (tool_prompt := _build_tool_prompt(tools, tool_choice)): + if inject_system_defaults and tools and (tool_prompt := build_tool_prompt(tools, tool_choice)): instructions.append(tool_prompt) tool_prompt_injected = True @@ -743,7 +435,7 @@ def _prepare_messages_for_model( if not instructions: if tools and tool_choice != "none" and not tool_prompt_injected: - _append_tool_hint_to_last_user_message(prepared) + append_tool_hint_to_last_user_message(prepared) return prepared combined_instructions = "\n\n".join(instructions) @@ -756,7 +448,7 @@ def _prepare_messages_for_model( prepared.insert(0, AppMessage(role="system", content=combined_instructions)) if tools and tool_choice != "none" and not tool_prompt_injected: - _append_tool_hint_to_last_user_message(prepared) + append_tool_hint_to_last_user_message(prepared) return prepared @@ -935,7 +627,7 @@ def _convert_instructions_to_app_messages( if instruction.type and instruction.type != "message": continue - role = _normalize_app_message_role(instruction.role) + role = normalize_app_message_role(instruction.role) content = instruction.content if isinstance(content, str): @@ -1371,7 +1063,7 @@ def make_chunk(delta_content: dict) -> str: if not structured_requirement and (remaining_text := suppressor.flush()): yield make_chunk({"delta": {"content": remaining_text}, "finish_reason": None}) - _, visible_output, storage_output, detected_tool_calls = _process_llm_output( + _, visible_output, storage_output, detected_tool_calls = process_llm_output( normalize_llm_text(full_thoughts or ""), normalize_llm_text(full_text or ""), structured_requirement, @@ -1481,7 +1173,7 @@ def make_chunk(delta_content: dict) -> str: } ) - p_tok, c_tok, t_tok, r_tok = _calculate_usage( + p_tok, c_tok, t_tok, r_tok = calculate_usage( messages, storage_output, detected_tool_calls, full_thoughts ) usage = CompletionUsage( @@ -1503,7 +1195,7 @@ def make_chunk(delta_content: dict) -> str: { "delta": {}, "finish_reason": "tool_calls" if detected_tool_calls else "stop", - "usage": usage.model_dump(mode="json"), + "usage": dump_model(usage), } ) yield "data: [DONE]\n\n" @@ -1559,8 +1251,8 @@ def make_event(etype: str, data: dict) -> str: "status": "in_progress", "metadata": request.metadata or {}, "input": None, - "tools": request.tools or [], - "tool_choice": request.tool_choice or "auto", + "tools": serialize_tools_for_response(request.tools), + "tool_choice": serialize_tool_choice_for_response(request.tool_choice), "output": [], "usage": None, }, @@ -1623,12 +1315,14 @@ async def _make_async_gen(item: ModelOutput) -> AsyncGenerator[ModelOutput]: **base_event, "type": "response.output_item.added", "output_index": thought_index, - "item": ResponseReasoningItem( - id=thought_item_id, - type="reasoning", - status="in_progress", - summary=[], - ).model_dump(mode="json"), + "item": dump_model( + ResponseReasoningItem( + id=thought_item_id, + type="reasoning", + status="in_progress", + summary=[], + ) + ), }, ) @@ -1640,7 +1334,7 @@ async def _make_async_gen(item: ModelOutput) -> AsyncGenerator[ModelOutput]: "item_id": thought_item_id, "output_index": thought_index, "summary_index": 0, - "part": SummaryTextContent(text="").model_dump(mode="json"), + "part": dump_model(SummaryTextContent(text="")), }, ) thought_open = True @@ -1680,9 +1374,7 @@ async def _make_async_gen(item: ModelOutput) -> AsyncGenerator[ModelOutput]: "item_id": thought_item_id, "output_index": thought_index, "summary_index": 0, - "part": SummaryTextContent(text=full_thoughts).model_dump( - mode="json" - ), + "part": dump_model(SummaryTextContent(text=full_thoughts)), }, ) yield make_event( @@ -1691,12 +1383,14 @@ async def _make_async_gen(item: ModelOutput) -> AsyncGenerator[ModelOutput]: **base_event, "type": "response.output_item.done", "output_index": thought_index, - "item": ResponseReasoningItem( - id=thought_item_id, - type="reasoning", - status="completed", - summary=[SummaryTextContent(text=full_thoughts)], - ).model_dump(mode="json"), + "item": dump_model( + ResponseReasoningItem( + id=thought_item_id, + type="reasoning", + status="completed", + summary=[SummaryTextContent(text=full_thoughts)], + ) + ), }, ) thought_open = False @@ -1711,13 +1405,15 @@ async def _make_async_gen(item: ModelOutput) -> AsyncGenerator[ModelOutput]: **base_event, "type": "response.output_item.added", "output_index": message_index, - "item": ResponseOutputMessage( - id=message_item_id, - type="message", - status="in_progress", - role="assistant", - content=[], - ).model_dump(mode="json"), + "item": dump_model( + ResponseOutputMessage( + id=message_item_id, + type="message", + status="in_progress", + role="assistant", + content=[], + ) + ), }, ) @@ -1729,9 +1425,9 @@ async def _make_async_gen(item: ModelOutput) -> AsyncGenerator[ModelOutput]: "item_id": message_item_id, "output_index": message_index, "content_index": 0, - "part": ResponseOutputText( - type="output_text", text="" - ).model_dump(mode="json"), + "part": dump_model( + ResponseOutputText(type="output_text", text="") + ), }, ) message_open = True @@ -1791,12 +1487,14 @@ async def _make_async_gen(item: ModelOutput) -> AsyncGenerator[ModelOutput]: **base_event, "type": "response.output_item.added", "output_index": thought_index, - "item": ResponseReasoningItem( - id=thought_item_id, - type="reasoning", - status="in_progress", - summary=[], - ).model_dump(mode="json"), + "item": dump_model( + ResponseReasoningItem( + id=thought_item_id, + type="reasoning", + status="in_progress", + summary=[], + ) + ), }, ) yield make_event( @@ -1807,7 +1505,7 @@ async def _make_async_gen(item: ModelOutput) -> AsyncGenerator[ModelOutput]: "item_id": thought_item_id, "output_index": thought_index, "summary_index": 0, - "part": SummaryTextContent(text="").model_dump(mode="json"), + "part": dump_model(SummaryTextContent(text="")), }, ) thought_open = True @@ -1840,13 +1538,15 @@ async def _make_async_gen(item: ModelOutput) -> AsyncGenerator[ModelOutput]: **base_event, "type": "response.output_item.added", "output_index": message_index, - "item": ResponseOutputMessage( - id=message_item_id, - type="message", - status="in_progress", - role="assistant", - content=[], - ).model_dump(mode="json"), + "item": dump_model( + ResponseOutputMessage( + id=message_item_id, + type="message", + status="in_progress", + role="assistant", + content=[], + ) + ), }, ) yield make_event( @@ -1857,9 +1557,9 @@ async def _make_async_gen(item: ModelOutput) -> AsyncGenerator[ModelOutput]: "item_id": message_item_id, "output_index": message_index, "content_index": 0, - "part": ResponseOutputText( - type="output_text", text="" - ).model_dump(mode="json"), + "part": dump_model( + ResponseOutputText(type="output_text", text="") + ), }, ) message_open = True @@ -1912,7 +1612,7 @@ async def _make_async_gen(item: ModelOutput) -> AsyncGenerator[ModelOutput]: "item_id": thought_item_id, "output_index": thought_index, "summary_index": 0, - "part": SummaryTextContent(text=full_thoughts).model_dump(mode="json"), + "part": dump_model(SummaryTextContent(text=full_thoughts)), }, ) yield make_event( @@ -1921,16 +1621,18 @@ async def _make_async_gen(item: ModelOutput) -> AsyncGenerator[ModelOutput]: **base_event, "type": "response.output_item.done", "output_index": thought_index, - "item": ResponseReasoningItem( - id=thought_item_id, - type="reasoning", - status="completed", - summary=[SummaryTextContent(text=full_thoughts)], - ).model_dump(mode="json"), + "item": dump_model( + ResponseReasoningItem( + id=thought_item_id, + type="reasoning", + status="completed", + summary=[SummaryTextContent(text=full_thoughts)], + ) + ), }, ) - _, assistant_text, storage_output, detected_tool_calls = _process_llm_output( + _, assistant_text, storage_output, detected_tool_calls = process_llm_output( normalize_llm_text(full_thoughts or ""), normalize_llm_text(full_text or ""), structured_requirement, @@ -1945,13 +1647,15 @@ async def _make_async_gen(item: ModelOutput) -> AsyncGenerator[ModelOutput]: **base_event, "type": "response.output_item.added", "output_index": message_index, - "item": ResponseOutputMessage( - id=message_item_id, - type="message", - status="in_progress", - role="assistant", - content=[], - ).model_dump(mode="json"), + "item": dump_model( + ResponseOutputMessage( + id=message_item_id, + type="message", + status="in_progress", + role="assistant", + content=[], + ) + ), }, ) yield make_event( @@ -1962,7 +1666,7 @@ async def _make_async_gen(item: ModelOutput) -> AsyncGenerator[ModelOutput]: "item_id": message_item_id, "output_index": message_index, "content_index": 0, - "part": ResponseOutputText(type="output_text", text="").model_dump(mode="json"), + "part": dump_model(ResponseOutputText(type="output_text", text="")), }, ) message_open = True @@ -2036,7 +1740,7 @@ async def _make_async_gen(item: ModelOutput) -> AsyncGenerator[ModelOutput]: **base_event, "type": "response.output_item.added", "output_index": img_index, - "item": img_item.model_dump(mode="json"), + "item": dump_model(img_item), }, ) yield make_event( @@ -2045,7 +1749,7 @@ async def _make_async_gen(item: ModelOutput) -> AsyncGenerator[ModelOutput]: **base_event, "type": "response.output_item.done", "output_index": img_index, - "item": img_item.model_dump(mode="json"), + "item": dump_model(img_item), }, ) @@ -2058,13 +1762,15 @@ async def _make_async_gen(item: ModelOutput) -> AsyncGenerator[ModelOutput]: **base_event, "type": "response.output_item.added", "output_index": message_index, - "item": ResponseOutputMessage( - id=message_item_id, - type="message", - status="in_progress", - role="assistant", - content=[], - ).model_dump(mode="json"), + "item": dump_model( + ResponseOutputMessage( + id=message_item_id, + type="message", + status="in_progress", + role="assistant", + content=[], + ) + ), }, ) yield make_event( @@ -2075,9 +1781,9 @@ async def _make_async_gen(item: ModelOutput) -> AsyncGenerator[ModelOutput]: "item_id": message_item_id, "output_index": message_index, "content_index": 0, - "part": ResponseOutputText( - type="output_text", text="" - ).model_dump(mode="json"), + "part": dump_model( + ResponseOutputText(type="output_text", text="") + ), }, ) message_open = True @@ -2152,13 +1858,15 @@ async def _make_async_gen(item: ModelOutput) -> AsyncGenerator[ModelOutput]: **base_event, "type": "response.output_item.added", "output_index": message_index, - "item": ResponseOutputMessage( - id=message_item_id, - type="message", - status="in_progress", - role="assistant", - content=[], - ).model_dump(mode="json"), + "item": dump_model( + ResponseOutputMessage( + id=message_item_id, + type="message", + status="in_progress", + role="assistant", + content=[], + ) + ), }, ) yield make_event( @@ -2169,9 +1877,9 @@ async def _make_async_gen(item: ModelOutput) -> AsyncGenerator[ModelOutput]: "item_id": message_item_id, "output_index": message_index, "content_index": 0, - "part": ResponseOutputText( - type="output_text", text="" - ).model_dump(mode="json"), + "part": dump_model( + ResponseOutputText(type="output_text", text="") + ), }, ) message_open = True @@ -2218,9 +1926,7 @@ async def _make_async_gen(item: ModelOutput) -> AsyncGenerator[ModelOutput]: "item_id": message_item_id, "output_index": message_index, "content_index": 0, - "part": ResponseOutputText(type="output_text", text=assistant_text).model_dump( - mode="json" - ), + "part": dump_model(ResponseOutputText(type="output_text", text=assistant_text)), }, ) @@ -2230,13 +1936,15 @@ async def _make_async_gen(item: ModelOutput) -> AsyncGenerator[ModelOutput]: **base_event, "type": "response.output_item.done", "output_index": message_index, - "item": ResponseOutputMessage( - id=message_item_id, - type="message", - status="completed", - role="assistant", - content=final_response_contents, - ).model_dump(mode="json"), + "item": dump_model( + ResponseOutputMessage( + id=message_item_id, + type="message", + status="completed", + role="assistant", + content=final_response_contents, + ) + ), }, ) @@ -2256,7 +1964,7 @@ async def _make_async_gen(item: ModelOutput) -> AsyncGenerator[ModelOutput]: **base_event, "type": "response.output_item.added", "output_index": tc_index, - "item": tc_item.model_dump(mode="json"), + "item": dump_model(tc_item), }, ) yield make_event( @@ -2265,11 +1973,11 @@ async def _make_async_gen(item: ModelOutput) -> AsyncGenerator[ModelOutput]: **base_event, "type": "response.output_item.done", "output_index": tc_index, - "item": tc_item.model_dump(mode="json"), + "item": dump_model(tc_item), }, ) - p_tok, c_tok, t_tok, r_tok = _calculate_usage( + p_tok, c_tok, t_tok, r_tok = calculate_usage( messages, storage_output, detected_tool_calls, full_thoughts ) usage = ResponseUsage( @@ -2306,7 +2014,7 @@ async def _make_async_gen(item: ModelOutput) -> AsyncGenerator[ModelOutput]: { **base_event, "type": "response.completed", - "response": payload.model_dump(mode="json"), + "response": dump_model(payload), }, ) @@ -2325,7 +2033,7 @@ async def list_models(api_key: str = Depends(verify_api_key)): return ModelListResponse(data=models) -@router.post("/v1/chat/completions") +@router.post("/v1/chat/completions", response_model_exclude_none=True) async def create_chat_completion( request: ChatCompletionRequest, raw_request: Request, @@ -2344,7 +2052,7 @@ async def create_chat_completion( structured_requirement = _build_structured_requirement(request.response_format) extra_instr = [structured_requirement.instruction] if structured_requirement else None - app_messages = _convert_to_app_messages(request.messages) + app_messages = convert_to_app_messages(request.messages) msgs = _prepare_messages_for_model( app_messages, @@ -2415,7 +2123,7 @@ async def create_chat_completion( assert isinstance(resp_or_stream, ModelOutput) - thoughts, visible_output, storage_output, tool_calls = _process_llm_output( + thoughts, visible_output, storage_output, tool_calls = process_llm_output( normalize_llm_text(resp_or_stream.thoughts or ""), normalize_llm_text(resp_or_stream.text or ""), structured_requirement, @@ -2516,9 +2224,7 @@ async def create_chat_completion( visible_output += media_markdown storage_output += media_markdown - p_tok, c_tok, t_tok, r_tok = _calculate_usage( - app_messages, storage_output, tool_calls, thoughts - ) + p_tok, c_tok, t_tok, r_tok = calculate_usage(app_messages, storage_output, tool_calls, thoughts) usage = { "prompt_tokens": p_tok, "completion_tokens": c_tok, @@ -2547,7 +2253,7 @@ async def create_chat_completion( return payload -@router.post("/v1/responses") +@router.post("/v1/responses", response_model_exclude_none=True) async def create_response( request: ResponseCreateRequest, raw_request: Request, @@ -2572,7 +2278,7 @@ async def create_response( elif t.get("type") == "image_generation": image_tools.append(ImageGeneration.model_validate(t)) - img_instr = _build_image_generation_instruction( + img_instr = build_image_generation_instruction( image_tools, request.tool_choice if isinstance(request.tool_choice, ToolChoiceFunction) else None, ) @@ -2658,7 +2364,7 @@ async def create_response( assert isinstance(resp_or_stream, ModelOutput) - thoughts, assistant_text, storage_output, tool_calls = _process_llm_output( + thoughts, assistant_text, storage_output, tool_calls = process_llm_output( normalize_llm_text(resp_or_stream.thoughts or ""), normalize_llm_text(resp_or_stream.text or ""), structured_requirement, @@ -2774,7 +2480,7 @@ async def create_response( if not contents: contents.append(ResponseOutputText(type="output_text", text="")) - p_tok, c_tok, t_tok, r_tok = _calculate_usage(messages, storage_output, tool_calls, thoughts) + p_tok, c_tok, t_tok, r_tok = calculate_usage(messages, storage_output, tool_calls, thoughts) usage = ResponseUsage( input_tokens=p_tok, output_tokens=c_tok, diff --git a/app/utils/helper.py b/app/utils/helper.py index bbe52c0..660d066 100644 --- a/app/utils/helper.py +++ b/app/utils/helper.py @@ -7,15 +7,28 @@ import struct import tempfile import unicodedata +from collections.abc import Sequence from pathlib import Path -from typing import Any +from typing import Any, Literal from urllib.parse import urlparse import orjson from curl_cffi import CurlFollow, CurlHttpVersion, requests from loguru import logger - -from app.models import AppMessage, AppToolCall, AppToolCallFunction +from pydantic import BaseModel + +from app.models import ( + AppContentItem, + AppMessage, + AppToolCall, + AppToolCallFunction, + ChatCompletionMessage, + ChatCompletionNamedToolChoice, + ImageGeneration, + StructuredOutputRequirement, + ToolChoiceFunction, + ToolChoiceTypes, +) type JsonValue = bool | int | float | str | list[JsonValue] | dict[str, JsonValue] | None @@ -440,3 +453,338 @@ def detect_image_extension(data: bytes) -> str | None: if data.startswith(b"GIF8"): return ".gif" return ".webp" if data.startswith(b"RIFF") and data[8:12] == b"WEBP" else None + + +def dump_model(model: BaseModel) -> dict[str, Any]: + """Serialize a Pydantic model into a JSON-compatible dict with None values excluded.""" + return model.model_dump(mode="json", exclude_none=True) + + +def serialize_tools_for_response(tools: Sequence[Any] | None) -> list[dict[str, Any]]: + """Serialize tool objects into clean dictionary representations without None values.""" + if not tools: + return [] + result: list[dict[str, Any]] = [] + for t in tools: + if hasattr(t, "model_dump"): + result.append(t.model_dump(exclude_none=True)) + elif hasattr(t, "dict"): + result.append(t.dict(exclude_none=True)) + elif isinstance(t, dict): + result.append({k: v for k, v in t.items() if v is not None}) + else: + result.append(t) + return result + + +def serialize_tool_choice_for_response(tool_choice: Any) -> Any: + """Serialize tool choice object into a clean dictionary or string representation.""" + if tool_choice is None: + return "auto" + if hasattr(tool_choice, "model_dump"): + return tool_choice.model_dump(exclude_none=True) + if hasattr(tool_choice, "dict"): + return tool_choice.dict(exclude_none=True) + return tool_choice + + +def calculate_usage( + messages: list[AppMessage], + assistant_text: str | None, + tool_calls: list[AppToolCall] | None, + thoughts: str | None = None, +) -> tuple[int, int, int, int]: + """Calculate prompt, completion, total and reasoning tokens consistently.""" + prompt_tokens = sum(estimate_tokens(text_from_message(msg)) for msg in messages) + tool_args_text = "" + if tool_calls: + for call in tool_calls: + tool_args_text += call.function.arguments or "" + + completion_basis = assistant_text or "" + if tool_args_text: + completion_basis = ( + f"{completion_basis}\n{tool_args_text}" if completion_basis else tool_args_text + ) + + completion_tokens = estimate_tokens(completion_basis) + reasoning_tokens = estimate_tokens(thoughts) if thoughts else 0 + total_completion_tokens = completion_tokens + reasoning_tokens + + return ( + prompt_tokens, + total_completion_tokens, + prompt_tokens + total_completion_tokens, + reasoning_tokens, + ) + + +def normalize_app_message_role(role_name: str) -> Literal["system", "user", "assistant", "tool"]: + """Normalize and validate input role string to a valid AppMessage role.""" + mapped = {"developer": "system", "function": "tool"}.get(role_name, role_name) + if mapped == "user": + return "user" + if mapped == "assistant": + return "assistant" + if mapped == "tool": + return "tool" + return "system" + + +def convert_to_app_messages(messages: list[ChatCompletionMessage]) -> list[AppMessage]: + """Convert OpenAI ChatCompletionMessage list into AppMessage format.""" + app_messages: list[AppMessage] = [] + for msg in messages: + app_content: str | list[AppContentItem] | None = None + if isinstance(msg.content, str): + app_content = msg.content + elif isinstance(msg.content, list): + app_content = [] + for item in msg.content: + if item.type == "text": + app_content.append(AppContentItem(type="text", text=item.text)) + elif item.type == "image_url": + media_dict = getattr(item, "image_url", None) + url = media_dict.get("url") if media_dict else None + app_content.append(AppContentItem(type="image_url", url=url)) + elif item.type == "file": + file_dict = getattr(item, "file", None) + filename = file_dict.get("filename") if file_dict else None + file_data = file_dict.get("file_data") if file_dict else None + app_content.append( + AppContentItem(type="file", filename=filename, file_data=file_data) + ) + elif item.type == "input_audio": + audio_dict = getattr(item, "input_audio", None) + audio_data = audio_dict.get("data") if audio_dict else None + app_content.append( + AppContentItem( + type="input_audio", + file_data=audio_data, + raw_data=audio_dict, + ) + ) + elif item.type in ("refusal", "reasoning"): + text_val = getattr(item, "text", None) or getattr(item, item.type, None) + app_content.append(AppContentItem(type=item.type, text=text_val)) + + tool_calls = None + if msg.tool_calls: + tool_calls = [ + AppToolCall( + id=tc.id, + type="function", + function=AppToolCallFunction( + name=tc.function.name, + arguments=tc.function.arguments, + ), + ) + for tc in msg.tool_calls + ] + + role = normalize_app_message_role(msg.role) + + app_messages.append( + AppMessage( + role=role, + content=app_content, + tool_calls=tool_calls, + tool_call_id=msg.tool_call_id, + name=msg.name, + reasoning_content=getattr(msg, "reasoning_content", None), + ) + ) + return app_messages + + +def canonicalize_structured_output( + visible_output: str, structured_requirement: StructuredOutputRequirement +) -> str | None: + """Parse raw or fenced structured JSON and return its canonical JSON representation.""" + candidate = strip_markdown_fence(visible_output) + try: + structured_payload = orjson.loads(candidate) + except orjson.JSONDecodeError: + logger.warning( + f"Failed to decode JSON for structured response (schema={structured_requirement.schema_name})." + ) + return None + + canonical_output = orjson.dumps(structured_payload).decode("utf-8") + logger.debug(f"Structured response fulfilled (schema={structured_requirement.schema_name}).") + return canonical_output + + +def process_llm_output( + thoughts: str | None, + raw_text: str, + structured_requirement: StructuredOutputRequirement | None, +) -> tuple[str | None, str, str, list[AppToolCall]]: + """ + Post-process Gemini output to extract tool calls, unwrap structured JSON fences, and prepare clean text for display and storage. + Returns: (thoughts, visible_text, storage_output, tool_calls) + """ + if thoughts: + thoughts = thoughts.strip() + + visible_output, tool_calls = extract_tool_calls(raw_text) + if tool_calls: + logger.debug(f"Detected {len(tool_calls)} tool call(s) in model output.") + + visible_output = visible_output.strip() + storage_output = visible_output + + if structured_requirement and visible_output: + canonical_output = canonicalize_structured_output(visible_output, structured_requirement) + if canonical_output: + visible_output = canonical_output + storage_output = canonical_output + + return thoughts, visible_output, storage_output, tool_calls + + +def extract_tool_info(tool: Any) -> tuple[str, str, dict[str, Any] | None]: + """Extract (name, description, parameters) from any tool representation.""" + if hasattr(tool, "function") and tool.function is not None: + fn = tool.function + if isinstance(fn, dict): + name = fn.get("name", "") + description = fn.get("description") or "No description provided." + parameters = fn.get("parameters") + else: + name = getattr(fn, "name", "") + description = getattr(fn, "description", None) or "No description provided." + parameters = getattr(fn, "parameters", None) + return name, description, parameters + + if isinstance(tool, dict): + if "function" in tool and isinstance(tool["function"], dict): + fn = tool["function"] + return ( + fn.get("name", ""), + fn.get("description") or "No description provided.", + fn.get("parameters"), + ) + return ( + tool.get("name", ""), + tool.get("description") or "No description provided.", + tool.get("parameters"), + ) + + name = getattr(tool, "name", "") + description = getattr(tool, "description", None) or "No description provided." + parameters = getattr(tool, "parameters", None) + return name, description, parameters + + +def extract_named_tool_choice(tool_choice: Any) -> str | None: + """Extract target function name from any named tool choice representation.""" + if isinstance(tool_choice, ChatCompletionNamedToolChoice): + return tool_choice.function.name + if isinstance(tool_choice, ToolChoiceFunction): + return tool_choice.name + if isinstance(tool_choice, dict): + if "function" in tool_choice and isinstance(tool_choice["function"], dict): + return tool_choice["function"].get("name") + return tool_choice.get("name") + return None + + +def build_tool_prompt( + tools: Sequence[Any], + tool_choice: ( + Literal["none", "auto", "required"] + | ChatCompletionNamedToolChoice + | ToolChoiceFunction + | ToolChoiceTypes + | None + ), +) -> str: + """Generate a system prompt describing available tools and the PascalCase protocol.""" + if not tools: + return "" + + lines: list[str] = [ + "SYSTEM INTERFACE: You have access to the following technical tools. You MUST invoke them when necessary to fulfill the request, strictly adhering to the provided JSON schemas." + ] + + for tool in tools: + name, description, parameters = extract_tool_info(tool) + if not name: + continue + lines.append(f"Tool `{name}`: {description}") + if parameters: + schema_text = orjson.dumps(parameters, option=orjson.OPT_SORT_KEYS).decode("utf-8") + lines.extend(("Arguments JSON schema:", schema_text)) + else: + lines.append("Arguments JSON schema: {}") + + if tool_choice == "none": + lines.append( + "For this request you must not call any tool. Provide the best possible natural language answer." + ) + elif tool_choice == "required": + lines.append( + "You must call at least one tool before responding to the user. Do not provide a final user-facing answer until a tool call has been issued." + ) + elif (target_name := extract_named_tool_choice(tool_choice)) is not None: + lines.append( + f"You are required to call the tool named `{target_name}`. Do not call any other tool." + ) + + lines.append(TOOL_WRAP_HINT) + + return "\n".join(lines) + + +def build_image_generation_instruction( + tools: list[ImageGeneration] | None, + tool_choice: ToolChoiceFunction | None, +) -> str | None: + """Construct explicit guidance so Gemini emits images when requested.""" + has_forced_choice = tool_choice is not None and tool_choice.type == "image_generation" + primary = tools[0] if tools else None + + if not has_forced_choice and primary is None: + return None + + instructions: list[str] = [ + "IMAGE GENERATION ENABLED: When an image is requested, you MUST return a real generated image directly.", + "1. For new requests, generate new images matching the description immediately.", + "2. For edits to existing images, apply changes and return a new generated version.", + "3. CRITICAL: Provide ZERO text explanation, prologue, or apologies. Do not describe the creation process.", + "4. NEVER send placeholder text or descriptions like 'Generating image...' without an actual image attachment.", + ] + + if has_forced_choice: + instructions.append( + "Image generation was explicitly requested. You MUST return at least one generated image. Any response without an image will be treated as a failure." + ) + + return "\n\n".join(instructions) + + +def append_tool_hint_to_last_user_message(messages: list[AppMessage]) -> None: + """Ensure the last user message carries the tool wrap hint.""" + for msg in reversed(messages): + if msg.role != "user" or msg.content is None: + continue + + if isinstance(msg.content, str): + if TOOL_HINT_STRIPPED not in msg.content: + msg.content = f"{msg.content}\n{TOOL_WRAP_HINT}" + return + + if isinstance(msg.content, list): + for part in reversed(msg.content): + if getattr(part, "type", None) != "text": + continue + text_value = getattr(part, "text", "") or "" + if TOOL_HINT_STRIPPED in text_value: + return + part.text = f"{text_value}\n{TOOL_WRAP_HINT}" + return + + messages_text = TOOL_WRAP_HINT.strip() + msg.content.append(AppContentItem(type="text", text=messages_text)) + return From 7b2b32fc5f26dd38fafa3d0a8c20547beb199776 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C6=B0u=20Quang=20V=C5=A9?= Date: Sat, 1 Aug 2026 09:22:10 +0700 Subject: [PATCH 276/291] Auto detect HTTP version and centralize message roles --- app/models/core.py | 5 ++++- app/server/chat.py | 12 ++---------- app/utils/helper.py | 38 +++++++++++++++++++++++--------------- 3 files changed, 29 insertions(+), 26 deletions(-) diff --git a/app/models/core.py b/app/models/core.py index d92dcc6..c51f233 100644 --- a/app/models/core.py +++ b/app/models/core.py @@ -24,8 +24,11 @@ class AppContentItem(BaseModel): raw_data: dict[str, Any] | None = None +type AppMessageRole = Literal["system", "user", "assistant", "tool"] + + class AppMessage(BaseModel): - role: Literal["system", "user", "assistant", "tool"] + role: AppMessageRole name: str | None = None content: str | list[AppContentItem] | None = None tool_calls: list[AppToolCall] | None = None diff --git a/app/server/chat.py b/app/server/chat.py index 9922823..081b416 100644 --- a/app/server/chat.py +++ b/app/server/chat.py @@ -467,10 +467,7 @@ def _convert_responses_to_app_messages( for item in items: if isinstance(item, (ResponseInputMessage, ResponseOutputMessage)): raw_role = getattr(item, "role", "user") - normalized_role = {"developer": "system", "function": "tool"}.get(raw_role, raw_role) - if normalized_role not in ("system", "user", "assistant", "tool"): - normalized_role = "system" - role = cast(Literal["system", "user", "assistant", "tool"], normalized_role) + role = normalize_app_message_role(raw_role) content = item.content if isinstance(content, str): @@ -558,12 +555,7 @@ def _convert_responses_to_app_messages( else: if hasattr(item, "role"): raw_role = getattr(item, "role", "user") - normalized_role = {"developer": "system", "function": "tool"}.get( - raw_role, raw_role - ) - if normalized_role not in ("system", "user", "assistant", "tool"): - normalized_role = "system" - role = cast(Literal["system", "user", "assistant", "tool"], normalized_role) + role = normalize_app_message_role(raw_role) messages.append( AppMessage( role=role, diff --git a/app/utils/helper.py b/app/utils/helper.py index 660d066..e49d455 100644 --- a/app/utils/helper.py +++ b/app/utils/helper.py @@ -20,6 +20,7 @@ from app.models import ( AppContentItem, AppMessage, + AppMessageRole, AppToolCall, AppToolCallFunction, ChatCompletionMessage, @@ -239,7 +240,7 @@ async def save_url_to_tempfile(url: str, tempdir: Path | None = None) -> Path: ) else: async with requests.AsyncSession( - impersonate="chrome", allow_redirects=CurlFollow.SAFE, http_version=CurlHttpVersion.V3 + impersonate="chrome", allow_redirects=CurlFollow.SAFE, http_version=CurlHttpVersion.NONE ) as client: resp = await client.get(url) resp.raise_for_status() @@ -519,16 +520,17 @@ def calculate_usage( ) -def normalize_app_message_role(role_name: str) -> Literal["system", "user", "assistant", "tool"]: +def normalize_app_message_role(role_name: str) -> AppMessageRole: """Normalize and validate input role string to a valid AppMessage role.""" - mapped = {"developer": "system", "function": "tool"}.get(role_name, role_name) - if mapped == "user": - return "user" - if mapped == "assistant": - return "assistant" - if mapped == "tool": - return "tool" - return "system" + roles: dict[str, AppMessageRole] = { + "developer": "system", + "function": "tool", + "user": "user", + "assistant": "assistant", + "tool": "tool", + "system": "system", + } + return roles.get(role_name, "system") def convert_to_app_messages(messages: list[ChatCompletionMessage]) -> list[AppMessage]: @@ -634,11 +636,17 @@ def process_llm_output( visible_output = visible_output.strip() storage_output = visible_output - if structured_requirement and visible_output: - canonical_output = canonicalize_structured_output(visible_output, structured_requirement) - if canonical_output: - visible_output = canonical_output - storage_output = canonical_output + if ( + structured_requirement + and visible_output + and ( + canonical_output := canonicalize_structured_output( + visible_output, structured_requirement + ) + ) + ): + visible_output = canonical_output + storage_output = canonical_output return thoughts, visible_output, storage_output, tool_calls From 5e96f7fefdc8dff56e4f0908435c5d0f26017f40 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C6=B0u=20Quang=20V=C5=A9?= Date: Wed, 12 Aug 2026 23:50:30 +0700 Subject: [PATCH 277/291] Revise the get_access_token logic in gemini-webapi, change auto_close=false to avoid cold start delay and update workflows --- .github/workflows/docker.yaml | 113 +++++++++++++---- .github/workflows/lint.yaml | 15 +-- .github/workflows/track.yml | 6 +- app/server/chat.py | 38 +++++- app/server/middleware.py | 3 +- app/services/lmdb.py | 8 +- app/utils/helper.py | 14 +-- config/config.yaml | 48 ++++---- pyproject.toml | 67 +++++----- uv.lock | 222 ++++++++++++++-------------------- 10 files changed, 286 insertions(+), 248 deletions(-) diff --git a/.github/workflows/docker.yaml b/.github/workflows/docker.yaml index da8135d..38ec80e 100644 --- a/.github/workflows/docker.yaml +++ b/.github/workflows/docker.yaml @@ -4,8 +4,6 @@ on: push: branches: - main - tags: - - "v*" paths: - "app/**" - "config/**" @@ -20,25 +18,42 @@ env: REGISTRY: ghcr.io IMAGE_NAME: ${{ github.repository }} +permissions: + contents: read + packages: write + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + jobs: - build-and-push: - runs-on: ubuntu-latest - permissions: - contents: read - packages: write + build: + strategy: + fail-fast: false + matrix: + include: + - platform: linux/amd64 + runner: ubuntu-26.04 + - platform: linux/arm64 + runner: ubuntu-26.04-arm + runs-on: ${{ matrix.runner }} steps: - name: Checkout repository - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false - - name: Set up QEMU - uses: docker/setup-qemu-action@ce360397dd3f832beb865e1373c09c0e9f86d70a # v4 + - name: Prepare platform pair + run: | + platform=${{ matrix.platform }} + echo "PLATFORM_PAIR=${platform//\//-}" >> "$GITHUB_ENV" - name: Set up Docker Buildx - uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4 + uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0 - name: Log in to Container Registry - uses: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4 + uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0 with: registry: ${{ env.REGISTRY }} username: ${{ github.actor }} @@ -46,7 +61,60 @@ jobs: - name: Extract metadata id: meta - uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf # v6 + uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6.2.0 + with: + images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} + + - name: Build and push image by digest + id: build + uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0 + with: + context: . + platforms: ${{ matrix.platform }} + labels: ${{ steps.meta.outputs.labels }} + outputs: type=image,name=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }},push-by-digest=true,name-canonical=true,push=true + cache-from: type=gha,scope=build-${{ env.PLATFORM_PAIR }} + cache-to: type=gha,mode=max,scope=build-${{ env.PLATFORM_PAIR }} + + - name: Export digest + run: | + mkdir -p "${{ runner.temp }}/digests" + digest="${{ steps.build.outputs.digest }}" + touch "${{ runner.temp }}/digests/${digest#sha256:}" + + - name: Upload digest + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: digests-${{ env.PLATFORM_PAIR }} + path: ${{ runner.temp }}/digests/* + if-no-files-found: error + retention-days: 1 + + merge: + needs: build + runs-on: ubuntu-26.04 + + steps: + - name: Download digests + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + path: ${{ runner.temp }}/digests + pattern: digests-* + merge-multiple: true + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0 + + - name: Log in to Container Registry + uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Extract metadata + id: meta + uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6.2.0 with: images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} tags: | @@ -57,13 +125,12 @@ jobs: type=raw,value={{date 'YYYYMMDD'}}-{{sha}} type=raw,value=latest,enable={{is_default_branch}} - - name: Build and push Docker image - uses: docker/build-push-action@d08e5c354a6adb9ed34480a06d141179aa583294 # v7 - with: - context: . - push: true - tags: ${{ steps.meta.outputs.tags }} - labels: ${{ steps.meta.outputs.labels }} - platforms: linux/amd64,linux/arm64 - cache-from: type=gha - cache-to: type=gha,mode=max + - name: Create manifest list and push + working-directory: ${{ runner.temp }}/digests + run: | + docker buildx imagetools create \ + $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \ + $(printf '${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}@sha256:%s ' *) + + - name: Inspect image + run: docker buildx imagetools inspect ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.meta.outputs.version }} diff --git a/.github/workflows/lint.yaml b/.github/workflows/lint.yaml index 4d46e56..557955a 100644 --- a/.github/workflows/lint.yaml +++ b/.github/workflows/lint.yaml @@ -1,18 +1,15 @@ name: Lint and Type Check on: - push: - paths: - - "**.py" - - "pyproject.toml" - - "uv.lock" - - ".github/workflows/lint.yaml" - pull_request: + push: &ci_trigger + branches: + - main paths: - "**.py" - "pyproject.toml" - "uv.lock" - ".github/workflows/lint.yaml" + pull_request: *ci_trigger permissions: contents: read @@ -23,10 +20,10 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Install uv - uses: astral-sh/setup-uv@cec208311dfd045dd5311c1add060b2062131d57 # v8.0.0 + uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0 - name: Install dependencies run: uv sync --all-groups diff --git a/.github/workflows/track.yml b/.github/workflows/track.yml index 39c4465..440e561 100644 --- a/.github/workflows/track.yml +++ b/.github/workflows/track.yml @@ -14,10 +14,10 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Install uv - uses: astral-sh/setup-uv@cec208311dfd045dd5311c1add060b2062131d57 # v8.0.0 + uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0 - name: Update gemini-webapi id: update @@ -56,7 +56,7 @@ jobs: - name: Create Pull Request if: steps.update.outputs.updated == 'true' - uses: peter-evans/create-pull-request@c0f553fe549906ede9cf27b5156039d195d2ece0 # v8 + uses: peter-evans/create-pull-request@5f6978faf089d4d20b00c7766989d076bb2fc7f1 # v8.1.1 with: token: ${{ secrets.GITHUB_TOKEN }} commit-message: ":arrow_up: update gemini-webapi to ${{ steps.update.outputs.version }}" diff --git a/app/server/chat.py b/app/server/chat.py index 081b416..daaaba6 100644 --- a/app/server/chat.py +++ b/app/server/chat.py @@ -2085,8 +2085,13 @@ async def create_chat_completion( completion_id = f"chatcmpl-{uuid.uuid4()}" created_time = int(datetime.now(tz=UTC).timestamp()) + if session is None or client is None: + logger.error("No Gemini session or client available after preparing conversation.") + raise HTTPException( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail="No available Gemini client." + ) + try: - assert session and client logger.debug( f"Client ID: {client.id}, Input length: {len(m_input)}, files count: {len(files)}" ) @@ -2098,7 +2103,11 @@ async def create_chat_completion( raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail=str(e)) from e if request.stream: - assert not isinstance(resp_or_stream, ModelOutput) + if isinstance(resp_or_stream, ModelOutput): + logger.error("Expected a streaming response from Gemini but got a complete output.") + raise HTTPException( + status_code=status.HTTP_502_BAD_GATEWAY, detail="Streaming response unavailable." + ) return _create_real_streaming_response( resp_or_stream, completion_id, @@ -2113,7 +2122,11 @@ async def create_chat_completion( structured_requirement, ) - assert isinstance(resp_or_stream, ModelOutput) + if not isinstance(resp_or_stream, ModelOutput): + logger.error("Expected a complete output from Gemini but got a stream.") + raise HTTPException( + status_code=status.HTTP_502_BAD_GATEWAY, detail="Unexpected streaming response." + ) thoughts, visible_output, storage_output, tool_calls = process_llm_output( normalize_llm_text(resp_or_stream.thoughts or ""), @@ -2325,8 +2338,13 @@ async def create_response( response_id = f"resp_{uuid.uuid4().hex}" created_time = int(datetime.now(tz=UTC).timestamp()) + if session is None or client is None: + logger.error("No Gemini session or client available after preparing conversation.") + raise HTTPException( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail="No available Gemini client." + ) + try: - assert session and client logger.debug( f"Client ID: {client.id}, Input length: {len(m_input)}, files count: {len(files)}" ) @@ -2338,7 +2356,11 @@ async def create_response( raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail=str(e)) from e if request.stream: - assert not isinstance(resp_or_stream, ModelOutput) + if isinstance(resp_or_stream, ModelOutput): + logger.error("Expected a streaming response from Gemini but got a complete output.") + raise HTTPException( + status_code=status.HTTP_502_BAD_GATEWAY, detail="Streaming response unavailable." + ) return _create_responses_real_streaming_response( resp_or_stream, response_id, @@ -2354,7 +2376,11 @@ async def create_response( structured_requirement, ) - assert isinstance(resp_or_stream, ModelOutput) + if not isinstance(resp_or_stream, ModelOutput): + logger.error("Expected a complete output from Gemini but got a stream.") + raise HTTPException( + status_code=status.HTTP_502_BAD_GATEWAY, detail="Unexpected streaming response." + ) thoughts, assistant_text, storage_output, tool_calls = process_llm_output( normalize_llm_text(resp_or_stream.thoughts or ""), diff --git a/app/server/middleware.py b/app/server/middleware.py index 4b1341f..57bb258 100644 --- a/app/server/middleware.py +++ b/app/server/middleware.py @@ -37,8 +37,7 @@ def verify_media_token(filename: str, token: str | None) -> bool: """Verify the provided token against the filename.""" if expected := get_media_token(filename): return hmac.compare_digest(token, expected) if token else False - else: - return True # No auth required + return True # No auth required def cleanup_expired_media(retention_days: int) -> int: diff --git a/app/services/lmdb.py b/app/services/lmdb.py index 0905448..afe2a1d 100644 --- a/app/services/lmdb.py +++ b/app/services/lmdb.py @@ -446,9 +446,7 @@ def keys(self, prefix: str = "", limit: int | None = None) -> list[str]: for key, _ in cursor: key_str = bytes(key).decode("utf-8") # Skip internal index mappings - if key_str.startswith(self.HASH_LOOKUP_PREFIX) or key_str.startswith( - self.FUZZY_LOOKUP_PREFIX - ): + if key_str.startswith((self.HASH_LOOKUP_PREFIX, self.FUZZY_LOOKUP_PREFIX)): continue if not prefix or key_str.startswith(prefix): @@ -477,9 +475,7 @@ def cleanup_expired(self, retention_days: int | None = None) -> int: cursor = txn.cursor() for key_bytes, value_bytes in cursor: key_str = bytes(key_bytes).decode("utf-8") - if key_str.startswith(self.HASH_LOOKUP_PREFIX) or key_str.startswith( - self.FUZZY_LOOKUP_PREFIX - ): + if key_str.startswith((self.HASH_LOOKUP_PREFIX, self.FUZZY_LOOKUP_PREFIX)): continue try: diff --git a/app/utils/helper.py b/app/utils/helper.py index e49d455..045726d 100644 --- a/app/utils/helper.py +++ b/app/utils/helper.py @@ -158,9 +158,7 @@ def normalize_llm_text(s: str) -> str: s = html.unescape(s) s = unicodedata.normalize("NFC", s) - s = s.replace("\r\n", "\n").replace("\r", "\n") - - return s + return s.replace("\r\n", "\n").replace("\r", "\n") def unescape_text(s: str) -> str: @@ -223,8 +221,7 @@ async def save_file_to_tempfile( delete=False, suffix=Path(file_name).suffix if file_name else ".bin", dir=tempdir ) as tmp: tmp.write(base64.b64decode(file_in_base64)) - path = Path(tmp.name) - return path + return Path(tmp.name) async def save_url_to_tempfile(url: str, tempdir: Path | None = None) -> Path: @@ -252,8 +249,7 @@ async def save_url_to_tempfile(url: str, tempdir: Path | None = None) -> Path: with tempfile.NamedTemporaryFile(delete=False, suffix=suffix, dir=tempdir) as tmp: tmp.write(data) - path = Path(tmp.name) - return path + return Path(tmp.name) def strip_tagged_blocks(text: str) -> str: @@ -312,9 +308,7 @@ def strip_system_hints(text: str) -> str: cleaned = RESPONSE_BLOCK_RE.sub("", cleaned) cleaned = RESPONSE_ITEM_RE.sub("", cleaned) cleaned = TAGGED_ARG_RE.sub("", cleaned) - cleaned = TAGGED_RESULT_RE.sub("", cleaned) - - return cleaned + return TAGGED_RESULT_RE.sub("", cleaned) def _process_tools_internal(text: str, extract: bool = True) -> tuple[str, list[AppToolCall]]: diff --git a/config/config.yaml b/config/config.yaml index b3cd4d2..0f9d4a4 100644 --- a/config/config.yaml +++ b/config/config.yaml @@ -1,16 +1,16 @@ # Gemini FastAPI Configuration File server: - host: "0.0.0.0" # Server bind address - port: 8000 # Server port - api_key: null # API key for authentication (null for no auth) + host: "0.0.0.0" # Server bind address + port: 8000 # Server port + api_key: null # API key for authentication (null for no auth) https: - enabled: false # Enable HTTPS - key_file: "certs/privkey.pem" # SSL private key file path - cert_file: "certs/fullchain.pem" # SSL certificate file path + enabled: false # Enable HTTPS + key_file: "certs/privkey.pem" # SSL private key file path + cert_file: "certs/fullchain.pem" # SSL certificate file path cors: - enabled: true # Enable CORS + enabled: true # Enable CORS allow_origins: ["*"] allow_credentials: true allow_methods: ["*"] @@ -18,28 +18,28 @@ cors: gemini: clients: - - id: "client-id-1" # Arbitrary client ID - secure_1psid: "YOUR_SECURE_1PSID_HERE" # Gemini Secure 1PSID - secure_1psidts: "YOUR_SECURE_1PSIDTS_HERE" # Gemini Secure 1PSIDTS - proxy: null # Optional proxy URL (null/empty means direct connection) - impersonate: null # Optional browser impersonation target (null uses library default) - timeout: 450 # Init timeout in seconds (Not less than 30s) - watchdog_timeout: 120 # Watchdog timeout in seconds (Not less than 30s) - auto_refresh: true # Auto-refresh session cookies - refresh_interval: 600 # Refresh interval in seconds (Not less than 60s) - auto_close: true # Automatically close Gemini session after inactivity - close_delay: 900 # Inactivity delay in seconds before auto-closing (Not less than 30s) - verbose: true # Enable verbose logging for Gemini requests + - id: "client-id-1" # Arbitrary client ID + secure_1psid: "YOUR_SECURE_1PSID_HERE" # Gemini Secure 1PSID + secure_1psidts: "YOUR_SECURE_1PSIDTS_HERE" # Gemini Secure 1PSIDTS + proxy: null # Optional proxy URL (null/empty means direct connection) + impersonate: null # Optional browser impersonation target (null uses library default) + timeout: 450 # Init timeout in seconds (Not less than 30s) + watchdog_timeout: 120 # Watchdog timeout in seconds (Not less than 30s) + auto_refresh: true # Auto-refresh session cookies + refresh_interval: 600 # Refresh interval in seconds (Not less than 60s) + auto_close: false # Automatically close Gemini session after inactivity + close_delay: 900 # Inactivity delay in seconds before auto-closing (Not less than 30s) + verbose: true # Enable verbose logging for Gemini requests extended_thinking: false # Enable Gemini extended thinking mode for message generation - max_chars_per_request: 1000000 # Maximum characters Gemini Web accepts per request. Non-pro users might have a lower limit + max_chars_per_request: 1000000 # Maximum characters Gemini Web accepts per request. Non-pro users might have a lower limit model_strategy: "append" # Strategy: 'append' (default + custom) or 'overwrite' (custom only) models: [] storage: - path: "data/lmdb" # Database storage path + path: "data/lmdb" # Database storage path media_path: "data/media" # Media storage path - max_size: 268435456 # Maximum database size (256 MB) - retention_days: 14 # Number of days to retain conversations before cleanup + max_size: 268435456 # Maximum database size (256 MB) + retention_days: 14 # Number of days to retain conversations before cleanup logging: - level: "DEBUG" # Log level: DEBUG, INFO, WARNING, ERROR + level: "DEBUG" # Log level: DEBUG, INFO, WARNING, ERROR diff --git a/pyproject.toml b/pyproject.toml index 8f8f6e8..f3f7dfb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,32 +5,26 @@ description = "FastAPI Server built on Gemini Web API" readme = "README.md" requires-python = "==3.13.*" dependencies = [ - "curl-cffi>=0.15.0", - "fastapi>=0.141.1", - "gemini-webapi>=2.0.0", - "httptools>=0.8.0", - "lmdb>=2.3.0", - "loguru>=0.7.3", - "orjson>=3.11.9", - "pydantic-settings[yaml]>=2.14.2", - "uvicorn>=0.52.0", - "uvloop>=0.22.1; sys_platform != 'win32'", + "curl-cffi>=0.16.0", + "fastapi>=0.141.1", + "gemini-webapi>=2.0.0", + "httptools>=0.8.0", + "lmdb>=2.3.0", + "loguru>=0.7.3", + "orjson>=3.11.9", + "pydantic-settings[yaml]>=2.15.0", + "uvicorn>=0.52.1", + "uvloop>=0.22.1; sys_platform != 'win32'", ] [project.urls] Repository = "https://github.com/Nativu5/Gemini-FastAPI" [project.optional-dependencies] -dev = [ - "pyright", - "ruff", - "ty", -] +dev = ["pyright", "ruff", "ty"] [dependency-groups] -dev = [ - "gemini-fastapi[dev]", -] +dev = ["gemini-fastapi[dev]"] [tool.ruff] line-length = 100 @@ -38,31 +32,40 @@ target-version = "py313" [tool.ruff.lint] select = [ - "E", # pycodestyle errors - "F", # pyflakes - "W", # pycodestyle warnings - "I", # isort - "UP", # pyupgrade - "B", # flake8-bugbear - "C4", # flake8-comprehensions - "SIM", # flake8-simplify - "RUF", # ruff-specific rules - "TID", # flake8-tidy-imports + "B", # flake8-bugbear + "C4", # flake8-comprehensions + "E", # pycodestyle errors + "F", # pyflakes + "G", # flake8-logging-format + "I", # isort + "LOG", # flake8-logging + "N", # pep8-naming + "PIE", # flake8-pie + "PT", # flake8-pytest-style + "RET", # flake8-return + "RUF", # ruff-specific rules + "SIM", # flake8-simplify + "TID", # flake8-tidy-imports + "UP", # pyupgrade + "W", # pycodestyle warnings ] ignore = [ - "E501", # line too long + "E501", # line too long, enforced by the formatter for code ] [tool.ruff.lint.flake8-bugbear] extend-immutable-calls = [ - "fastapi.Depends", - "fastapi.Query", - "fastapi.security.HTTPBearer", + "fastapi.Depends", + "fastapi.Query", + "fastapi.security.HTTPBearer", ] [tool.ruff.format] quote-style = "double" indent-style = "space" +[tool.pyright] +typeCheckingMode = "standard" + [tool.uv.sources] gemini-webapi = { git = "https://github.com/luuquangvu/Gemini-API.git", rev = "enable-guest-mode" } diff --git a/uv.lock b/uv.lock index 60ae95e..d14ed2a 100644 --- a/uv.lock +++ b/uv.lock @@ -43,27 +43,27 @@ wheels = [ [[package]] name = "cffi" -version = "2.1.0" +version = "2.1.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pycparser", marker = "implementation_name != 'PyPy'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/57/5f/ff100cae70ebe9d8df1c01a00e510e45d9adb5c1fdda84791b199141de97/cffi-2.1.0.tar.gz", hash = "sha256:efc1cdd798b1aaf39b4610bba7aad28c9bea9b910f25c784ccf9ec1fa719d1f9", size = 531036, upload-time = "2026-07-06T21:34:30.382Z" } +sdist = { url = "https://files.pythonhosted.org/packages/9e/ef/008a1939e372c06329a3fce4279c02f328488f3526744906eeec3da7ad5f/cffi-2.1.1.tar.gz", hash = "sha256:dd31f52ea1086513bb9df30f8fcee9b8918323ae067a3d5b78bc826a000712be", size = 530807, upload-time = "2026-08-03T21:21:18.939Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/96/88/a996879e2eeccb815f6e3a5967b12a308257412acec882039d386bd2aa7b/cffi-2.1.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:10537b1df4967ca26d21e5072d7d54188354483b91dc75058968d3f0cf13fbda", size = 194331, upload-time = "2026-07-06T21:33:03.697Z" }, - { url = "https://files.pythonhosted.org/packages/58/85/7ae00d5c8dd6266f4e944c3db630f3c5c9a98b61d469c714d848b1d8138a/cffi-2.1.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:a95b05f9baf29b91171b3a8bd2020b028835243e7b0ff6bb23e2a3c228518b1b", size = 196966, upload-time = "2026-07-06T21:33:05.353Z" }, - { url = "https://files.pythonhosted.org/packages/8c/e9/45c3a76ad8d43ad9261f4c95436da61128d3ca545d72b9612c0ab5be0b1c/cffi-2.1.0-cp313-cp313-macosx_10_15_x86_64.whl", hash = "sha256:15faec4adfff450819f3aee0e2e02c812de6edb88203aa58807955db2003472a", size = 184795, upload-time = "2026-07-06T21:33:06.699Z" }, - { url = "https://files.pythonhosted.org/packages/84/4c/82f132cb4418ee6d953d982b19191e87e2a6372c8a4ce36e50b69d6ade4a/cffi-2.1.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:716ff8ec22f20b4d988b12884086bcef0fc99737043e503f7a3935a6be99b1ea", size = 184746, upload-time = "2026-07-06T21:33:08.071Z" }, - { url = "https://files.pythonhosted.org/packages/a0/1c/4ed5a0e5bdca6cbc275556de3328dd1b76fd0c11cc13c88fe66d1d8715f2/cffi-2.1.0-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:63960549e4f8dc41e31accb97b975abaecfc44c03e396c093a6436763c2ea7db", size = 214747, upload-time = "2026-07-06T21:33:09.671Z" }, - { url = "https://files.pythonhosted.org/packages/3a/a6/e879bb68cc23a2bc9ba8f4b7d8019f0c2694bad2ab6c4a3701d429439f58/cffi-2.1.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ff067a8d8d880e7809e4ac88eb009bb848870115317b306666502ccad30b147f", size = 222392, upload-time = "2026-07-06T21:33:10.896Z" }, - { url = "https://files.pythonhosted.org/packages/88/f6/01890cfd63c08f8eb96a8319b0443690197d240a8bd6346048cf7bde9190/cffi-2.1.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:3b926723c13eba9f81d2ef3820d63aeceec3b2d4639906047bf675cb8a7a500d", size = 210285, upload-time = "2026-07-06T21:33:12.251Z" }, - { url = "https://files.pythonhosted.org/packages/a6/cf/2b684132056f438567b61e19d690dd31cd0921ace051e0a458be6074369e/cffi-2.1.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:47ff3a8bfd8cb9da1af7524b965127095055654c177fcfc7578debcb015eecd0", size = 208801, upload-time = "2026-07-06T21:33:13.617Z" }, - { url = "https://files.pythonhosted.org/packages/6f/08/f2e7d62c460faae0926f2d6e423694aa409ced3bc1fe2927a0a6e5f05416/cffi-2.1.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:799416bae98336e400981ff6e532d67d5c709cfb30afb79865a1315f94b0e224", size = 221808, upload-time = "2026-07-06T21:33:15.466Z" }, - { url = "https://files.pythonhosted.org/packages/38/37/04f54b8e63a02f3d908332c9effbf8c366167c6f733ed8a3d4f79b7e2a1e/cffi-2.1.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:961be50688f7fba2fa65f63712d3b9b341a22311f5253460ce933f52f0de1c8c", size = 225241, upload-time = "2026-07-06T21:33:16.869Z" }, - { url = "https://files.pythonhosted.org/packages/a9/d6/c72eecca433cd3e681c65ed313ab4835d9d4a379704d0f628a6a05f51c2e/cffi-2.1.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:bf5c6cf48238b0eb4c086978c492ad1cbc22373fc5b2d7353b3a598ce6db887a", size = 223588, upload-time = "2026-07-06T21:33:18.239Z" }, - { url = "https://files.pythonhosted.org/packages/c6/4b/e706f67279140f92939da3475ad610df18bfd52d50f14953a8e5fede71d5/cffi-2.1.0-cp313-cp313-win32.whl", hash = "sha256:db3eb7d46527159a878ec3460e9d40615bc25ba337d477db681aea6e4f05c5d2", size = 175248, upload-time = "2026-07-06T21:33:19.799Z" }, - { url = "https://files.pythonhosted.org/packages/5a/47/59eb7975cb0e4ef0afa764ea945b29a5bb4537a9f771cb7d6c8a5dd74c95/cffi-2.1.0-cp313-cp313-win_amd64.whl", hash = "sha256:8e74a6135550c4748af665b1b1118b6aab33b1fc6a16f9aff630af107c3b4512", size = 185717, upload-time = "2026-07-06T21:33:21.47Z" }, - { url = "https://files.pythonhosted.org/packages/5a/af/34fee85c48f8d94efc8597bc09470c9dd274c145f1c12e0fbc6ab6d38d74/cffi-2.1.0-cp313-cp313-win_arm64.whl", hash = "sha256:2282cd5e38aa8accd03e99d1256af8411c84cdbee6a89d841b563fdbd1f3e50f", size = 180114, upload-time = "2026-07-06T21:33:22.515Z" }, + { url = "https://files.pythonhosted.org/packages/9d/f4/035513d4117049066b4779dc3b7c0c0fdad175fa13731c9f4003f1cd1478/cffi-2.1.1-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:b5bdfd1c873d4e093aabc0ca84c4ca6dbc4f752afb5c86f146d9742580c9da2e", size = 194248, upload-time = "2026-08-03T21:19:59.399Z" }, + { url = "https://files.pythonhosted.org/packages/76/af/2aeb4dbb5fc41a04161ae9ff1518de7cec08e164f44a8ce6a4cf7fd2cd1d/cffi-2.1.1-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:31348097ff5bbe827ccc41795d4dd099d9f0625e7def00ee653c137a490c2a6c", size = 196908, upload-time = "2026-08-03T21:20:00.746Z" }, + { url = "https://files.pythonhosted.org/packages/a7/46/2e5fdde8555706dd98139a910ca11be02809f3f605ce956f655d0214e100/cffi-2.1.1-cp313-cp313-macosx_10_15_x86_64.whl", hash = "sha256:9d2055050ea716bd38b7f7f1579c275386646b4894c155a3e2f3cd62ed41b7c6", size = 184805, upload-time = "2026-08-03T21:20:02.02Z" }, + { url = "https://files.pythonhosted.org/packages/55/41/4c7042f317b9217502988f0873af87e16ad606dc20f84e546e3e6ce9764c/cffi-2.1.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:19ee6127ee34de7d83ce3d371ebc5ed91addbdcc39f9ab15ce4eb35a4e534971", size = 184764, upload-time = "2026-08-03T21:20:03.141Z" }, + { url = "https://files.pythonhosted.org/packages/43/1f/1c3d90d91811c8f86ced9ed637956c54bfe5b79ca98fe976d7f8c8979f6b/cffi-2.1.1-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:6a8dddef476fab96d066d578fc88526767b836ab5ab21754e1d5bf3879c31c7c", size = 214722, upload-time = "2026-08-03T21:20:04.377Z" }, + { url = "https://files.pythonhosted.org/packages/37/6f/3b5ce4c3b2192d250f04908f2bfd91ef34552ec8f7716a5d4abdb8d67bb2/cffi-2.1.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f16c709686a78c727bbbf059f92b0bf41c6fc60deec706d2dc19f529175a6125", size = 222369, upload-time = "2026-08-03T21:20:05.544Z" }, + { url = "https://files.pythonhosted.org/packages/02/10/4b3c75dde3d9663c9e02ba05c2668b954f671d4bbe346413ca8c696b295a/cffi-2.1.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:fcd22650c908d7b7da162bbfaab594a1227a15d1643a98c68b122ac642fa2264", size = 210175, upload-time = "2026-08-03T21:20:06.75Z" }, + { url = "https://files.pythonhosted.org/packages/df/62/14f74b9543e605d17701dc797b815958b8bb70b7624ce1b832ddad48ed6c/cffi-2.1.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:aa9511c62d14da7aacc9b4bf51f3f697a621e83b2d6919008243c3aad168eea3", size = 208670, upload-time = "2026-08-03T21:20:08.04Z" }, + { url = "https://files.pythonhosted.org/packages/95/95/86342356ff5953b3fb06f7ef7c5bee212d45e770abc7218d451b9148313c/cffi-2.1.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a931079504ecc49efed7744c476a5c343a92fabf66dec2db95edb1b2fdc770e2", size = 221824, upload-time = "2026-08-03T21:20:09.274Z" }, + { url = "https://files.pythonhosted.org/packages/eb/ff/7b3429ff53aafe931ed8a5fc69f481bbef7ba6de87ddcbb63d08f483f613/cffi-2.1.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a2d7755bef5a12ed488f4ef1f1b69ee9191d7396083b755a5d2295f6edb4768b", size = 225148, upload-time = "2026-08-03T21:20:10.7Z" }, + { url = "https://files.pythonhosted.org/packages/34/34/a95870b9221e09cf4f2ce3178b1a210abdfe63a1bd357da940418d7b8d15/cffi-2.1.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e0bcb7e0f677f543555d2adff3bf19c05f66cdb4796e5ff602442ab2fe3c4ef7", size = 223564, upload-time = "2026-08-03T21:20:12.165Z" }, + { url = "https://files.pythonhosted.org/packages/70/ea/839b50531021a647fb5e929f72cf97bc1ff702b5472166164b5b6e76b851/cffi-2.1.1-cp313-cp313-win32.whl", hash = "sha256:334644fbac4eff73d985a17a91226df55d0f394160c4cfb880e084c8f7161cac", size = 175263, upload-time = "2026-08-03T21:20:13.559Z" }, + { url = "https://files.pythonhosted.org/packages/60/a6/8b149b2c3f2e11aaa1618ef64500b45f50f22c57a977a4dff1aff1f91042/cffi-2.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:1aa5645c30469b09530c4ebca77ebf8f17618293c58f8549cb1a543a50236e7d", size = 185688, upload-time = "2026-08-03T21:20:14.69Z" }, + { url = "https://files.pythonhosted.org/packages/01/9a/11f687cb39d6a3504060d5242f04f48c735afb4d3d533958a20594890cb2/cffi-2.1.1-cp313-cp313-win_arm64.whl", hash = "sha256:63bbfd5ded17c4840ac07cd8f1c21ba9d9708141f840b324f422f41b207e3973", size = 180078, upload-time = "2026-08-03T21:20:15.917Z" }, ] [[package]] @@ -89,27 +89,26 @@ wheels = [ [[package]] name = "curl-cffi" -version = "0.15.0" +version = "0.16.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "certifi" }, { name = "cffi" }, - { name = "rich" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/48/5b/89fcfebd3e5e85134147ac99e9f2b2271165fd4d71984fc65da5f17819b7/curl_cffi-0.15.0.tar.gz", hash = "sha256:ea0c67652bf6893d34ee0f82c944f37e488f6147e9421bef1771cc6545b02ded", size = 196437, upload-time = "2026-04-03T11:12:31.525Z" } +sdist = { url = "https://files.pythonhosted.org/packages/b4/23/d32e113b16dbfb458bea408871ed98dd12f306a366a04215e84537e0af7e/curl_cffi-0.16.0.tar.gz", hash = "sha256:b00b423da8028eb6221e3b63bcd63d681150c07cee8b16000d1f7ea292731895", size = 238344, upload-time = "2026-08-01T13:45:12.372Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/5e/42/54ddd442c795f30ce5dd4e49f87ce77505958d3777cd96a91567a3975d2a/curl_cffi-0.15.0-cp310-abi3-macosx_10_9_x86_64.whl", hash = "sha256:bda66404010e9ed743b1b83c20c86f24fe21a9a6873e17479d6e67e29d8ded28", size = 2795267, upload-time = "2026-04-03T11:11:46.48Z" }, - { url = "https://files.pythonhosted.org/packages/83/2d/3915e238579b3c5a92cead5c79130c3b8d20caaba7616cc4d894650e1d6b/curl_cffi-0.15.0-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:a25620d9bf989c9c029a7d1642999c4c265abb0bad811deb2f77b0b5b2b12e5b", size = 2573544, upload-time = "2026-04-03T11:11:47.951Z" }, - { url = "https://files.pythonhosted.org/packages/2a/b3/9d2f1057749a1b07ba1989db3c1503ce8bed998310bae9aea2c43aa64f20/curl_cffi-0.15.0-cp310-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:582e570aa2586b96ed47cf4a17586b9a3c462cbe43f780487c3dc245c6ef1527", size = 10515369, upload-time = "2026-04-03T11:11:50.126Z" }, - { url = "https://files.pythonhosted.org/packages/b5/1d/6d10dded5ce3fd8157e558ebd97d09e551b77a62cdc1c31e93d0a633cee5/curl_cffi-0.15.0-cp310-abi3-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:838e48212447d9c81364b04707a5c861daf08f8320f9ecb3406a8919d1d5c3b3", size = 10160045, upload-time = "2026-04-03T11:11:52.664Z" }, - { url = "https://files.pythonhosted.org/packages/5c/12/c70b835487ace3b9ba1502631912e3440082b8ae3a162f60b59cb0b6444d/curl_cffi-0.15.0-cp310-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:2b6c847d86283b07ae69bb72c82eb8a59242277142aa35b89850f89e792a02fc", size = 11090433, upload-time = "2026-04-03T11:11:55.049Z" }, - { url = "https://files.pythonhosted.org/packages/ea/0d/78edcc4f71934225db99df68197a107386d59080742fc7bf6bb4d007924f/curl_cffi-0.15.0-cp310-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:9e5e69eee735f659287e2c84444319d68a1fa68dd37abf228943a4074864283a", size = 10479178, upload-time = "2026-04-03T11:11:57.685Z" }, - { url = "https://files.pythonhosted.org/packages/5b/84/1e101c1acb1ea2f0b4992f5c3024f596d8e21db0d53540b9d583f673c4e7/curl_cffi-0.15.0-cp310-abi3-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:aa1323950224db24f4c510d010b3affa02196ca853fb424191fa917a513d3f4b", size = 10317051, upload-time = "2026-04-03T11:12:00.295Z" }, - { url = "https://files.pythonhosted.org/packages/28/42/8ef236b22a6c23d096c85a1dc507efe37bfdfc7a2f8a4b34efb590197369/curl_cffi-0.15.0-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:41f80170ba844009273b2660da1964ec31e99e5719d16b3422ada87177e32e13", size = 11299660, upload-time = "2026-04-03T11:12:02.791Z" }, - { url = "https://files.pythonhosted.org/packages/1d/01/56aeb055d962da87a1be0d74c6c644e251c7e88129b5471dc44ac724e678/curl_cffi-0.15.0-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:1977e1e12cfb5c11352cbb74acef1bed24eb7d226dab61ca57c168c21acd4d61", size = 11945049, upload-time = "2026-04-03T11:12:05.912Z" }, - { url = "https://files.pythonhosted.org/packages/d8/8c/2abf99a38d6340d66cf0557e0c750ef3f8883dfc5d450087e01c85861343/curl_cffi-0.15.0-cp310-abi3-win_amd64.whl", hash = "sha256:5a0c1896a0d5a5ac1eb89cd24b008d2b718dd1df6fd2f75451b59ca66e49e572", size = 1661649, upload-time = "2026-04-03T11:12:07.948Z" }, - { url = "https://files.pythonhosted.org/packages/3d/39/dfd54f2240d3a9b96d77bacc62b97813b35e2aa8ecf5cd5013c683f1ba96/curl_cffi-0.15.0-cp310-abi3-win_arm64.whl", hash = "sha256:a6d57f8389273a3a1f94370473c74897467bcc36af0a17336989780c507fa43d", size = 1410741, upload-time = "2026-04-03T11:12:10.073Z" }, - { url = "https://files.pythonhosted.org/packages/19/6a/c24df8a4fc22fa84070dcd94abeba43c15e08cc09e35869565c0bad196fd/curl_cffi-0.15.0-cp313-abi3-android_24_arm64_v8a.whl", hash = "sha256:4682dc38d4336e0eb0b185374db90a760efde63cbea994b4e63f3521d44c4c92", size = 7190427, upload-time = "2026-04-03T11:12:12.142Z" }, + { url = "https://files.pythonhosted.org/packages/fd/fe/0c330de78421af13e6384ab948e3adbcd4c638b06b53b7fff108bf1db121/curl_cffi-0.16.0-cp310-abi3-macosx_10_9_x86_64.whl", hash = "sha256:6128021320f74999ec1216c1817b2c3adcb0f334d204add1ccf18e248bf7efcb", size = 3023503, upload-time = "2026-08-01T13:44:33.452Z" }, + { url = "https://files.pythonhosted.org/packages/2e/49/3b502d0d09e427b1bdec4f7339bb115c971c9b3fdaf355d02ca06e97ad61/curl_cffi-0.16.0-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:edd5f6e8f122157f4d2351b0b5e48e6a1c0677a2064da71451bb30ef57af19ba", size = 2780341, upload-time = "2026-08-01T13:44:35.131Z" }, + { url = "https://files.pythonhosted.org/packages/2e/b5/b341f96f9fa12b28d1150913a1f9007a09a36757c81b4100373c5bdbf78b/curl_cffi-0.16.0-cp310-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:93615d44f23e56c1256700c2e78de4b879310f39a5621828ea7e2a5ecc04bdda", size = 12824596, upload-time = "2026-08-01T13:44:36.556Z" }, + { url = "https://files.pythonhosted.org/packages/f5/3b/d600b20bff0c55b80b156dc9be0de7c3b3ee2d29977d0a839ea703fab978/curl_cffi-0.16.0-cp310-abi3-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:3c31e71bf68a9c02a279a184ec9c0ea7c80ce1ef4f1d35073fae3124eb3a7868", size = 12647842, upload-time = "2026-08-01T13:44:38.814Z" }, + { url = "https://files.pythonhosted.org/packages/e4/45/9208864ec429558efac168088e50f8a91b947f742ee128cff55b88c7b635/curl_cffi-0.16.0-cp310-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:182416f07d71a342240554fa62c22e591999b78c225b21d3fe27d9f807420dd6", size = 13472637, upload-time = "2026-08-01T13:44:40.893Z" }, + { url = "https://files.pythonhosted.org/packages/eb/c8/1639a1d9c8b64d0323330b219b7207a54b14fc54982c30cb08c8cc95aa16/curl_cffi-0.16.0-cp310-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:d95c0deccc2184eeee7c2aa18d07de261184b418210995aabd0d29f98717a05c", size = 12828918, upload-time = "2026-08-01T13:44:43.049Z" }, + { url = "https://files.pythonhosted.org/packages/0a/02/bcdf03ea583a445280568c9b163c10668037ee09ece62e78c15846a62df0/curl_cffi-0.16.0-cp310-abi3-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ce1f823bc5ce675291a7cf14781496775dfae9298638c25059f2565f6a58a704", size = 12604731, upload-time = "2026-08-01T13:44:45.239Z" }, + { url = "https://files.pythonhosted.org/packages/17/8b/4ddae52044c537ace13ad7e46dded8e9340a64e0704e320455c5151108a8/curl_cffi-0.16.0-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:e52586a9dc4ed5e75faa39be0f30353b10cdc7410bad276beb013085e974bb44", size = 12576433, upload-time = "2026-08-01T13:44:47.626Z" }, + { url = "https://files.pythonhosted.org/packages/5a/f5/38ee2f039db7832f07ce91f6a3d22d87ebfcfaa541130371ac1faa5caea9/curl_cffi-0.16.0-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:ce87f301b31147711c3aebc86fb7e16d8dc48f7e5df272c1bea760c687e28eef", size = 13240699, upload-time = "2026-08-01T13:44:49.727Z" }, + { url = "https://files.pythonhosted.org/packages/ad/03/b9df2973f1119f9d11d8fb3bf2682e5ffe5c52ef3ab89f720473c60fe97e/curl_cffi-0.16.0-cp310-abi3-win_amd64.whl", hash = "sha256:e22a8212d830108e977ff394237f637238e265f5f65037d6c1ee71ea8cc03bcb", size = 1976497, upload-time = "2026-08-01T13:44:51.839Z" }, + { url = "https://files.pythonhosted.org/packages/6b/8b/092beeb5fbe3b7370666708eb5618a9e593ffc80ecb2c97c3395158d270b/curl_cffi-0.16.0-cp310-abi3-win_arm64.whl", hash = "sha256:095fc36e4988736f31521d6fe0aa1f243dba22656b4818fc2fb3b7a547e7a9ba", size = 1711122, upload-time = "2026-08-01T13:44:53.242Z" }, + { url = "https://files.pythonhosted.org/packages/15/ea/81cf3858b256494b31a554cf76bbd345def3ea7e7a1a592cc515633b4e28/curl_cffi-0.16.0-cp313-abi3-android_24_arm64_v8a.whl", hash = "sha256:06b1c7e07af8ff7c4c5ce4086ea89cc582ebff9adff4a37cfffa5f5de5d5b943", size = 8603463, upload-time = "2026-08-01T13:44:55.003Z" }, ] [[package]] @@ -159,18 +158,18 @@ dev = [ [package.metadata] requires-dist = [ - { name = "curl-cffi", specifier = ">=0.15.0" }, + { name = "curl-cffi", specifier = ">=0.16.0" }, { name = "fastapi", specifier = ">=0.141.1" }, { name = "gemini-webapi", git = "https://github.com/luuquangvu/Gemini-API.git?rev=enable-guest-mode" }, { name = "httptools", specifier = ">=0.8.0" }, { name = "lmdb", specifier = ">=2.3.0" }, { name = "loguru", specifier = ">=0.7.3" }, { name = "orjson", specifier = ">=3.11.9" }, - { name = "pydantic-settings", extras = ["yaml"], specifier = ">=2.14.2" }, + { name = "pydantic-settings", extras = ["yaml"], specifier = ">=2.15.0" }, { name = "pyright", marker = "extra == 'dev'" }, { name = "ruff", marker = "extra == 'dev'" }, { name = "ty", marker = "extra == 'dev'" }, - { name = "uvicorn", specifier = ">=0.52.0" }, + { name = "uvicorn", specifier = ">=0.52.1" }, { name = "uvloop", marker = "sys_platform != 'win32'", specifier = ">=0.22.1" }, ] provides-extras = ["dev"] @@ -180,8 +179,8 @@ dev = [{ name = "gemini-fastapi", extras = ["dev"] }] [[package]] name = "gemini-webapi" -version = "0.0.post258" -source = { git = "https://github.com/luuquangvu/Gemini-API.git?rev=enable-guest-mode#2b988bfb03dc4b54e2d4589f0a6ed8ebaff5a668" } +version = "0.0.post259" +source = { git = "https://github.com/luuquangvu/Gemini-API.git?rev=enable-guest-mode#2942759fe3e324ca8e06e2bbaac588623640907c" } dependencies = [ { name = "curl-cffi" }, { name = "loguru" }, @@ -249,27 +248,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/0c/29/0348de65b8cc732daa3e33e67806420b2ae89bdce2b04af740289c5c6c8c/loguru-0.7.3-py3-none-any.whl", hash = "sha256:31a33c10c8e1e10422bfd431aeb5d351c7cf7fa671e3c4df004162264b28220c", size = 61595, upload-time = "2024-12-06T11:20:54.538Z" }, ] -[[package]] -name = "markdown-it-py" -version = "4.2.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "mdurl" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/06/ff/7841249c247aa650a76b9ee4bbaeae59370dc8bfd2f6c01f3630c35eb134/markdown_it_py-4.2.0.tar.gz", hash = "sha256:04a21681d6fbb623de53f6f364d352309d4094dd4194040a10fd51833e418d49", size = 82454, upload-time = "2026-05-07T12:08:28.36Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/b3/81/4da04ced5a082363ecfa159c010d200ecbd959ae410c10c0264a38cac0f5/markdown_it_py-4.2.0-py3-none-any.whl", hash = "sha256:9f7ebbcd14fe59494226453aed97c1070d83f8d24b6fc3a3bcf9a38092641c4a", size = 91687, upload-time = "2026-05-07T12:08:27.182Z" }, -] - -[[package]] -name = "mdurl" -version = "0.1.2" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" }, -] - [[package]] name = "nodeenv" version = "1.10.0" @@ -353,16 +331,16 @@ wheels = [ [[package]] name = "pydantic-settings" -version = "2.14.2" +version = "2.15.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pydantic" }, { name = "python-dotenv" }, { name = "typing-inspection" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/5c/b5/8f48e906c3e0205276e8bd8cb7512217a87b2685304d64be27cad5b3019f/pydantic_settings-2.14.2.tar.gz", hash = "sha256:c19dd64b19097f1de80184f0cc7b0272a13ae6e170cbf240a3e27e381ed14a5f", size = 237700, upload-time = "2026-06-19T13:44:56.324Z" } +sdist = { url = "https://files.pythonhosted.org/packages/68/ca/31c57507b13119d7d3cfa1576dad2911a4861e3be07b579395f4e9d393f9/pydantic_settings-2.15.0.tar.gz", hash = "sha256:694b793e84f766ba76a90ebdefc01d0a9a045dab0382bee70393da93712ad117", size = 261253, upload-time = "2026-08-07T09:24:57.419Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/77/c1/6e422f34e569cf8e18df68d1939c81c099d2b61e4f7d9621c8a77560799c/pydantic_settings-2.14.2-py3-none-any.whl", hash = "sha256:a20c97b37910b6550d5ea50fbcc2d4187defe58cd57070b73863d069419c9440", size = 61715, upload-time = "2026-06-19T13:44:55.02Z" }, + { url = "https://files.pythonhosted.org/packages/30/a4/2bffa9f8e804325a09867f0e9d30795c80ea9f8d62560bd1b6ad6220eb2f/pydantic_settings-2.15.0-py3-none-any.whl", hash = "sha256:0ba092c291c94baceb5eff768aa0d56400a457585bc0175925a5a5510303da42", size = 69413, upload-time = "2026-08-07T09:24:55.839Z" }, ] [package.optional-dependencies] @@ -370,15 +348,6 @@ yaml = [ { name = "pyyaml" }, ] -[[package]] -name = "pygments" -version = "2.20.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, -] - [[package]] name = "pyright" version = "1.1.411" @@ -419,79 +388,66 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" }, ] -[[package]] -name = "rich" -version = "15.0.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "markdown-it-py" }, - { name = "pygments" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/c0/8f/0722ca900cc807c13a6a0c696dacf35430f72e0ec571c4275d2371fca3e9/rich-15.0.0.tar.gz", hash = "sha256:edd07a4824c6b40189fb7ac9bc4c52536e9780fbbfbddf6f1e2502c31b068c36", size = 230680, upload-time = "2026-04-12T08:24:00.75Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/82/3b/64d4899d73f91ba49a8c18a8ff3f0ea8f1c1d75481760df8c68ef5235bf5/rich-15.0.0-py3-none-any.whl", hash = "sha256:33bd4ef74232fb73fe9279a257718407f169c09b78a87ad3d296f548e27de0bb", size = 310654, upload-time = "2026-04-12T08:24:02.83Z" }, -] - [[package]] name = "ruff" -version = "0.16.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/70/25/7113f6d5498888c5fb7db34081cba7d5971c4cb1bfb26819966eee68f003/ruff-0.16.1.tar.gz", hash = "sha256:fedad7c801dabd3fb9741d76aca39246e6ddd9ca446a015875207bf19f1e6bc7", size = 4877500, upload-time = "2026-07-30T19:37:01.379Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/1b/bd/694da69368e0973de65df2ddc73ab18d43c469d5963d9b150911de6bc513/ruff-0.16.1-py3-none-linux_armv6l.whl", hash = "sha256:58edb313b88f0c5460a26adf5f39a37a3be789494a15e3e411e35fa78b89f9a0", size = 10839126, upload-time = "2026-07-30T19:36:13.697Z" }, - { url = "https://files.pythonhosted.org/packages/3f/f0/b626e5d5bd0dd9576263658ef12885e2288afd1029a48e26ffed65ec1ac1/ruff-0.16.1-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:fde5a99e2f97479af66edd6622c6d5a2a7592c77cf4153d9e4428f5eeb55b60c", size = 11070253, upload-time = "2026-07-30T19:36:17.14Z" }, - { url = "https://files.pythonhosted.org/packages/83/63/f40acfb6b35b88623e71684942b552c3edd96035f5d98f313815f7b277de/ruff-0.16.1-py3-none-macosx_11_0_arm64.whl", hash = "sha256:e0d4c20532fca4f7fa609369161d968dd28f65d83dabbd61d8e9c7edbf7001f6", size = 10561425, upload-time = "2026-07-30T19:36:20.04Z" }, - { url = "https://files.pythonhosted.org/packages/aa/dd/14ec0e9c2b4d315547dd38765004b4863e354e1b52cb308272215d9f6f6d/ruff-0.16.1-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:30affbcedf59ad5703d9c91f82266e02b47739f797e1a7b6e158e5526a6dae38", size = 10948879, upload-time = "2026-07-30T19:36:22.476Z" }, - { url = "https://files.pythonhosted.org/packages/33/e9/9d870cbae575030fdef595f04b4b97573c525b5497cce4f4498cf2f85446/ruff-0.16.1-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:24e9c631573cbca9d20f1283f8f479b2afa4a8503504822bd71a293889f16743", size = 10643691, upload-time = "2026-07-30T19:36:24.914Z" }, - { url = "https://files.pythonhosted.org/packages/c4/09/12743d544e2173f53ecd27217c65f90d2bc0f8424a66a60339e56bbc0457/ruff-0.16.1-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b41bdd48fb420987a9b5212e4957c26ad4abce401fa9ea9d4d85843727945f4f", size = 11435354, upload-time = "2026-07-30T19:36:28.447Z" }, - { url = "https://files.pythonhosted.org/packages/7f/89/a1652b2daee52083c9554a6333b678a8b01d0400f976827bb87857f9449a/ruff-0.16.1-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b0d1e1393b7648079e13669de1c1f4fde06d4583e84d8fd5c1551e0a77a2aa75", size = 12259033, upload-time = "2026-07-30T19:36:31.326Z" }, - { url = "https://files.pythonhosted.org/packages/16/96/ecdcb8c54ee7b123b487f807eb014e6e019155a0b81dfb669acd52f28ce3/ruff-0.16.1-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:07bf434b1c95f4e093be4532068ef4fcf00924eb2ade8796075980902d6fd54a", size = 11667981, upload-time = "2026-07-30T19:36:34.394Z" }, - { url = "https://files.pythonhosted.org/packages/cd/90/c52e12e0d862e9572f2a33aa227409143520abe53111e9a6babbac7b4af8/ruff-0.16.1-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:39897739f112253ee4fdd2e8aa9a4f9ded99fb2be367d5f31dfa4ded6025584c", size = 11468183, upload-time = "2026-07-30T19:36:37.339Z" }, - { url = "https://files.pythonhosted.org/packages/2c/6b/4ffb7ad1d83eb16cf8cbb3c8815d3f11c88460fd162d4b372a2059be1c2a/ruff-0.16.1-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:82ae3c0c0d74daf17b968a10b7b3bb3ef297ab7de0c1f749646b25e690ccb150", size = 11470071, upload-time = "2026-07-30T19:36:39.91Z" }, - { url = "https://files.pythonhosted.org/packages/9c/72/32ae7db4c0b5e32ab611787caa19d1546800676d79f7483b7100a3561bf4/ruff-0.16.1-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:4d5f2ed10f8242d83fc08d521301089364e3375375705356f20c0e31606ef3ef", size = 10919503, upload-time = "2026-07-30T19:36:42.65Z" }, - { url = "https://files.pythonhosted.org/packages/f7/ca/3d901ba6ad6fc38da39c3448fc6c59ac945679293a17c3ceb6d6c1cba13e/ruff-0.16.1-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:a4665b309891f83f3e3c25447935f1213e9abbd4b5640af7a1f2def9f8d413c1", size = 10649861, upload-time = "2026-07-30T19:36:45.18Z" }, - { url = "https://files.pythonhosted.org/packages/92/79/894ef1ced26552d5f8c9cf6d85b0687840e1128c55aeab7b9c2d54a0d880/ruff-0.16.1-py3-none-musllinux_1_2_i686.whl", hash = "sha256:26e9ca5c9bc3971f20d3cf18a957f52ffd6a5f6564ff15c4912a144dcac22494", size = 11148137, upload-time = "2026-07-30T19:36:47.936Z" }, - { url = "https://files.pythonhosted.org/packages/2d/69/3609a09fa1cb46cc28b762363e440a354204e5dff01bd0c8d7437874d6b9/ruff-0.16.1-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:67e1e1e3fa4f0c82f0e36d4cd61e661f6e7a6196cb1aa92fe0828fa7b8f257cd", size = 11559211, upload-time = "2026-07-30T19:36:50.448Z" }, - { url = "https://files.pythonhosted.org/packages/fc/8a/fb22af2fd78a736e241fabf67e30ce1799a64244026377a49e133af90762/ruff-0.16.1-py3-none-win32.whl", hash = "sha256:d31765e131295b8445caf301e3e8a85b34d1b9b211b4109b7ba457888b051806", size = 10838258, upload-time = "2026-07-30T19:36:53.298Z" }, - { url = "https://files.pythonhosted.org/packages/d4/35/e57fd9fb5d423961df087a00b12d42c0a830288dc2f3b45ecca299158b4f/ruff-0.16.1-py3-none-win_amd64.whl", hash = "sha256:09b05e8b90c2cb06ad63464350e7a45e8e44a2dfe52072ebfba6666ca8d3f596", size = 11961111, upload-time = "2026-07-30T19:36:56.107Z" }, - { url = "https://files.pythonhosted.org/packages/cb/46/240ea004bf6dc4feb40e9832f2205a476a47dd5b8a3f8211a5fc5f95e20e/ruff-0.16.1-py3-none-win_arm64.whl", hash = "sha256:dbaadaac38c70239f056d306b7476f246b0bf000fa6b3876402acbf5b227eaf8", size = 11309414, upload-time = "2026-07-30T19:36:58.79Z" }, +version = "0.16.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/73/e1/4508a569211b35599016e84ba65c1a992b7a4004b4b6c4bea02a851cba1b/ruff-0.16.2.tar.gz", hash = "sha256:c3d7828d12e8927a6fc65fe38e2c2541b9e762d360a1786d752cb1b8883b3c9c", size = 4885811, upload-time = "2026-08-07T13:31:01.432Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/14/57/db19951540f98859c956b50bdb4d31089b4d91e9f15e2968e7d5193806d5/ruff-0.16.2-py3-none-linux_armv6l.whl", hash = "sha256:3c8de4cf2181f01d57946d87d777aa52916976fc09942aed89938fab5e013318", size = 10847925, upload-time = "2026-08-07T13:30:14.468Z" }, + { url = "https://files.pythonhosted.org/packages/13/5a/995fe85a8470d3e391ac0f7fa8054bb454eaf33ee138196d6172ed1079c0/ruff-0.16.2-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:9a48cc05c6fbc811ca81b5d7ba95375affea6582d1b8024e455e41afbbf55344", size = 11072662, upload-time = "2026-08-07T13:30:18.143Z" }, + { url = "https://files.pythonhosted.org/packages/32/53/370d767c61c71a971a4ace36703a7ecd8c393956349a7325d7fab2b56827/ruff-0.16.2-py3-none-macosx_11_0_arm64.whl", hash = "sha256:a2c0d14fcbb26c91f0f867a6dc9bd71bbc30b1b6151829c884f23faeab2e5700", size = 10566771, upload-time = "2026-08-07T13:30:20.899Z" }, + { url = "https://files.pythonhosted.org/packages/85/d6/9d96948caf5a632be62d62202d5ec914d6856f204fd79eb036e5915e79ea/ruff-0.16.2-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:335c621622c4650330be50842561c6586ac6971bb8ab5407fe34dcc9efb16bbe", size = 10975825, upload-time = "2026-08-07T13:30:23.517Z" }, + { url = "https://files.pythonhosted.org/packages/3b/92/ea87129b3414acb0b5770563779c51804d37ac67675c7ba35447ddb14773/ruff-0.16.2-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:20e66910f2c37cc753f9ef6580c914a621b80c4fa3549d3e3521e29d0f5bfc3f", size = 10649437, upload-time = "2026-08-07T13:30:26.097Z" }, + { url = "https://files.pythonhosted.org/packages/ac/43/f8f291dcd4af5bb7872b74fdfa41a7cd7c856ca1d4069670971cf1b9f5cb/ruff-0.16.2-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c7e36fbfba65510548156902bcf1350a979a958ce0347ce0f90d73894036b39f", size = 11446761, upload-time = "2026-08-07T13:30:28.752Z" }, + { url = "https://files.pythonhosted.org/packages/71/4a/ef991fb2fcf516ab71f0808adcdd8da5e18c8cde447f4ceaf5f47a5132a5/ruff-0.16.2-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f0eab35f80df8f134aae5d1630e751901321d317cc8e50dc39e36fa3ed34cd12", size = 12336364, upload-time = "2026-08-07T13:30:31.468Z" }, + { url = "https://files.pythonhosted.org/packages/f3/24/f615e74f307e6ca0e56a482872477b856c70d530aa356abfb6dfe5ca8a80/ruff-0.16.2-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:40ea8c0594feb894e89c8c61ab9c103d38b0ea72dfde6c594107147ca31b1140", size = 11630720, upload-time = "2026-08-07T13:30:34.426Z" }, + { url = "https://files.pythonhosted.org/packages/c5/d3/8ef50149e8412a77f7ab409efdef0e2b23803707a3863da4fc64cb23d459/ruff-0.16.2-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ab3d62dde0b19facdd632008cc4827fc28ada7736c6bd35ab6f1050f0bfed53f", size = 11466130, upload-time = "2026-08-07T13:30:36.958Z" }, + { url = "https://files.pythonhosted.org/packages/dd/a7/a19334985c4dea8c381981fa252cd854c7ee52dc4b1686dc16f4a911c702/ruff-0.16.2-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:e43e1f5b8388da9eca1b9e88328d47a5cec794633ccf6f7484ac2dd15eee92c0", size = 11523634, upload-time = "2026-08-07T13:30:39.822Z" }, + { url = "https://files.pythonhosted.org/packages/6e/6c/96d192b0e742412ceda08c0a50f9669b253dde9fd6a60ea1a10c9fa79a63/ruff-0.16.2-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:c24788a980581e1d7ea3a0cbe4344c4fbeb0a6a9b1f4713aa46bb104f8294690", size = 10949807, upload-time = "2026-08-07T13:30:42.745Z" }, + { url = "https://files.pythonhosted.org/packages/fa/51/e26599ceca11e79ee255c7df515995561edf87e9ca1893284e44d98f5a86/ruff-0.16.2-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:81806b08329130005dd4a8a8394a0c9da8c6f4cafb16ba438d2a2ee6a18bedf1", size = 10646891, upload-time = "2026-08-07T13:30:45.522Z" }, + { url = "https://files.pythonhosted.org/packages/68/01/800c4b1f97bc8d7c6029e06b1f20473a3cf1e13c4933d8f3342add83fc55/ruff-0.16.2-py3-none-musllinux_1_2_i686.whl", hash = "sha256:4ce4e02bad779bef557f541a1b31f20d6abeae1cc05ed1b1ac019d4ffd1044c8", size = 11162063, upload-time = "2026-08-07T13:30:48.131Z" }, + { url = "https://files.pythonhosted.org/packages/e4/d0/1477ea50fc5a0d4b0b71d1d63d50770bdd794d90b43e37a7618e63ec9894/ruff-0.16.2-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:e0422abdf70070255fc4073ce9dfc814cc03db577013761ddd09bc1e4a9a4fbd", size = 11556038, upload-time = "2026-08-07T13:30:50.686Z" }, + { url = "https://files.pythonhosted.org/packages/b8/76/a7776f32048d991e16d4fa8ff91790b877342d3596cc3ed04acdbf1aaedc/ruff-0.16.2-py3-none-win32.whl", hash = "sha256:bf3a63d78fb39f4bf5ac8ae52051c5520505301abe19ba4e204c453b3f09bb0b", size = 10872850, upload-time = "2026-08-07T13:30:53.471Z" }, + { url = "https://files.pythonhosted.org/packages/00/0d/929c800d920e61397d82a01b60bffc68da3052c17d31de59efaad2e4ed75/ruff-0.16.2-py3-none-win_amd64.whl", hash = "sha256:bcabe2f6d0fc7819f1431793005af4e4de7371927d037345bf941252b195b9fa", size = 12023338, upload-time = "2026-08-07T13:30:56.193Z" }, + { url = "https://files.pythonhosted.org/packages/5b/6c/93e26c22c5f78ff87363e07da49c84955affbeb1098bd1936bf3b3f293bf/ruff-0.16.2-py3-none-win_arm64.whl", hash = "sha256:d614e95cedf38a2053fd351c55b103ba30d017d61688fdbfd40ee0412852a99f", size = 11374065, upload-time = "2026-08-07T13:30:58.775Z" }, ] [[package]] name = "starlette" -version = "1.3.1" +version = "1.6.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/eb/e3/7c1dc7381d9f8ab7d854328ebfa884e62cb3f3d8549ddfd37c7814f42afa/starlette-1.3.1.tar.gz", hash = "sha256:05d0213193f2fbaae60e2ecb593b4add4262ad4e46536b54abe36f11a71724e0", size = 2703240, upload-time = "2026-06-12T09:23:11.602Z" } +sdist = { url = "https://files.pythonhosted.org/packages/b5/b4/205b0d5241d934e8add0c38aa924c4f9fb7330834ff11e5444db964ec3f9/starlette-1.6.0.tar.gz", hash = "sha256:d4e3ac5e546444960c710297a3c9fc3f7ebae1b7e963f3d36173b49da535be9b", size = 2716969, upload-time = "2026-08-08T18:27:57.512Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ec/bb/2799cc2ede3ed41131f8975621e7213dfc7ef4acbbaadfa440f32500c370/starlette-1.3.1-py3-none-any.whl", hash = "sha256:c7372aae11c3c3f26a42df7bd626cec2f47d03483d261d369516a615a53714c6", size = 73632, upload-time = "2026-06-12T09:23:10.017Z" }, + { url = "https://files.pythonhosted.org/packages/c8/cb/6a6a47d5b464bd08695d254f3da6e7986cc70c9fa5d778eda57538edfe56/starlette-1.6.0-py3-none-any.whl", hash = "sha256:a86dd39d14bb45f85a3d18525215a9ef0cfd1f192ac793220e72598c90335f0c", size = 75969, upload-time = "2026-08-08T18:27:56.196Z" }, ] [[package]] name = "ty" -version = "0.0.65" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cf561927e8e9ab5c1892a833b664aa9cd6f051a75f6280c66d8047246bda/ty-0.0.65.tar.gz", hash = "sha256:b7134bffcc00b715fa8291e84d845782ced810a998dc1f7f11d71c85c4046325", size = 6460098, upload-time = "2026-07-29T18:31:03.27Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/7b/4e/71e2d325d2b53a1afad81624ad076b2ede413213fc4a18cb05b78c568571/ty-0.0.65-py3-none-linux_armv6l.whl", hash = "sha256:dc556c9f05408bef4c4ef02b2cc382e4e5f797b4b20d64410289848f0d76705f", size = 12298466, upload-time = "2026-07-29T18:30:12.744Z" }, - { url = "https://files.pythonhosted.org/packages/57/77/fec8f29647c55794efa430a7f365e44f5ce7ffb6459d9445a87fac569bec/ty-0.0.65-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:29d2e0d34cc0a28a17ef0cf81135c5ebabc3562131f9079138ba5e7bae0f56bd", size = 11942421, upload-time = "2026-07-29T18:30:16.076Z" }, - { url = "https://files.pythonhosted.org/packages/13/09/7f3766aef9dc627e2698cf4e3e59cf53389dcae3812040d33c1aa931230f/ty-0.0.65-py3-none-macosx_11_0_arm64.whl", hash = "sha256:685f49a9312bbf69d5b65bbb66384fed1f927403ea030c217b9289092d7e46c4", size = 11451922, upload-time = "2026-07-29T18:30:19.155Z" }, - { url = "https://files.pythonhosted.org/packages/cb/7b/1a77cd50e0befb50f55b8bf9bd3ed3eddf184bf28c61b56727039e0774fc/ty-0.0.65-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6f564b5ebe78e2f3a8e7b8eacb1292eb88b7c0f3c8630671cfca31abc0709cd9", size = 11994999, upload-time = "2026-07-29T18:30:22.315Z" }, - { url = "https://files.pythonhosted.org/packages/63/7b/feda16f3a4a0a99be27431e0c9598eeeec0db1eb2fec9a15976698209418/ty-0.0.65-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c983e156fe9e113fb56389e13d327b6b8549fe866de9b269684723a88e9b732d", size = 12090662, upload-time = "2026-07-29T18:30:24.93Z" }, - { url = "https://files.pythonhosted.org/packages/ed/3e/3f69bf9c9307dbdc0771719f65ce5b556e7bdeeaccbdd599d4f57866d801/ty-0.0.65-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e3663b7396e8b1a9954e20e732de7ccb0192bf4118473069b4945920d6923921", size = 12822094, upload-time = "2026-07-29T18:30:28.012Z" }, - { url = "https://files.pythonhosted.org/packages/90/38/8fa791b3bb503ee2b46ad81690cd1bdd54519582df6d805cee57fe143e85/ty-0.0.65-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:306ed01f29d6e108e98feb233dbbf5878a027603b71bd3743b343977933a9f16", size = 13357833, upload-time = "2026-07-29T18:30:31.122Z" }, - { url = "https://files.pythonhosted.org/packages/c1/73/4dda396a201e1dd0ed3594a9b48e559cb41c4bc048c6cd4c4d1b39eb4313/ty-0.0.65-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:28bcfc8898c94f079a9100e684bcf312b6a64ad3a7d4ebb35a4591546030a2cd", size = 12977303, upload-time = "2026-07-29T18:30:33.944Z" }, - { url = "https://files.pythonhosted.org/packages/a5/26/c250c2c569adc53a8591716641388397bcb2a442e4a30b952ae81b50c0e0/ty-0.0.65-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5a75bd0c245c38802a8f488378e74f92feb7dd33db7d63fbdd6fdf82791ba730", size = 12579338, upload-time = "2026-07-29T18:30:37.199Z" }, - { url = "https://files.pythonhosted.org/packages/d3/94/4a5647d44753ca218fc930d7e4d9bf468d0ed4a0ad4b3d57588bc1bbacf7/ty-0.0.65-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:9e5e1bdea9662d2b5312b4e99f319f4e6e2ea427511b5fbc546141b79ec53f76", size = 12957731, upload-time = "2026-07-29T18:30:39.937Z" }, - { url = "https://files.pythonhosted.org/packages/36/b6/1e22fa11a1e0dfb20b1c7f3cbfd8170273aada2a82f9ecd3055275370c44/ty-0.0.65-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:03a88493d4842889f65280ae241e06b399d57eb3c63571054cad21a4c33b3b69", size = 11938625, upload-time = "2026-07-29T18:30:42.603Z" }, - { url = "https://files.pythonhosted.org/packages/5c/0a/fe5f22ef62b193201bc5566762e22049762cd485bfafb5095a7050760054/ty-0.0.65-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:600b8bf6f4940cf7ffb2f43d3716faaf38dcb97cd8617c55771451bc0276408f", size = 12105592, upload-time = "2026-07-29T18:30:45.419Z" }, - { url = "https://files.pythonhosted.org/packages/76/fd/922b3a6e9d697452cdbb4b7e3f636868add5ec652154518a736e4364f3b7/ty-0.0.65-py3-none-musllinux_1_2_i686.whl", hash = "sha256:0c28007bc79d648c1ddaf1e65885d07baec48eb87240da442f608e4107c1b7d8", size = 12387335, upload-time = "2026-07-29T18:30:48.405Z" }, - { url = "https://files.pythonhosted.org/packages/77/22/a1a08ebc84c083db2fb55e3b5cd186db0c067692f4921146f601360231e2/ty-0.0.65-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:c852da96091ad22361e6586b7c7ba98e1334dcd4d8ffb67e47f4fb673de33f77", size = 12682710, upload-time = "2026-07-29T18:30:51.364Z" }, - { url = "https://files.pythonhosted.org/packages/81/14/eaaa410a25bbdea19722109b5422380a0e211b3afcf3071d15953ddbd5db/ty-0.0.65-py3-none-win32.whl", hash = "sha256:cf529d538f1403b14b0511e6ec3cdb95d3d974adabf24cc76cedc533368c3edc", size = 11692341, upload-time = "2026-07-29T18:30:54.35Z" }, - { url = "https://files.pythonhosted.org/packages/bc/0f/6d48f206dce9d7e53fe3b5ea0f0ab5800dd9d2365b2b48f736783436c43f/ty-0.0.65-py3-none-win_amd64.whl", hash = "sha256:234a321e33c7cbbfbd67bfa0b01b685dd9c21f1841781a21e5ca1fa0b25f1d5d", size = 12729355, upload-time = "2026-07-29T18:30:57.275Z" }, - { url = "https://files.pythonhosted.org/packages/96/aa/7446f7725e303cf78e058c893af1f0552b9451895454908706f4c6c3494b/ty-0.0.65-py3-none-win_arm64.whl", hash = "sha256:b9424be1ec56d93ff18609fb1c0a0a2283fe1282cd6d1c7604f97d73b94d61f2", size = 12051375, upload-time = "2026-07-29T18:31:00.579Z" }, +version = "0.0.70" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/58/ed/38a8ab52f1d7c3ed701442a31b23ba774cbc5d6909f2c00da9e1f3c590f9/ty-0.0.70.tar.gz", hash = "sha256:a01bebc128b4081c16002965d906fccb21323d69bb709b9108c1f2406bcffced", size = 6601156, upload-time = "2026-08-10T23:20:26.268Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3d/c5/ddd6cc5657fd3da85591b264f4dabb1ce5e5b535e73fd5174991c294978b/ty-0.0.70-py3-none-linux_armv6l.whl", hash = "sha256:4fb2d2e55e2160c07152361be2e1a26fdd4f6261055731317d5972daf1749935", size = 12537215, upload-time = "2026-08-10T23:19:43.512Z" }, + { url = "https://files.pythonhosted.org/packages/8b/8a/acc9b34331cde81e0d63cda92d4db9e4042def5b1efa7d47431ff1d30d17/ty-0.0.70-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:9b04d4c21cb029501c05598ca21e9c0829c0b2e35a7ceba1e5b78014c1e8104d", size = 12149070, upload-time = "2026-08-10T23:19:46.369Z" }, + { url = "https://files.pythonhosted.org/packages/fd/cd/51ac2708f4d077058a0bfeefa40d630883c5ec7a82fd2c15c86519140235/ty-0.0.70-py3-none-macosx_11_0_arm64.whl", hash = "sha256:c605ebf2643f5e64ec4bcb269640a1aa85966d29fa888fb746039c28570369b2", size = 11625729, upload-time = "2026-08-10T23:19:48.535Z" }, + { url = "https://files.pythonhosted.org/packages/b7/99/afc4fe7e630100dc782ff0cdc8c59c01acfb05299551ff0ef49c93814320/ty-0.0.70-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0c38ea76e12909c29e18fcd295ff223b63f3701c1d1c34db2cdc9736340b9de2", size = 12204810, upload-time = "2026-08-10T23:19:51.845Z" }, + { url = "https://files.pythonhosted.org/packages/ca/38/8cabc3c8ad4c3a02e585ecff12a71ff8e5881f8a00ee71745fd765201da5/ty-0.0.70-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c1ee6bf4edaabf7bd0307f0c0f9ddc2204df0390820fe89f6a9de65f07722aba", size = 12308114, upload-time = "2026-08-10T23:19:54.229Z" }, + { url = "https://files.pythonhosted.org/packages/44/7e/bb4e552ecd68bb490bae9368ef323e3d0c79ef85a811857139a2d0f59ddb/ty-0.0.70-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5eef3e11d7d6b800ef66da0cce8ea24f8be804d1ebf359683426cfe848729bf8", size = 13072240, upload-time = "2026-08-10T23:19:56.872Z" }, + { url = "https://files.pythonhosted.org/packages/f6/c4/1861e1d554e5b6e0d11b3a9f36d40c372253f44a3c4e56ad4bf840f2801e/ty-0.0.70-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a95e4ae7c2599197c9d89652e49ca344ab224f7d12376e6ad8beb0587e8ff83f", size = 13497678, upload-time = "2026-08-10T23:19:59.191Z" }, + { url = "https://files.pythonhosted.org/packages/77/22/3b22442133e8f641485e59e463a070a31184db0d6456851f871db8f59763/ty-0.0.70-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:06aca758d1e0016c0a1f57fe9d8de7a21ff83f692306f747a0d97f32de24e27f", size = 13232510, upload-time = "2026-08-10T23:20:01.456Z" }, + { url = "https://files.pythonhosted.org/packages/84/b8/911f1e6885b5485b6e1d29aaab19ce5e6deeb3e931b6ad82296da4f22051/ty-0.0.70-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d81825524f1b57ecbcb5fce7d61fb159cb4837a6167a4569309c9fa7fc15a77d", size = 12817128, upload-time = "2026-08-10T23:20:04.275Z" }, + { url = "https://files.pythonhosted.org/packages/e6/a6/9affc3ca11c32d75b348a66144ab83335fa7fa7100b1581356725e15a668/ty-0.0.70-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:3287dfb09f7320ef28f114f5f9aae5f697b7b1a6ee37fb2a4a3be94481c1b4f8", size = 13089208, upload-time = "2026-08-10T23:20:06.574Z" }, + { url = "https://files.pythonhosted.org/packages/f0/cb/4776108ea08ec4013b71375b101be9c8a632967cd10f24abbdd4664d66f4/ty-0.0.70-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:9fb1877f6401cdaac4db46c5bf762f327482ba5f891420ea462ebd15d0de4185", size = 12148890, upload-time = "2026-08-10T23:20:08.827Z" }, + { url = "https://files.pythonhosted.org/packages/c9/d9/814e71f698d9231ef6412bd2c1499d81c6f86f66c2764e952c991e2d6da5/ty-0.0.70-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:e78f4997dbb0db2d2270210eb3694466206e5b3a11985e24148770e702158a25", size = 12327084, upload-time = "2026-08-10T23:20:11.023Z" }, + { url = "https://files.pythonhosted.org/packages/3b/69/9f34bba534c5d1ace391d300ead4f1971f4348c5459e66dc466cfd60661c/ty-0.0.70-py3-none-musllinux_1_2_i686.whl", hash = "sha256:cf758d3b2dad910c9b1d22d2d62fea894b5bc9acd3c00365e8a73ace6090a452", size = 12604372, upload-time = "2026-08-10T23:20:13.366Z" }, + { url = "https://files.pythonhosted.org/packages/fe/ff/abb34674517b29a8e2489a39c4e01930fcf6aab993a7d6b33aa97f119117/ty-0.0.70-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:0d338761617279a4fb6a83e7fad7e21126394637b8c45e13e196b2bb675f39b2", size = 12917800, upload-time = "2026-08-10T23:20:15.841Z" }, + { url = "https://files.pythonhosted.org/packages/a4/1a/f8289572f4fad5cb16c883b94638548166bc78cf3eb569f0cd9199eb10a0/ty-0.0.70-py3-none-win32.whl", hash = "sha256:a45642cf09dde91f0a3ce9b9e6fffda9779ff69d73f7347c18acf4fb45007c07", size = 11921482, upload-time = "2026-08-10T23:20:18.244Z" }, + { url = "https://files.pythonhosted.org/packages/17/ae/8739d7618b4670c3ee4f52d641d53be87928bd121d8bda9c0e3450500b75/ty-0.0.70-py3-none-win_amd64.whl", hash = "sha256:33e7941a926cf39b82553911a59a6ed68ec98c3d3d5a415df633f4d1cd051e6c", size = 12986994, upload-time = "2026-08-10T23:20:20.53Z" }, + { url = "https://files.pythonhosted.org/packages/f1/44/2bc3301ba4356ad8866daac9f8cfb3687953e52d076ea204f113b9304c42/ty-0.0.70-py3-none-win_arm64.whl", hash = "sha256:0d380f735d52b1d4b773193f8f5c58c065725eab1ebb0008d38a7b830de14f47", size = 12301082, upload-time = "2026-08-10T23:20:23.816Z" }, ] [[package]] @@ -505,27 +461,27 @@ wheels = [ [[package]] name = "typing-inspection" -version = "0.4.2" +version = "0.4.4" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" } +sdist = { url = "https://files.pythonhosted.org/packages/a3/26/b09b8010994eccc3c09092e6b34058f36a460eea2d4c3e8b910c695975a0/typing_inspection-0.4.4.tar.gz", hash = "sha256:547274fa6b0a561ccf549cc9524b999a578e737d015d8709d021f9d0d13bea47", size = 76928, upload-time = "2026-08-12T12:37:25.997Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" }, + { url = "https://files.pythonhosted.org/packages/67/81/4add07e5172b7ac40d8ed5ff580409a7801a4fe26d529bdd915401dabfbe/typing_inspection-0.4.4-py3-none-any.whl", hash = "sha256:65b8397ba37ccbce054456aaccddfc91e6e3083c92824df348d96ca832f3f147", size = 14750, upload-time = "2026-08-12T12:37:24.648Z" }, ] [[package]] name = "uvicorn" -version = "0.52.0" +version = "0.52.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "click" }, { name = "h11" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/05/c8/2d307868453a4bca6e64fa3581d122ae0748a0869c53f159339def179c7c/uvicorn-0.52.0.tar.gz", hash = "sha256:ca8876ad6c1983f394157c168b39d52f6dd56dabf5602fa0982751cffc2293ae", size = 97504, upload-time = "2026-07-29T08:45:34.065Z" } +sdist = { url = "https://files.pythonhosted.org/packages/03/18/ccce41535dee1be77735592bd19965f3972c82e07ee703d324709496b716/uvicorn-0.52.1.tar.gz", hash = "sha256:112ec661814189acbccd3f7b86460147cc065fc92c0821afa78918780e4354dd", size = 100571, upload-time = "2026-08-01T18:19:30.732Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/39/e6/b5c0630ace9757232aec07112be8146b812787db52141ff9d50674aa7634/uvicorn-0.52.0-py3-none-any.whl", hash = "sha256:3d887809810b89ed33501bcf0a9aba469b06ecd608158efce04bd6b48d8c9b08", size = 79058, upload-time = "2026-07-29T08:45:32.492Z" }, + { url = "https://files.pythonhosted.org/packages/c7/d5/68e6e9bca63c0badf67002890a46d3784c958de45b65e1275ec583ca1f06/uvicorn-0.52.1-py3-none-any.whl", hash = "sha256:e4403f9d93188cf9d1088e9f40e3acd12630e2df8675316704379a7fc20fff6a", size = 79859, upload-time = "2026-08-01T18:19:29.294Z" }, ] [[package]] From 01fe60ae9ef967228103fba103a075f586aac2e3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C6=B0u=20Quang=20V=C5=A9?= Date: Wed, 12 Aug 2026 23:56:12 +0700 Subject: [PATCH 278/291] Update the `Build and Push Docker Image` workflow --- .github/workflows/docker.yaml | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/.github/workflows/docker.yaml b/.github/workflows/docker.yaml index 38ec80e..83cd36d 100644 --- a/.github/workflows/docker.yaml +++ b/.github/workflows/docker.yaml @@ -16,7 +16,6 @@ on: env: REGISTRY: ghcr.io - IMAGE_NAME: ${{ github.repository }} permissions: contents: read @@ -44,10 +43,11 @@ jobs: with: persist-credentials: false - - name: Prepare platform pair + - name: Prepare platform pair and image name run: | platform=${{ matrix.platform }} echo "PLATFORM_PAIR=${platform//\//-}" >> "$GITHUB_ENV" + echo "IMAGE_NAME=${GITHUB_REPOSITORY,,}" >> "$GITHUB_ENV" - name: Set up Docker Buildx uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0 @@ -95,6 +95,9 @@ jobs: runs-on: ubuntu-26.04 steps: + - name: Prepare image name + run: echo "IMAGE_NAME=${GITHUB_REPOSITORY,,}" >> "$GITHUB_ENV" + - name: Download digests uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: From 40225eb2f480926601b4700b08270ef5870de587 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C6=B0u=20Quang=20V=C5=A9?= Date: Thu, 13 Aug 2026 09:31:59 +0700 Subject: [PATCH 279/291] Update the Gemini Web API checkpoint and minor optimize workflows --- .github/workflows/{lint.yaml => ci.yaml} | 13 ++++++-- .github/workflows/track.yml | 6 ++++ uv.lock | 40 ++++++++++++------------ 3 files changed, 36 insertions(+), 23 deletions(-) rename .github/workflows/{lint.yaml => ci.yaml} (74%) diff --git a/.github/workflows/lint.yaml b/.github/workflows/ci.yaml similarity index 74% rename from .github/workflows/lint.yaml rename to .github/workflows/ci.yaml index 557955a..166afca 100644 --- a/.github/workflows/lint.yaml +++ b/.github/workflows/ci.yaml @@ -1,4 +1,4 @@ -name: Lint and Type Check +name: CI on: push: &ci_trigger @@ -8,19 +8,26 @@ on: - "**.py" - "pyproject.toml" - "uv.lock" - - ".github/workflows/lint.yaml" + - ".github/workflows/ci.yaml" pull_request: *ci_trigger permissions: contents: read +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + jobs: - lint: + static-checks: runs-on: ubuntu-latest + name: "Static Checks (Synced with Local Environment)" steps: - name: Checkout repository uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false - name: Install uv uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0 diff --git a/.github/workflows/track.yml b/.github/workflows/track.yml index 440e561..eee1ccc 100644 --- a/.github/workflows/track.yml +++ b/.github/workflows/track.yml @@ -5,6 +5,10 @@ on: - cron: "0 0 * * *" workflow_dispatch: +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + jobs: update-dep: runs-on: ubuntu-latest @@ -15,6 +19,8 @@ jobs: steps: - name: Checkout repository uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false - name: Install uv uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0 diff --git a/uv.lock b/uv.lock index d14ed2a..ad569b5 100644 --- a/uv.lock +++ b/uv.lock @@ -180,7 +180,7 @@ dev = [{ name = "gemini-fastapi", extras = ["dev"] }] [[package]] name = "gemini-webapi" version = "0.0.post259" -source = { git = "https://github.com/luuquangvu/Gemini-API.git?rev=enable-guest-mode#2942759fe3e324ca8e06e2bbaac588623640907c" } +source = { git = "https://github.com/luuquangvu/Gemini-API.git?rev=enable-guest-mode#1698ec212ddec202a950e9d9ed15f09f3f365c76" } dependencies = [ { name = "curl-cffi" }, { name = "loguru" }, @@ -427,27 +427,27 @@ wheels = [ [[package]] name = "ty" -version = "0.0.70" +version = "0.0.71" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/58/ed/38a8ab52f1d7c3ed701442a31b23ba774cbc5d6909f2c00da9e1f3c590f9/ty-0.0.70.tar.gz", hash = "sha256:a01bebc128b4081c16002965d906fccb21323d69bb709b9108c1f2406bcffced", size = 6601156, upload-time = "2026-08-10T23:20:26.268Z" } +sdist = { url = "https://files.pythonhosted.org/packages/dd/e2/f6e716371b5913a31190db1ad250ac2b5c68b3ca2db71afeba3f98f5fe50/ty-0.0.71.tar.gz", hash = "sha256:c2a24f2745294946c27cef8cc012b84fb2db5405ecefddbe845be4162833da01", size = 6624721, upload-time = "2026-08-13T00:39:31.035Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/3d/c5/ddd6cc5657fd3da85591b264f4dabb1ce5e5b535e73fd5174991c294978b/ty-0.0.70-py3-none-linux_armv6l.whl", hash = "sha256:4fb2d2e55e2160c07152361be2e1a26fdd4f6261055731317d5972daf1749935", size = 12537215, upload-time = "2026-08-10T23:19:43.512Z" }, - { url = "https://files.pythonhosted.org/packages/8b/8a/acc9b34331cde81e0d63cda92d4db9e4042def5b1efa7d47431ff1d30d17/ty-0.0.70-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:9b04d4c21cb029501c05598ca21e9c0829c0b2e35a7ceba1e5b78014c1e8104d", size = 12149070, upload-time = "2026-08-10T23:19:46.369Z" }, - { url = "https://files.pythonhosted.org/packages/fd/cd/51ac2708f4d077058a0bfeefa40d630883c5ec7a82fd2c15c86519140235/ty-0.0.70-py3-none-macosx_11_0_arm64.whl", hash = "sha256:c605ebf2643f5e64ec4bcb269640a1aa85966d29fa888fb746039c28570369b2", size = 11625729, upload-time = "2026-08-10T23:19:48.535Z" }, - { url = "https://files.pythonhosted.org/packages/b7/99/afc4fe7e630100dc782ff0cdc8c59c01acfb05299551ff0ef49c93814320/ty-0.0.70-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0c38ea76e12909c29e18fcd295ff223b63f3701c1d1c34db2cdc9736340b9de2", size = 12204810, upload-time = "2026-08-10T23:19:51.845Z" }, - { url = "https://files.pythonhosted.org/packages/ca/38/8cabc3c8ad4c3a02e585ecff12a71ff8e5881f8a00ee71745fd765201da5/ty-0.0.70-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c1ee6bf4edaabf7bd0307f0c0f9ddc2204df0390820fe89f6a9de65f07722aba", size = 12308114, upload-time = "2026-08-10T23:19:54.229Z" }, - { url = "https://files.pythonhosted.org/packages/44/7e/bb4e552ecd68bb490bae9368ef323e3d0c79ef85a811857139a2d0f59ddb/ty-0.0.70-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5eef3e11d7d6b800ef66da0cce8ea24f8be804d1ebf359683426cfe848729bf8", size = 13072240, upload-time = "2026-08-10T23:19:56.872Z" }, - { url = "https://files.pythonhosted.org/packages/f6/c4/1861e1d554e5b6e0d11b3a9f36d40c372253f44a3c4e56ad4bf840f2801e/ty-0.0.70-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a95e4ae7c2599197c9d89652e49ca344ab224f7d12376e6ad8beb0587e8ff83f", size = 13497678, upload-time = "2026-08-10T23:19:59.191Z" }, - { url = "https://files.pythonhosted.org/packages/77/22/3b22442133e8f641485e59e463a070a31184db0d6456851f871db8f59763/ty-0.0.70-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:06aca758d1e0016c0a1f57fe9d8de7a21ff83f692306f747a0d97f32de24e27f", size = 13232510, upload-time = "2026-08-10T23:20:01.456Z" }, - { url = "https://files.pythonhosted.org/packages/84/b8/911f1e6885b5485b6e1d29aaab19ce5e6deeb3e931b6ad82296da4f22051/ty-0.0.70-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d81825524f1b57ecbcb5fce7d61fb159cb4837a6167a4569309c9fa7fc15a77d", size = 12817128, upload-time = "2026-08-10T23:20:04.275Z" }, - { url = "https://files.pythonhosted.org/packages/e6/a6/9affc3ca11c32d75b348a66144ab83335fa7fa7100b1581356725e15a668/ty-0.0.70-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:3287dfb09f7320ef28f114f5f9aae5f697b7b1a6ee37fb2a4a3be94481c1b4f8", size = 13089208, upload-time = "2026-08-10T23:20:06.574Z" }, - { url = "https://files.pythonhosted.org/packages/f0/cb/4776108ea08ec4013b71375b101be9c8a632967cd10f24abbdd4664d66f4/ty-0.0.70-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:9fb1877f6401cdaac4db46c5bf762f327482ba5f891420ea462ebd15d0de4185", size = 12148890, upload-time = "2026-08-10T23:20:08.827Z" }, - { url = "https://files.pythonhosted.org/packages/c9/d9/814e71f698d9231ef6412bd2c1499d81c6f86f66c2764e952c991e2d6da5/ty-0.0.70-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:e78f4997dbb0db2d2270210eb3694466206e5b3a11985e24148770e702158a25", size = 12327084, upload-time = "2026-08-10T23:20:11.023Z" }, - { url = "https://files.pythonhosted.org/packages/3b/69/9f34bba534c5d1ace391d300ead4f1971f4348c5459e66dc466cfd60661c/ty-0.0.70-py3-none-musllinux_1_2_i686.whl", hash = "sha256:cf758d3b2dad910c9b1d22d2d62fea894b5bc9acd3c00365e8a73ace6090a452", size = 12604372, upload-time = "2026-08-10T23:20:13.366Z" }, - { url = "https://files.pythonhosted.org/packages/fe/ff/abb34674517b29a8e2489a39c4e01930fcf6aab993a7d6b33aa97f119117/ty-0.0.70-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:0d338761617279a4fb6a83e7fad7e21126394637b8c45e13e196b2bb675f39b2", size = 12917800, upload-time = "2026-08-10T23:20:15.841Z" }, - { url = "https://files.pythonhosted.org/packages/a4/1a/f8289572f4fad5cb16c883b94638548166bc78cf3eb569f0cd9199eb10a0/ty-0.0.70-py3-none-win32.whl", hash = "sha256:a45642cf09dde91f0a3ce9b9e6fffda9779ff69d73f7347c18acf4fb45007c07", size = 11921482, upload-time = "2026-08-10T23:20:18.244Z" }, - { url = "https://files.pythonhosted.org/packages/17/ae/8739d7618b4670c3ee4f52d641d53be87928bd121d8bda9c0e3450500b75/ty-0.0.70-py3-none-win_amd64.whl", hash = "sha256:33e7941a926cf39b82553911a59a6ed68ec98c3d3d5a415df633f4d1cd051e6c", size = 12986994, upload-time = "2026-08-10T23:20:20.53Z" }, - { url = "https://files.pythonhosted.org/packages/f1/44/2bc3301ba4356ad8866daac9f8cfb3687953e52d076ea204f113b9304c42/ty-0.0.70-py3-none-win_arm64.whl", hash = "sha256:0d380f735d52b1d4b773193f8f5c58c065725eab1ebb0008d38a7b830de14f47", size = 12301082, upload-time = "2026-08-10T23:20:23.816Z" }, + { url = "https://files.pythonhosted.org/packages/4a/e4/8d6d17827c5335d0efe54241fb54fed3cb6ca2d2eca62f3f2382be196b91/ty-0.0.71-py3-none-linux_armv6l.whl", hash = "sha256:a309c9a35e69f45d7053e9205c7fe2295fac09e07e3a2fdb791524f30440a9cc", size = 12576435, upload-time = "2026-08-13T00:38:50.617Z" }, + { url = "https://files.pythonhosted.org/packages/d5/cb/d4c48832ee3d162abc6c834ba62242ec27630cb93014a0e1be82e86c3ee3/ty-0.0.71-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:2d42e9ae4b754ce1f34dd1b545f1cdcfc8e704d7460b584c898be156f048828f", size = 12177806, upload-time = "2026-08-13T00:38:53.068Z" }, + { url = "https://files.pythonhosted.org/packages/f0/97/e04782c9eaa1a0b634830f61b0e18cd1b3415332143d45a28882802c8ea1/ty-0.0.71-py3-none-macosx_11_0_arm64.whl", hash = "sha256:4d0b1f2002adc03f3a53aeb70b5cafcea634bb48726d82a307b0f15580d2f74b", size = 12015265, upload-time = "2026-08-13T00:38:55.209Z" }, + { url = "https://files.pythonhosted.org/packages/4f/5c/d716b9049c11a7b85b3b74ec3547d2dbab9a261347a6558420dcae37083d/ty-0.0.71-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:165e6086363f5c149ae4acacfbc36eea0093552a60f4153ef48321e4a3a15c4c", size = 12119608, upload-time = "2026-08-13T00:38:57.336Z" }, + { url = "https://files.pythonhosted.org/packages/b8/78/560ce2d874467d50605b7783e19e45b571fea7a89f664bd0ccdf252adf8a/ty-0.0.71-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:067e16f80855afbda024e7ff6fa5ed3ada5290c403badc63800f0534bd617c04", size = 12353762, upload-time = "2026-08-13T00:38:59.729Z" }, + { url = "https://files.pythonhosted.org/packages/a3/7b/f6a22c0bf2c0dbfe20b7ce90b51bc09efa9a9e245f1c35e160e39ed5dea9/ty-0.0.71-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:416149a550bcb678e619b4e2cd1cca9066d28edc52df76ad9d3640b36e6b5bf1", size = 13088120, upload-time = "2026-08-13T00:39:02.34Z" }, + { url = "https://files.pythonhosted.org/packages/a3/95/6f5382781f2e4fa933296d303376c61b8b3475d58ed60125c54de665fd21/ty-0.0.71-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:39e04c41e6d0e74f73cf8b3247d4dde3642ec38aeda7f4674110155b3da416a5", size = 13545140, upload-time = "2026-08-13T00:39:04.655Z" }, + { url = "https://files.pythonhosted.org/packages/6d/e7/8f0ad7b6c4804f8682e08fb161eaf710dd1765006b1b6df7c657b9707c95/ty-0.0.71-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7fb696bdea4a3554a0d0a7bbb36e4c5f508acb2bbad435e91c7fc812c1de6bfb", size = 13266730, upload-time = "2026-08-13T00:39:06.858Z" }, + { url = "https://files.pythonhosted.org/packages/ac/06/b83158fdf1473c2486fba0de337a963e9cc21317ccaa3e54ab003d419737/ty-0.0.71-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e3ec4ca9d4ba3e11ecc282c3d50d0730a2e181da7142734219d9f213fcbaaa00", size = 12673786, upload-time = "2026-08-13T00:39:09.281Z" }, + { url = "https://files.pythonhosted.org/packages/7c/9a/10f37c0550277722ac1d2c8096e492e0f3fc1aa2e587082439dd066fdb8d/ty-0.0.71-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:48a231253b32639ff4b19f74e476bacdba0150182603011d3792d1f1a335b932", size = 13140294, upload-time = "2026-08-13T00:39:11.659Z" }, + { url = "https://files.pythonhosted.org/packages/3b/e6/5710de1da7eb8aa755d289aa25316691e545b72b4ee2ab14172612eec1c9/ty-0.0.71-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:1a082f57d1fcbe209afdcacff37870defd4cea15ce69e2c6c652a9208462722f", size = 12171216, upload-time = "2026-08-13T00:39:14.382Z" }, + { url = "https://files.pythonhosted.org/packages/80/c5/8d9113e3cf0d4c6c0a9c9e088cee93e41facb278026bea6f6e12533b09dd/ty-0.0.71-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:cbe1c962f6c9e8964180cd171cc6ad35d687f74d51c07f60b670d3f1c5d58ffe", size = 12360626, upload-time = "2026-08-13T00:39:16.747Z" }, + { url = "https://files.pythonhosted.org/packages/7d/a0/0e167507c4c863814d3ea7602bde958baefd03346bd38bae3a430dff1ea9/ty-0.0.71-py3-none-musllinux_1_2_i686.whl", hash = "sha256:25f641b988916b3975e50b2a59cbc5179f2a466d181f207f3032e1e6bc617a88", size = 12637309, upload-time = "2026-08-13T00:39:19.14Z" }, + { url = "https://files.pythonhosted.org/packages/1d/c6/ff6719b91e4916985e9a92f9bddb10d9fe3cb3bdf5abf0f9019a67aebe21/ty-0.0.71-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:c5fe916b6e5b152ef4f583324e1efd770ac3316894776dc1b57c354cb767b8ee", size = 12937996, upload-time = "2026-08-13T00:39:21.533Z" }, + { url = "https://files.pythonhosted.org/packages/ee/3e/21a0873da8f1ece28e166fbbaf696cd48c55fd7cd203dba0266a1fe05ceb/ty-0.0.71-py3-none-win32.whl", hash = "sha256:a273fe0dcc453e94cf2e1955076efec8b739e6e1a890862de29d3c5a1c9ddaff", size = 11953321, upload-time = "2026-08-13T00:39:23.804Z" }, + { url = "https://files.pythonhosted.org/packages/69/fa/ce6614c748f7abfc546d12983d4bad1085116b6458b8c84d9372eb713f08/ty-0.0.71-py3-none-win_amd64.whl", hash = "sha256:65f5f980551ed79f68a0f6c0f2fb71b39a8d54ed0625d2529b01f6c919db72c5", size = 12570891, upload-time = "2026-08-13T00:39:26.346Z" }, + { url = "https://files.pythonhosted.org/packages/77/7f/0fb022535c66fd96e7dd2a1f9c14e3a0e39544a4c3f35a451e8835562730/ty-0.0.71-py3-none-win_arm64.whl", hash = "sha256:6d5552078b9934d359bd5f381dbcb160f1fc5addfca59a960021adca38a73741", size = 12338716, upload-time = "2026-08-13T00:39:28.987Z" }, ] [[package]] From d2c023ee5aa827c78e89077cd08056ec3e9fedf2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C6=B0u=20Quang=20V=C5=A9?= Date: Thu, 13 Aug 2026 15:18:07 +0700 Subject: [PATCH 280/291] Integrate temporary chat mode with metadata fallback logic from the upstream --- README.md | 56 +++++++++- README.zh.md | 44 ++++++++ app/server/chat.py | 234 +++++++++++++++++++++++++++++++++++++---- app/services/client.py | 8 ++ app/services/lmdb.py | 9 ++ app/utils/config.py | 17 +++ config/config.yaml | 4 + 7 files changed, 352 insertions(+), 20 deletions(-) diff --git a/README.md b/README.md index f425323..4c7d220 100644 --- a/README.md +++ b/README.md @@ -46,7 +46,7 @@ cd Gemini-FastAPI pip install -e . ``` -### Configuration +### Basic Configuration Edit `config/config.yaml` and provide at least one credential pair: @@ -61,7 +61,7 @@ gemini: ``` > [!NOTE] -> For details, refer to the [Configuration](#configuration-1) section below. +> For details, refer to the [Configuration](#configuration) section below. ### Running the Server @@ -214,6 +214,8 @@ To use Gemini-FastAPI, you need to extract your Gemini session cookies: > > While active chat turns may work temporarily without it, any transient error, TLS session restart, or server reboot can cause Google to expire the conversation metadata. If this setting is disabled, the model will **completely lose the context of your multi-turn conversation**, making old threads unreachable even if they are stored in your local LMDB. + + > [!TIP] > For detailed instructions, refer to the [HanaokaYuzu/Gemini-API authentication guide](https://github.com/HanaokaYuzu/Gemini-API?tab=readme-ov-file#authentication). @@ -238,6 +240,56 @@ gemini: impersonate: null # Use library default ``` +### Chat Session Mode + +You can control whether requests use normal Google chats or Google's temporary chat mode: + +```yaml +gemini: + chat_mode: "normal" # "normal" or "temporary" + max_chars_per_request: 1000000 +``` + +With `temporary`, conversations are not saved to the Google account. A temporary chat is still +continuable for as long as Google keeps the window open, so session reuse and conversation +storage work exactly as they do in normal mode. + +When a stored chat can no longer be continued - after changing `chat_mode`, or once Google has +closed a temporary window - the server falls back to replaying the full conversation history +into a fresh chat, so the context is rebuilt rather than lost. + +Google keeps at most one temporary window open per account and closes the previous one as soon +as a new conversation is created, so only the most recently opened temporary chat is still +continuable. The server tracks that chat per client and reuses **only** it; any older temporary +conversation is replayed in full into a fresh chat instead. There is no timeout to tune - the +rule follows Google's actual behaviour rather than guessing at an expiry. + +That tracking is deliberately in-memory, so it is also cleared whenever the client session +restarts - an `auto_close` after inactivity, a server restart, or a redeploy. After any of +those, no window can be vouched for and every stored temporary conversation is replayed rather +than reused. + +This applies **only** in temporary mode. A normal chat is kept by Google until you delete it, +so its metadata stays reusable indefinitely and across restarts. + +> [!WARNING] +> Google can close a temporary chat window at any time, without notice and mid-conversation. +> When that happens the reply may come back without the earlier context instead of raising an +> error, so the loss can be silent. The server replays the full history into a fresh chat when +> it can detect the chat is gone, but detection is not guaranteed. Prefer `normal` for long or +> context-sensitive conversations, and treat `temporary` as best-effort continuity. + +Because temporary chats accept a smaller payload, the server applies an additional 10% reduction +on top of the standard safety margin, so the effective input limit becomes 81% of +`max_chars_per_request` instead of 90%. Input exceeding the effective limit is still sent as a +`message.txt` attachment in both modes. + +Environment variable equivalent: + +```bash +export CONFIG_GEMINI__CHAT_MODE="temporary" +``` + ### Custom Models You can define custom models in `config/config.yaml` or via environment variables. diff --git a/README.zh.md b/README.zh.md index 27ecebc..a0a83a4 100644 --- a/README.zh.md +++ b/README.zh.md @@ -234,6 +234,50 @@ gemini: impersonate: null # 使用库默认值 ``` +### 会话模式 + +你可以控制请求使用普通的 Google 会话,还是 Google 的临时会话模式: + +```yaml +gemini: + chat_mode: "normal" # "normal"(普通)或 "temporary"(临时) + max_chars_per_request: 1000000 +``` + +设置为 `temporary` 时,对话不会保存到 Google 账号中。只要 Google 尚未关闭该临时窗口, +临时会话仍然可以继续对话,因此会话重用与会话存储的行为与普通模式完全一致。 + +当已存储的会话无法再被延续时——例如切换了 `chat_mode`,或 Google 已关闭该临时窗口—— +服务会回退到将完整对话历史重放到一个全新的会话中,从而重建上下文,而不是丢失上下文。 + +每个账号在 Google 侧最多只保留一个处于开启状态的临时窗口:一旦创建新的会话,上一个临时 +会话就会被关闭。因此只有最近一次开启的临时会话仍可继续对话。服务会按客户端记录该会话, +并且**只**重用它;任何更早的临时会话都会以完整历史重放到全新会话中。这里没有需要调节的 +超时时间——该规则直接依据 Google 的实际行为,而不是靠猜测过期时长。 + +该记录刻意只保存在内存中,因此只要客户端会话被重新初始化——例如因闲置触发 `auto_close`、 +服务重启或重新部署——它同样会被清空。发生上述情况后,服务无法再确认任何窗口仍然有效, +所有已存储的临时会话都会改为重放,而不是重用。 + +以上规则**仅**在临时模式下生效:普通会话在用户手动删除之前会一直由 Google 保留, +因此其元数据可以长期重用,并且不受重启影响。 + +> [!WARNING] +> Google 可能在任意时刻、且不作任何提示地关闭临时会话窗口,包括在对话进行到一半时。 +> 此时模型可能直接返回不含既有上下文的回复,而不会抛出错误,因此上下文丢失可能是静默的。 +> 只要服务能够识别出该会话已失效,就会将完整历史重放到新会话中,但这种识别并非总能成功。 +> 对于较长或对上下文较敏感的对话,建议使用 `normal`;`temporary` 的连续性应视为尽力而为。 + +由于临时会话可接受的负载更小,服务会在标准安全余量的基础上再收紧 10%, +因此有效输入上限为 `max_chars_per_request` 的 81%(而非 90%)。 +两种模式下,超出有效上限的输入仍会以 `message.txt` 附件的形式发送。 + +环境变量等价写法: + +```bash +export CONFIG_GEMINI__CHAT_MODE="temporary" +``` + ### 自定义模型 你可以在 `config/config.yaml` 中或通过环境变量定义自定义模型。 diff --git a/app/server/chat.py b/app/server/chat.py index daaaba6..50e7f9d 100644 --- a/app/server/chat.py +++ b/app/server/chat.py @@ -15,6 +15,7 @@ from gemini_webapi import ModelOutput from gemini_webapi.client import ChatSession from gemini_webapi.constants import Model +from gemini_webapi.exceptions import ModelInvalidError from gemini_webapi.types.image import GeneratedImage, Image from gemini_webapi.types.video import GeneratedMedia, GeneratedVideo from loguru import logger @@ -31,6 +32,7 @@ ChatCompletionRequest, ChatCompletionResponse, CompletionUsage, + ConversationInStore, FunctionCall, FunctionCallOutput, FunctionTool, @@ -62,6 +64,7 @@ ) from app.services import GeminiClientPool, GeminiClientWrapper, LMDBConversationStore from app.utils import g_config +from app.utils.config import ChatMode from app.utils.helper import ( STREAM_MASTER_RE, STREAM_TAIL_RE, @@ -83,6 +86,9 @@ ) MAX_CHARS_PER_REQUEST = int(g_config.gemini.max_chars_per_request * 0.9) +# Google's temporary chat mode accepts a smaller payload than a normal chat, so tighten +# the guardrail further on top of the standard 10% safety margin. +TEMPORARY_MAX_CHARS_PER_REQUEST = int(MAX_CHARS_PER_REQUEST * 0.9) router = APIRouter() _AVAILABLE_MODELS_CACHE: list[ModelData] | None = None @@ -315,13 +321,19 @@ def _create_chat_completion_standard_payload( def _persist_conversation( db: LMDBConversationStore, model_name: str, - client_id: str, + client: GeminiClientWrapper, metadata: list[str | None], messages: list[AppMessage], storage_output: str | None, tool_calls: list[AppToolCall] | None, ) -> str | None: """Unified logic to save conversation history to LMDB.""" + if _use_temporary_chat_mode(): + # This turn's conversation is now the last chat this client opened; any window it + # replaced has been closed by Google and must not be replayed again. Recorded before + # the store so a persistence failure cannot leave a closed window looking reusable. + client.latest_chat_cid = _cid_of(metadata) + try: current_assistant_message = AppMessage( role="assistant", @@ -331,8 +343,10 @@ def _persist_conversation( ) full_history = [*messages, current_assistant_message] + # A temporary chat stays continuable while its window is the live one, so its metadata + # is worth keeping and reusing just like a normal chat's. db.store( - client_id=client_id, + client_id=client.id, model=model_name, messages=full_history, metadata=metadata, @@ -722,15 +736,54 @@ async def _get_available_models(pool: GeminiClientPool) -> list[ModelData]: return await refresh_available_models_cache(pool) +def _cid_of(metadata: list[str | None] | None) -> str | None: + """Chat id from a metadata list, which stores it at index 0.""" + return metadata[0] if metadata else None + + +def _is_live_temporary_chat(conv: ConversationInStore, client: GeminiClientWrapper) -> bool: + """Whether a stored chat can still be the open temporary window on this client. + + Google closes the previous temporary conversation as soon as another one is created, so at + most one temporary window per client is alive at a time, and it can only be the last chat + the client opened. Replaying an older one makes Google start a fresh chat and answer + without the earlier context: a silent loss that raises no error, which is why this is + checked up front rather than detected after the fact. + + `latest_chat_cid` is only that - the last cid this client saw. Its kind is not verified, so + a match is a necessary condition rather than proof the window is temporary or still open. + It lives in memory and is cleared on every client (re)initialization, so an auto-close, + restart or redeploy invalidates every stored window: once the client that opened a window + is gone, there is nothing left to vouch for it. + """ + latest = client.latest_chat_cid + if not latest: + logger.debug(f"Client {client.id} has no chat on record; starting a fresh conversation.") + return False + + cid = _cid_of(conv.metadata) + if cid and cid == latest: + return True + + logger.debug( + f"Stored chat {cid!r} is not the latest ({latest!r}) on client {client.id}; a newer " + "conversation has closed it, so replaying the full history in a fresh conversation." + ) + return False + + async def _find_reusable_session( db: LMDBConversationStore, pool: GeminiClientPool, model: Model, messages: list[AppMessage], -) -> tuple[ChatSession | None, GeminiClientWrapper | None, list[AppMessage]]: + temporary: bool = False, +) -> tuple[ + ChatSession | None, GeminiClientWrapper | None, list[AppMessage], ConversationInStore | None +]: """Find an existing chat session matching the longest suitable history prefix.""" if len(messages) < 2: - return None, None, messages + return None, None, messages, None search_end = len(messages) while search_end >= 2: @@ -739,12 +792,18 @@ async def _find_reusable_session( try: if conv := db.find(model.model_name, search_history): client = await pool.acquire(conv.client_id) + # Checked after acquiring: acquire may restart a closed client, which clears + # the tracked cid and is exactly what invalidates a temporary window. + if temporary and not _is_live_temporary_chat(conv, client): + # Every prefix of one conversation carries the same cid, so if the + # longest match is not the live window, no shorter one will be either. + break session = client.start_chat(metadata=conv.metadata, model=model) remain = messages[search_end:] logger.debug( f"Match found at prefix length {search_end}/{len(messages)}. Client: {conv.client_id}" ) - return session, client, remain + return session, client, remain, conv except Exception as e: logger.warning( f"Error checking LMDB for reusable session at length {search_end}: {e}" @@ -753,7 +812,17 @@ async def _find_reusable_session( search_end -= 1 logger.debug(f"No reusable session found for {len(messages)} messages.") - return None, None, messages + return None, None, messages, None + + +def _use_temporary_chat_mode() -> bool: + """Whether requests should be sent through Google's temporary chat mode.""" + return g_config.gemini.chat_mode == ChatMode.TEMPORARY + + +def _effective_max_chars_per_request(temporary: bool) -> int: + """Return the payload guardrail for the active chat mode.""" + return TEMPORARY_MAX_CHARS_PER_REQUEST if temporary else MAX_CHARS_PER_REQUEST async def _send_with_split( @@ -761,19 +830,23 @@ async def _send_with_split( text: str, files: list[Any] | None = None, stream: bool = False, + temporary: bool = False, ) -> AsyncGenerator[ModelOutput] | ModelOutput: """Send text to Gemini with configured generation options, using an attachment if too long.""" - if len(text) <= MAX_CHARS_PER_REQUEST: + limit = _effective_max_chars_per_request(temporary) + if len(text) <= limit: try: if stream: return session.send_message_stream( text, files=files, + temporary=temporary, extended_thinking=g_config.gemini.extended_thinking, ) return await session.send_message( text, files=files, + temporary=temporary, extended_thinking=g_config.gemini.extended_thinking, ) except Exception as e: @@ -781,7 +854,7 @@ async def _send_with_split( raise logger.info( - f"Message length ({len(text)}) exceeds limit ({MAX_CHARS_PER_REQUEST}). Converting text to file attachment." + f"Message length ({len(text)}) exceeds limit ({limit}). Converting text to file attachment." ) file_obj = io.BytesIO(text.encode("utf-8")) file_obj.name = "message.txt" @@ -799,11 +872,13 @@ async def _send_with_split( return session.send_message_stream( instruction, files=final_files, + temporary=temporary, extended_thinking=g_config.gemini.extended_thinking, ) return await session.send_message( instruction, files=final_files, + temporary=temporary, extended_thinking=g_config.gemini.extended_thinking, ) except Exception as e: @@ -811,6 +886,101 @@ async def _send_with_split( raise +async def _restream( + first: ModelOutput, rest: AsyncGenerator[ModelOutput] +) -> AsyncGenerator[ModelOutput]: + """Re-emit an already-consumed first chunk, then delegate to the remainder.""" + yield first + async for chunk in rest: + yield chunk + + +async def _send_and_await_first_chunk( + session: ChatSession, + text: str, + *, + files: list[Any], + stream: bool, + temporary: bool, +) -> AsyncGenerator[ModelOutput] | ModelOutput: + """Send to Gemini, pulling the first streamed chunk so start-of-stream errors surface here. + + `send_message_stream` is an async generator function: calling it runs none of its body, so + without this the request would not reach Google until the caller iterates - by which point + the HTTP response has already been committed and a failure can no longer be recovered. + """ + output = await _send_with_split(session, text, files=files, stream=stream, temporary=temporary) + if not stream: + return output + + generator = cast(AsyncGenerator[ModelOutput], output) + try: + first = await anext(generator) + except StopAsyncIteration: + return generator # already exhausted, so iterating it again simply yields nothing + except BaseException: + await generator.aclose() + raise + return _restream(first, generator) + + +async def _send_with_internal_fallback( + *, + pool: GeminiClientPool, + db: LMDBConversationStore, + model: Model, + session: ChatSession, + client: GeminiClientWrapper, + current_input: str, + files: list[Any], + full_prepared_messages: list[AppMessage], + stored_conversation: ConversationInStore | None, + tmp_dir: Path, + stream: bool, + temporary: bool, +) -> tuple[AsyncGenerator[ModelOutput] | ModelOutput, ChatSession, GeminiClientWrapper]: + """Send the request, replaying the full history in a fresh chat if reused metadata is dead. + + Streaming is recovered as well as non-streaming: the first chunk is pulled here, which is + where Google reports a rejected chat, so the retry happens before any response is committed + to the client. The cost is that response headers wait for Google's first chunk. + """ + try: + output = await _send_and_await_first_chunk( + session, current_input, files=files, stream=stream, temporary=temporary + ) + return output, session, client + except ModelInvalidError: + if stored_conversation is None: + raise + + # Drop the dead metadata so the next request does not rediscover and re-fail on it. + try: + if db.evict(stored_conversation): + logger.info("Evicted stale conversation metadata after Google rejected it.") + except Exception as evict_exc: + logger.warning(f"Failed to evict stale conversation metadata: {evict_exc}") + + logger.warning( + "Metadata-backed chat reuse failed; retrying with internal history replay in a fresh chat." + ) + fallback_client = await pool.acquire() + fallback_session = fallback_client.start_chat(model=model) + fallback_input, fallback_files = await GeminiClientWrapper.process_conversation( + full_prepared_messages, tmp_dir + ) + # Keep the caller's streaming mode: the endpoints reject a ModelOutput when the client + # asked for a stream, so downgrading here would turn a recovery into a 502. + output = await _send_and_await_first_chunk( + fallback_session, + fallback_input, + files=list(fallback_files), + stream=stream, + temporary=temporary, + ) + return output, fallback_session, fallback_client + + class StreamingOutputFilter: """ Filter to suppress technical protocol markers, tool calls, and system hints from the stream. @@ -1177,7 +1347,7 @@ def make_chunk(delta_content: dict) -> str: _persist_conversation( db, model.model_name, - client_wrapper.id, + client_wrapper, session.metadata, messages, storage_output, @@ -1994,7 +2164,7 @@ async def _make_async_gen(item: ModelOutput) -> AsyncGenerator[ModelOutput]: _persist_conversation( db, model.model_name, - client_wrapper.id, + client_wrapper, session.metadata, messages, storage_output, @@ -2053,7 +2223,10 @@ async def create_chat_completion( extra_instr, ) - session, client, remain = await _find_reusable_session(db, pool, model, msgs) + use_temporary = _use_temporary_chat_mode() + session, client, remain, stored_conv = await _find_reusable_session( + db, pool, model, msgs, temporary=use_temporary + ) if session: if not remain: @@ -2095,8 +2268,19 @@ async def create_chat_completion( logger.debug( f"Client ID: {client.id}, Input length: {len(m_input)}, files count: {len(files)}" ) - resp_or_stream = await _send_with_split( - session, m_input, files=files, stream=bool(request.stream) + resp_or_stream, session, client = await _send_with_internal_fallback( + pool=pool, + db=db, + model=model, + session=session, + client=client, + current_input=m_input, + files=files, + full_prepared_messages=msgs, + stored_conversation=stored_conv, + tmp_dir=tmp_dir, + stream=bool(request.stream), + temporary=use_temporary, ) except Exception as e: logger.error(f"Gemini API error: {e}") @@ -2249,7 +2433,7 @@ async def create_chat_completion( _persist_conversation( db, model.model_name, - client.id, + client, session.metadata, msgs, storage_output, @@ -2309,7 +2493,10 @@ async def create_response( except ValueError as exc: raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc - session, client, remain = await _find_reusable_session(db, pool, model, messages) + use_temporary = _use_temporary_chat_mode() + session, client, remain, stored_conv = await _find_reusable_session( + db, pool, model, messages, temporary=use_temporary + ) if session: msgs = _prepare_messages_for_model( remain, @@ -2348,8 +2535,19 @@ async def create_response( logger.debug( f"Client ID: {client.id}, Input length: {len(m_input)}, files count: {len(files)}" ) - resp_or_stream = await _send_with_split( - session, m_input, files=files, stream=bool(request.stream) + resp_or_stream, session, client = await _send_with_internal_fallback( + pool=pool, + db=db, + model=model, + session=session, + client=client, + current_input=m_input, + files=files, + full_prepared_messages=messages, + stored_conversation=stored_conv, + tmp_dir=tmp_dir, + stream=bool(request.stream), + temporary=use_temporary, ) except Exception as e: logger.error(f"Gemini API error: {e}") @@ -2519,7 +2717,7 @@ async def create_response( _persist_conversation( db, model.model_name, - client.id, + client, session.metadata, messages, storage_output, diff --git a/app/services/client.py b/app/services/client.py index 9984c46..2df704a 100644 --- a/app/services/client.py +++ b/app/services/client.py @@ -24,6 +24,13 @@ def __init__(self, client_id: str, **kwargs): super().__init__(**kwargs) self.id = client_id self._initialized = False + # Chat id of the last conversation this client opened. Its kind is not verified here - + # it is simply the most recent cid we saw, whether that conversation is persistent or + # temporary. Callers use it in temporary mode, where Google closes the open window as + # soon as another conversation is created, making this the only cid that can still be + # continuable. Deliberately in-memory and cleared on every (re)initialization: after an + # auto-close, restart or redeploy we can no longer vouch for any window. + self.latest_chat_cid: str | None = None async def init(self, *args: Any, **kwargs: Any) -> None: """ @@ -44,6 +51,7 @@ async def init(self, *args: Any, **kwargs: Any) -> None: try: await super().init(**init_kwargs) self._initialized = True + self.latest_chat_cid = None except Exception: self._initialized = False logger.exception(f"Failed to initialize GeminiClient {self.id}") diff --git a/app/services/lmdb.py b/app/services/lmdb.py index afe2a1d..080075d 100644 --- a/app/services/lmdb.py +++ b/app/services/lmdb.py @@ -398,6 +398,15 @@ def _find_by_message_list( return conv return None + def evict(self, conv: ConversationInStore) -> bool: + """Delete a stored conversation given the record itself. + + Used to drop metadata that Google has already invalidated, so the next request + does not rediscover the same dead session and fail again. + """ + key = _hash_conversation(conv.client_id, conv.model, conv.messages) + return self.delete(key) is not None + def exists(self, key: str) -> bool: """Check if a key exists in the store.""" try: diff --git a/app/utils/config.py b/app/utils/config.py index c2effde..6cffa54 100644 --- a/app/utils/config.py +++ b/app/utils/config.py @@ -1,6 +1,7 @@ import ast import os import sys +from enum import StrEnum from typing import Any, Literal, cast, get_args import orjson @@ -93,6 +94,13 @@ def _parse_json_string(cls, v: Any) -> Any: return v +class ChatMode(StrEnum): + """Chat mode options for Gemini conversation handling.""" + + NORMAL = "normal" + TEMPORARY = "temporary" + + class GeminiConfig(BaseModel): """Gemini API configuration, including session behavior and generation options.""" @@ -128,6 +136,15 @@ class GeminiConfig(BaseModel): ge=1, description="Maximum characters Gemini Web can accept per request", ) + chat_mode: ChatMode = Field( + default=ChatMode.NORMAL, + description=( + "Chat mode: 'normal' uses standard chats; 'temporary' sends with Google's temporary " + "mode (not saved to the account) and applies a tighter effective input limit. " + "Warning: Google may close a temporary window at any time mid-conversation, and the " + "reply can then come back without the earlier context instead of erroring" + ), + ) @field_validator("models", mode="before") @classmethod diff --git a/config/config.yaml b/config/config.yaml index 0f9d4a4..15bd99c 100644 --- a/config/config.yaml +++ b/config/config.yaml @@ -32,6 +32,10 @@ gemini: verbose: true # Enable verbose logging for Gemini requests extended_thinking: false # Enable Gemini extended thinking mode for message generation max_chars_per_request: 1000000 # Maximum characters Gemini Web accepts per request. Non-pro users might have a lower limit + # "normal" uses standard Google chats; "temporary" uses Google's temporary mode (not saved to the account) with a tighter input limit. + # WARNING: Google may close a temporary window at any time mid-conversation. The reply can then come back without the earlier + # context instead of erroring, so the loss may be silent. Prefer "normal" for long or context-sensitive conversations. + chat_mode: "normal" model_strategy: "append" # Strategy: 'append' (default + custom) or 'overwrite' (custom only) models: [] From 71c0db20c5a86e651670887517fc89999a70b502 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C6=B0u=20Quang=20V=C5=A9?= Date: Thu, 13 Aug 2026 21:47:42 +0700 Subject: [PATCH 281/291] Update the logic to gracefully handle temporary or guest mode and switch entirely to using dynamic models --- README.md | 67 +++++++----- README.zh.md | 58 +++++----- app/models/core.py | 7 ++ app/server/chat.py | 240 ++++++++++++++++++++++++++--------------- app/services/client.py | 65 +++++++++-- app/services/lmdb.py | 4 + app/services/pool.py | 42 ++++++-- app/utils/config.py | 124 +-------------------- config/config.yaml | 2 - uv.lock | 4 +- 10 files changed, 338 insertions(+), 275 deletions(-) diff --git a/README.md b/README.md index 4c7d220..c6e6dfc 100644 --- a/README.md +++ b/README.md @@ -269,8 +269,32 @@ restarts - an `auto_close` after inactivity, a server restart, or a redeploy. Af those, no window can be vouched for and every stored temporary conversation is replayed rather than reused. -This applies **only** in temporary mode. A normal chat is kept by Google until you delete it, -so its metadata stays reusable indefinitely and across restarts. +The same rule applies to a client running as a guest, regardless of `chat_mode`. When every +cookie group fails, or an authenticated session is rejected mid-flight because its cookies +expired, the client keeps serving text prompts without an account - and a guest chat is never +written to any history, so it behaves exactly like a temporary one. Each stored conversation +records the session window it belongs to, so chats opened while authenticated are never replayed +into a guest session and chats opened as a guest are never replayed once cookies are restored; +either crossing falls back to a full history replay in a fresh chat. + +A guest session keeps the service up rather than taking it down, and requests degrade instead of +failing: + +- The pool prefers authenticated clients, so a downgraded one only receives traffic when no + authenticated client is left. +- Requests that need a file upload - attachments, or input long enough to be sent as + `message.txt` - are routed to an authenticated client, including when a stored session would + otherwise pin them to a guest one. If none exists, the request fails with an explicit message + instead of Google's `Permission denied`. +- Google gives a guest no model choice, so the requested model is replaced by the default one it + is allowed to use, logged as a warning. `/v1/models` advertises only models a client can + actually serve. + +`/health` reports a guest client as unhealthy - refresh its cookies to restore full capability. + +Otherwise this applies **only** in temporary mode. A normal chat opened by an authenticated +client is kept by Google until you delete it, so its metadata stays reusable indefinitely and +across restarts. > [!WARNING] > Google can close a temporary chat window at any time, without notice and mid-conversation. @@ -290,31 +314,26 @@ Environment variable equivalent: export CONFIG_GEMINI__CHAT_MODE="temporary" ``` -### Custom Models +### Models -You can define custom models in `config/config.yaml` or via environment variables. +Models are discovered from Google at startup - there is nothing to configure. Each client reads +the models its own account may use and builds the request headers for them at runtime, so a newly +launched model is served as soon as Google offers it. `GET /v1/models` lists what the running +clients can actually serve. -#### YAML Configuration +Requests may name a model by its canonical name (currently `gemini-pro`, `gemini-flash` and +`gemini-flash-lite`), by an alias or display name (`pro`, `Flash Lite`) or by its internal id; +all forms resolve to the same conversation history. Call `GET /v1/models` for the list your own +accounts see - the names come from Google, not from this project. Only discovered models are +served: a name no client offers is rejected with `400` rather than quietly answered by a +different model. -```yaml -gemini: - model_strategy: "append" # "append" (default + custom) or "overwrite" (custom only) - models: - - model_name: "xxx" - model_header: - x-goog-ext-525001261-jspb: '[1,null,null,null,"fbb127bbb056c959",null,null,0,[4,5,6,8],null,null,1,null,null,1,1,"EA3C5672-E422-4A5F-BE26-B5B57D3B9AC3"]' - x-goog-ext-73010989-jspb: "[0]" - x-goog-ext-73010990-jspb: "[0,0,0]" -``` - -#### Environment Variables - -You can supply models as a JSON string or list structure via `CONFIG_GEMINI__MODELS`. This provides a flexible way to override settings via the shell or in automated environments (e.g. Docker) without modifying the configuration file. - -```bash -export CONFIG_GEMINI__MODEL_STRATEGY="overwrite" -export CONFIG_GEMINI__MODELS='[{"model_name": "xxx", "model_header": {"x-goog-ext-525001261-jspb": "[1,null,null,null,\"fbb127bbb056c959\",null,null,0,\[4,5,6,8\],null,null,1,null,null,1,1,\"EA3C5672-E422-4A5F-BE26-B5B57D3B9AC3\"]", "x-goog-ext-73010989-jspb": "[0]", "x-goog-ext-73010990-jspb": "[0,0,0]"}}]' -``` +> [!NOTE] +> The `models` and `model_strategy` settings are gone, as is any use of the library's removed +> static `Model` name lookups. They existed to hand-write model headers while the library lagged behind +> new releases, which dynamic discovery has made unnecessary - and hardcoded headers now risk +> pinning requests to a stale model. Both keys are simply ignored if left in a config file or +> environment. ## Acknowledgments diff --git a/README.zh.md b/README.zh.md index a0a83a4..ff13f4e 100644 --- a/README.zh.md +++ b/README.zh.md @@ -259,8 +259,27 @@ gemini: 服务重启或重新部署——它同样会被清空。发生上述情况后,服务无法再确认任何窗口仍然有效, 所有已存储的临时会话都会改为重放,而不是重用。 -以上规则**仅**在临时模式下生效:普通会话在用户手动删除之前会一直由 Google 保留, -因此其元数据可以长期重用,并且不受重启影响。 +同一规则也适用于以访客身份运行的客户端,且与 `chat_mode` 无关。当所有 Cookie 分组都失败, +或已认证的会话因 Cookie 过期而在使用过程中被拒绝时,客户端仍会以无账号状态继续处理纯文本 +请求——而访客会话的对话不会写入任何历史记录,因此其行为与临时会话完全一致。每条已存储的 +对话都会记录其所属的会话窗口,因此已认证状态下开启的会话不会被重放到访客会话中,访客状态 +下开启的会话也不会在 Cookie 恢复后被重放;无论哪个方向的跨越,都会回退为在全新会话中重放 +完整历史。 + +访客会话会维持服务不中断,而不是让服务失效;相关请求会降级处理,而不是直接失败: + +- 客户端池优先选择已认证的客户端,被降级为访客的客户端只有在没有任何已认证客户端可用时才会 + 承接流量。 +- 需要上传文件的请求——包括附件,以及需要以 `message.txt` 形式发送的超长输入——会被路由到 + 已认证的客户端,即使已存储的会话原本会将其绑定到访客客户端。若没有可用的已认证客户端, + 请求会返回明确的错误说明,而不是 Google 的 `Permission denied`。 +- Google 不为访客提供模型选择,因此所请求的模型会被替换为访客被允许使用的默认模型,并记录 + 一条警告。`/v1/models` 只会公布客户端确实能够提供服务的模型。 + +`/health` 会将访客客户端报告为不健康——请刷新其 Cookie 以恢复完整能力。 + +除此之外,以上规则**仅**在临时模式下生效:由已认证客户端开启的普通会话在用户手动删除之前 +会一直由 Google 保留,因此其元数据可以长期重用,并且不受重启影响。 > [!WARNING] > Google 可能在任意时刻、且不作任何提示地关闭临时会话窗口,包括在对话进行到一半时。 @@ -278,31 +297,22 @@ gemini: export CONFIG_GEMINI__CHAT_MODE="temporary" ``` -### 自定义模型 +### 模型 -你可以在 `config/config.yaml` 中或通过环境变量定义自定义模型。 +模型在启动时从 Google 动态获取,无需任何配置。每个客户端会读取自己账号可用的模型,并在运行时 +构建对应的请求头,因此只要 Google 提供了新发布的模型,服务就能立即使用。`GET /v1/models` +列出的是运行中的客户端确实能够提供服务的模型。 -#### YAML 配置 +请求可以使用模型的规范名称(当前为 `gemini-pro`、`gemini-flash`、`gemini-flash-lite`)、别名或 +显示名称(`pro`、`Flash Lite`),也可以使用其内部 id;所有写法都会解析到同一份会话历史。模型 +名称来自 Google 而非本项目,可通过 `GET /v1/models` 查看你自己账号实际可用的列表。服务只提供 +动态获取到的模型——若没有任何客户端提供该模型,则返回 `400`,而不会悄悄改用其他模型作答。 -```yaml -gemini: - model_strategy: "append" # "append" (默认 + 自定义) 或 "overwrite" (仅限自定义) - models: - - model_name: "xxx" - model_header: - x-goog-ext-525001261-jspb: '[1,null,null,null,"fbb127bbb056c959",null,null,0,[4,5,6,8],null,null,1,null,null,1,1,"EA3C5672-E422-4A5F-BE26-B5B57D3B9AC3"]' - x-goog-ext-73010989-jspb: "[0]" - x-goog-ext-73010990-jspb: "[0,0,0]" -``` - -#### 环境变量 - -你可以通过 `CONFIG_GEMINI__MODELS` 以 JSON 字符串或列表结构的形式提供模型。这为通过 shell 或在自动化环境(例如 Docker)中覆盖设置提供了一种灵活的方式,而无需修改配置文件。 - -```bash -export CONFIG_GEMINI__MODEL_STRATEGY="overwrite" -export CONFIG_GEMINI__MODELS='[{"model_name": "xxx", "model_header": {"x-goog-ext-525001261-jspb": "[1,null,null,null,\"fbb127bbb056c959\",null,null,0,\[4,5,6,8\],null,null,1,null,null,1,1,\"EA3C5672-E422-4A5F-BE26-B5B57D3B9AC3\"]", "x-goog-ext-73010989-jspb": "[0]", "x-goog-ext-73010990-jspb": "[0,0,0]"}}]' -``` +> [!NOTE] +> `models` 与 `model_strategy` 配置项已移除,底层库中基于静态 `Model` 枚举的名称查找也已删除。 +> 它们的作用是在库尚未跟上新模型发布时手写模型请求头,而动态获取已让这一需求不再存在——如今 +> 硬编码的请求头反而有把请求固定在过时模型上的风险。若配置文件或环境变量中仍保留这两个键, +> 将被直接忽略。 ## 鸣谢 diff --git a/app/models/core.py b/app/models/core.py index c51f233..a165a86 100644 --- a/app/models/core.py +++ b/app/models/core.py @@ -49,3 +49,10 @@ class ConversationInStore(BaseModel): messages: list[AppMessage] = Field( ..., description="Canonical message contents in the conversation" ) + chat_scope: str | None = Field( + default=None, + description=( + "Identity of the ephemeral window this chat lives in, or None for a normal chat kept " + "in the account's history. Reusable only while the client still reports this scope" + ), + ) diff --git a/app/server/chat.py b/app/server/chat.py index 50e7f9d..f60cd9e 100644 --- a/app/server/chat.py +++ b/app/server/chat.py @@ -14,7 +14,6 @@ from fastapi.responses import StreamingResponse from gemini_webapi import ModelOutput from gemini_webapi.client import ChatSession -from gemini_webapi.constants import Model from gemini_webapi.exceptions import ModelInvalidError from gemini_webapi.types.image import GeneratedImage, Image from gemini_webapi.types.video import GeneratedMedia, GeneratedVideo @@ -328,11 +327,11 @@ def _persist_conversation( tool_calls: list[AppToolCall] | None, ) -> str | None: """Unified logic to save conversation history to LMDB.""" - if _use_temporary_chat_mode(): - # This turn's conversation is now the last chat this client opened; any window it - # replaced has been closed by Google and must not be replayed again. Recorded before - # the store so a persistence failure cannot leave a closed window looking reusable. - client.latest_chat_cid = _cid_of(metadata) + # This turn is now the last chat this client opened; any window it replaced is closed. Set + # before the store, so a persistence failure cannot leave a closed window looking reusable, + # and in every mode, since an expired cookie can turn a client ephemeral without warning. + client.latest_chat_cid = _cid_of(metadata) + chat_scope = client.chat_scope(_use_temporary_chat_mode()) try: current_assistant_message = AppMessage( @@ -343,13 +342,14 @@ def _persist_conversation( ) full_history = [*messages, current_assistant_message] - # A temporary chat stays continuable while its window is the live one, so its metadata - # is worth keeping and reusing just like a normal chat's. + # An ephemeral chat is worth storing like any other while its window is live, tagged with + # that window so a later session cannot mistake it for one of its own. db.store( client_id=client.id, model=model_name, messages=full_history, metadata=metadata, + chat_scope=chat_scope, ) logger.debug("Conversation saved to LMDB.") return "success" @@ -664,55 +664,57 @@ def _convert_instructions_to_app_messages( return instruction_messages -def _get_model_by_name(name: str) -> Model: - """Retrieve a Model instance by name.""" - strategy = g_config.gemini.model_strategy - custom_models = {m.model_name: m for m in g_config.gemini.models if m.model_name} +def _resolve_model_name(pool: GeminiClientPool, name: str) -> str: + """Canonical name of the model a request asked for, resolved against a client's registry. - if name in custom_models: - return Model.from_dict(custom_models[name].model_dump()) + Names, aliases and hex ids all resolve, and every client discovers its own models, so nothing + here has to be configured or kept up to date. Resolution is canonical on purpose: two aliases + of one model must reach the same conversation records. - if strategy == "overwrite": - raise ValueError(f"Model '{name}' not found in custom models (strategy='overwrite').") + The name, not the resolved object, is what callers pass on - each client re-resolves it in its + own registry at send time, where the model header carries that account's tier. Preferring an + authenticated client keeps a guest, whose registry marks everything but the default + unavailable, from narrowing what the whole pool accepts. + """ + # A registry outlives an auto-close, so an idle client still resolves; one that never + # initialized has nothing to offer. + clients = sorted(pool.clients, key=lambda c: (c.is_guest(), not c.running())) + for client in clients: + if not client.list_models(): + continue + + try: + return client.resolve_model(name).model_name + except ValueError: + continue - return Model.from_name(name) + raise ValueError(f"Model '{name}' is not available on any Gemini client.") async def _build_available_models(pool: GeminiClientPool) -> list[ModelData]: - """Build the available model list from configured models and currently running clients.""" + """Build the available model list from the models the clients discovered.""" now = int(datetime.now(tz=UTC).timestamp()) - strategy = g_config.gemini.model_strategy models_data = [] seen_model_ids = set() - for model in g_config.gemini.models: - if model.model_name and model.model_name not in seen_model_ids: - models_data.append( - ModelData( - id=model.model_name, - created=now, - owned_by="custom", - ) - ) - seen_model_ids.add(model.model_name) - - if strategy == "append": - for client in pool.clients: - if not client.running(): - continue + for client in pool.clients: + if client_models := client.list_models(): + for model in client_models: + # A guest session registers the models it can see but may only use the + # default one; advertising the rest would promise what it cannot serve. + if not model.is_available: + continue - if client_models := client.list_models(): - for model in client_models: - model_id = model.model_name or model.model_id - if model_id and model_id not in seen_model_ids: - models_data.append( - ModelData( - id=model_id, - created=now, - owned_by="google", - ) + model_id = model.model_name or model.model_id + if model_id and model_id not in seen_model_ids: + models_data.append( + ModelData( + id=model_id, + created=now, + owned_by="google", ) - seen_model_ids.add(model_id) + ) + seen_model_ids.add(model_id) return models_data @@ -741,21 +743,34 @@ def _cid_of(metadata: list[str | None] | None) -> str | None: return metadata[0] if metadata else None -def _is_live_temporary_chat(conv: ConversationInStore, client: GeminiClientWrapper) -> bool: - """Whether a stored chat can still be the open temporary window on this client. +def _is_reusable_chat( + conv: ConversationInStore, client: GeminiClientWrapper, temporary: bool +) -> bool: + """Whether a stored chat can still be continued on this client. - Google closes the previous temporary conversation as soon as another one is created, so at - most one temporary window per client is alive at a time, and it can only be the last chat - the client opened. Replaying an older one makes Google start a fresh chat and answer - without the earlier context: a silent loss that raises no error, which is why this is - checked up front rather than detected after the fact. + A normal chat lives in the account's history and stays continuable indefinitely, so only the + ephemeral records need checking: temporary-mode chats, and everything a guest session opened. + Those survive only as the one open window of the session that created them, and replaying a + closed one makes Google answer from a fresh chat without the earlier context - no error, just + silent loss. So an ephemeral record must still carry the client's current scope, which no + longer matches once that session is gone (reinitialized, or downgraded to guest by expired + cookies, or authenticated again afterwards), and its cid must be the last one this client + opened, since a newer conversation has closed anything older. - `latest_chat_cid` is only that - the last cid this client saw. Its kind is not verified, so - a match is a necessary condition rather than proof the window is temporary or still open. - It lives in memory and is cleared on every client (re)initialization, so an auto-close, - restart or redeploy invalidates every stored window: once the client that opened a window - is gone, there is nothing left to vouch for it. + A `latest_chat_cid` match is necessary but not proof: the cid's kind is never verified. """ + scope = client.chat_scope(temporary) + if conv.chat_scope is None and scope is None: + return True + + if conv.chat_scope != scope: + logger.debug( + f"Stored chat scope {conv.chat_scope!r} no longer matches client {client.id} " + f"({scope!r}); the window behind it is gone, so replaying the full history in a " + "fresh conversation." + ) + return False + latest = client.latest_chat_cid if not latest: logger.debug(f"Client {client.id} has no chat on record; starting a fresh conversation.") @@ -775,9 +790,10 @@ def _is_live_temporary_chat(conv: ConversationInStore, client: GeminiClientWrapp async def _find_reusable_session( db: LMDBConversationStore, pool: GeminiClientPool, - model: Model, + resolved_model: str, messages: list[AppMessage], temporary: bool = False, + require_account: bool = False, ) -> tuple[ ChatSession | None, GeminiClientWrapper | None, list[AppMessage], ConversationInStore | None ]: @@ -790,15 +806,25 @@ async def _find_reusable_session( search_history = messages[:search_end] if search_history[-1].role in {"assistant", "system", "tool"}: try: - if conv := db.find(model.model_name, search_history): + if conv := db.find(resolved_model, search_history): client = await pool.acquire(conv.client_id) - # Checked after acquiring: acquire may restart a closed client, which clears - # the tracked cid and is exactly what invalidates a temporary window. - if temporary and not _is_live_temporary_chat(conv, client): - # Every prefix of one conversation carries the same cid, so if the - # longest match is not the live window, no shorter one will be either. + if require_account and client.is_guest(): + # Continuing here would fail on the upload; a fresh chat on an + # authenticated client can still serve the request. + logger.debug( + f"Client {client.id} owns the match but is a guest session and this " + "request needs an upload; starting a fresh conversation." + ) + break + # Checked after acquiring: acquire may restart a closed client, which rerolls + # the scope and clears the tracked cid, invalidating any ephemeral window. + if not _is_reusable_chat(conv, client, temporary): + # Every prefix of one conversation carries the same cid and scope, so if + # the longest match is not the live window, no shorter one will be either. break - session = client.start_chat(metadata=conv.metadata, model=model) + session = client.start_chat( + metadata=conv.metadata, model=client.usable_model(resolved_model) + ) remain = messages[search_end:] logger.debug( f"Match found at prefix length {search_end}/{len(messages)}. Client: {conv.client_id}" @@ -825,6 +851,32 @@ def _effective_max_chars_per_request(temporary: bool) -> int: return TEMPORARY_MAX_CHARS_PER_REQUEST if temporary else MAX_CHARS_PER_REQUEST +def _requires_upload(messages: list[AppMessage], temporary: bool) -> bool: + """Whether serving these messages needs a file upload, which a guest session cannot do. + + Attachments do; so does input long enough to be sent as `message.txt`. The length is measured + before the prompt is assembled, so it slightly underestimates and only steers client choice - + `_send_with_split` makes the real call. + """ + total = 0 + for message in messages: + if isinstance(message.content, str): + total += len(message.content) + elif isinstance(message.content, list): + for item in message.content: + if item.type != "text": + return True + total += len(item.text or "") + + return total > _effective_max_chars_per_request(temporary) + + +def _can_upload(session: ChatSession) -> bool: + """Whether the client behind this session may attach files.""" + client = session.geminiclient + return client.can_upload() if isinstance(client, GeminiClientWrapper) else True + + async def _send_with_split( session: ChatSession, text: str, @@ -853,6 +905,14 @@ async def _send_with_split( logger.error(f"Error sending message to Gemini: {e}") raise + if not _can_upload(session): + # Only reachable once every client is a guest, since routing prefers an authenticated one. + raise RuntimeError( + f"Message length ({len(text)}) exceeds limit ({limit}) and would have to be sent as " + "an attachment, which a guest session cannot upload. Refresh the client cookies or " + "shorten the request." + ) + logger.info( f"Message length ({len(text)}) exceeds limit ({limit}). Converting text to file attachment." ) @@ -928,7 +988,7 @@ async def _send_with_internal_fallback( *, pool: GeminiClientPool, db: LMDBConversationStore, - model: Model, + resolved_model: str, session: ChatSession, client: GeminiClientWrapper, current_input: str, @@ -964,11 +1024,17 @@ async def _send_with_internal_fallback( logger.warning( "Metadata-backed chat reuse failed; retrying with internal history replay in a fresh chat." ) - fallback_client = await pool.acquire() - fallback_session = fallback_client.start_chat(model=model) fallback_input, fallback_files = await GeminiClientWrapper.process_conversation( full_prepared_messages, tmp_dir ) + # Built before acquiring, so a replay that needs an upload is not handed to a guest. + fallback_client = await pool.acquire( + require_account=bool(fallback_files) + or len(fallback_input) > _effective_max_chars_per_request(temporary) + ) + fallback_session = fallback_client.start_chat( + model=fallback_client.usable_model(resolved_model) + ) # Keep the caller's streaming mode: the endpoints reject a ModelOutput when the client # asked for a stream, so downgrading here would turn a recovery into a 502. output = await _send_and_await_first_chunk( @@ -1118,7 +1184,7 @@ def _create_real_streaming_response( model_name: str, messages: list[AppMessage], db: LMDBConversationStore, - model: Model, + resolved_model: str, client_wrapper: GeminiClientWrapper, session: ChatSession, base_url: str, @@ -1346,7 +1412,7 @@ def make_chunk(delta_content: dict) -> str: ) _persist_conversation( db, - model.model_name, + resolved_model, client_wrapper, session.metadata, messages, @@ -1372,7 +1438,7 @@ def _create_responses_real_streaming_response( model_name: str, messages: list[AppMessage], db: LMDBConversationStore, - model: Model, + resolved_model: str, client_wrapper: GeminiClientWrapper, session: ChatSession, request: ResponseCreateRequest, @@ -2163,7 +2229,7 @@ async def _make_async_gen(item: ModelOutput) -> AsyncGenerator[ModelOutput]: ) _persist_conversation( db, - model.model_name, + resolved_model, client_wrapper, session.metadata, messages, @@ -2205,7 +2271,7 @@ async def create_chat_completion( base_url = str(raw_request.base_url) pool, db = GeminiClientPool(), LMDBConversationStore() try: - model = _get_model_by_name(request.model) + resolved_model = _resolve_model_name(pool, request.model) except ValueError as exc: raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc if not request.messages: @@ -2224,8 +2290,9 @@ async def create_chat_completion( ) use_temporary = _use_temporary_chat_mode() + needs_upload = _requires_upload(msgs, use_temporary) session, client, remain, stored_conv = await _find_reusable_session( - db, pool, model, msgs, temporary=use_temporary + db, pool, resolved_model, msgs, temporary=use_temporary, require_account=needs_upload ) if session: @@ -2246,8 +2313,8 @@ async def create_chat_completion( ) else: try: - client = await pool.acquire() - session = client.start_chat(model=model) + client = await pool.acquire(require_account=needs_upload) + session = client.start_chat(model=client.usable_model(resolved_model)) m_input, files = await GeminiClientWrapper.process_conversation(msgs, tmp_dir) except Exception as e: logger.error(f"Error in preparing conversation: {e}") @@ -2271,7 +2338,7 @@ async def create_chat_completion( resp_or_stream, session, client = await _send_with_internal_fallback( pool=pool, db=db, - model=model, + resolved_model=resolved_model, session=session, client=client, current_input=m_input, @@ -2299,7 +2366,7 @@ async def create_chat_completion( request.model, msgs, db, - model, + resolved_model, client, session, base_url, @@ -2432,7 +2499,7 @@ async def create_chat_completion( ) _persist_conversation( db, - model.model_name, + resolved_model, client, session.metadata, msgs, @@ -2489,13 +2556,14 @@ async def create_response( ) pool, db = GeminiClientPool(), LMDBConversationStore() try: - model = _get_model_by_name(request.model) + resolved_model = _resolve_model_name(pool, request.model) except ValueError as exc: raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc use_temporary = _use_temporary_chat_mode() + needs_upload = _requires_upload(messages, use_temporary) session, client, remain, stored_conv = await _find_reusable_session( - db, pool, model, messages, temporary=use_temporary + db, pool, resolved_model, messages, temporary=use_temporary, require_account=needs_upload ) if session: msgs = _prepare_messages_for_model( @@ -2513,8 +2581,8 @@ async def create_response( ) else: try: - client = await pool.acquire() - session = client.start_chat(model=model) + client = await pool.acquire(require_account=needs_upload) + session = client.start_chat(model=client.usable_model(resolved_model)) m_input, files = await GeminiClientWrapper.process_conversation(messages, tmp_dir) except Exception as e: logger.error(f"Error in preparing conversation: {e}") @@ -2538,7 +2606,7 @@ async def create_response( resp_or_stream, session, client = await _send_with_internal_fallback( pool=pool, db=db, - model=model, + resolved_model=resolved_model, session=session, client=client, current_input=m_input, @@ -2566,7 +2634,7 @@ async def create_response( request.model, messages, db, - model, + resolved_model, client, session, request, @@ -2716,7 +2784,7 @@ async def create_response( ) _persist_conversation( db, - model.model_name, + resolved_model, client, session.metadata, messages, diff --git a/app/services/client.py b/app/services/client.py index 2df704a..4378b55 100644 --- a/app/services/client.py +++ b/app/services/client.py @@ -5,6 +5,7 @@ import orjson from gemini_webapi import GeminiClient from gemini_webapi.constants import AccountStatus +from gemini_webapi.types import AvailableModel from loguru import logger from app.models import AppMessage @@ -15,6 +16,10 @@ save_url_to_tempfile, ) +# How a model is addressed: by name, or as one of the models a client discovered. `None` leaves +# the choice to Google. +type ModelSpec = str | AvailableModel | None + class GeminiClientWrapper(GeminiClient): """Gemini client with helper methods.""" @@ -24,12 +29,10 @@ def __init__(self, client_id: str, **kwargs): super().__init__(**kwargs) self.id = client_id self._initialized = False - # Chat id of the last conversation this client opened. Its kind is not verified here - - # it is simply the most recent cid we saw, whether that conversation is persistent or - # temporary. Callers use it in temporary mode, where Google closes the open window as - # soon as another conversation is created, making this the only cid that can still be - # continuable. Deliberately in-memory and cleared on every (re)initialization: after an - # auto-close, restart or redeploy we can no longer vouch for any window. + # Chat id of the last conversation this client opened, its kind unverified. Google closes + # an ephemeral window as soon as another conversation is created, so for those chats this + # is the only cid still continuable. In memory and cleared on every (re)initialization: + # once the session that opened a window is gone, nothing can vouch for it. self.latest_chat_cid: str | None = None async def init(self, *args: Any, **kwargs: Any) -> None: @@ -70,6 +73,56 @@ def is_healthy(self) -> bool: is_active = self._running or (self.auto_close and self._initialized) return is_active and self.account_status == AccountStatus.AVAILABLE + def is_guest(self) -> bool: + """Whether this client talks to Google without an account. + + Set when initialization falls back to a guest session, or when an authenticated one is + rejected mid-flight because its cookies expired. Text generation keeps working, so this + has to be asked rather than assumed away: the session just has no history, no uploads + and no model choice. + """ + return self.account_status == AccountStatus.UNAUTHENTICATED + + def can_upload(self) -> bool: + """Whether files may be attached; Google rejects uploads from a guest session.""" + return not self.is_guest() + + def usable_model(self, model: ModelSpec) -> ModelSpec: + """The requested model, or the only one a guest session may use. + + Google offers a guest no model choice, so honouring the request is impossible; serving + its default beats failing outright. Callers keep addressing the request by its original + model name, which is what conversation storage stays keyed on. + """ + if not self.is_guest(): + return model + + # None when the guest registry is empty, which leaves the model unspecified and lets + # Google answer with whatever it gives a signed-out visitor. + default = next((m for m in self._model_registry.values() if m.is_available), None) + requested = model if isinstance(model, str) else getattr(model, "model_name", None) + if requested != getattr(default, "model_name", None): + logger.warning( + f"Client {self.id} is a guest session; serving " + f"{default or 'the default model'} instead of the requested {requested}." + ) + return default + + def chat_scope(self, temporary: bool) -> str | None: + """Identity of the ephemeral window chats opened now belong to, else None. + + `None` is a normal chat, which Google keeps in the account's history and stays reusable + across restarts. Any other value names a window only that exact session can continue, so + a stored scope that no longer matches this client's proves the window behind it is gone. + + Keyed on the parent's per-session id, rerolled on every successful initialization, plus + guest state. That covers both ways a window dies silently: a reinitialization, and a + mid-session downgrade to guest, which keeps the session id but loses the account's chats. + """ + if self.is_guest(): + return f"guest:{self._sessionid}" + return f"temporary:{self._sessionid}" if temporary else None + @staticmethod async def _process_content_item( item: Any, role: str, tempdir: Path | None diff --git a/app/services/lmdb.py b/app/services/lmdb.py index 080075d..b076447 100644 --- a/app/services/lmdb.py +++ b/app/services/lmdb.py @@ -237,6 +237,7 @@ def store( model: str, messages: list[AppMessage], metadata: list[str | None], + chat_scope: str | None = None, ) -> None: """ Store a conversation model in LMDB. @@ -246,6 +247,8 @@ def store( model: The model name messages: Unsanitized API messages metadata: Session metadata + chat_scope: Identity of the ephemeral window owning the chat, None if it is a normal + chat kept in the account's history """ if not messages: raise ValueError("Messages list cannot be empty") @@ -256,6 +259,7 @@ def store( client_id=client_id, metadata=metadata, messages=messages, + chat_scope=chat_scope, created_at=now, updated_at=now, ) diff --git a/app/services/pool.py b/app/services/pool.py index 1e8aae5..dfc6c27 100644 --- a/app/services/pool.py +++ b/app/services/pool.py @@ -50,8 +50,14 @@ async def init(self) -> None: if success_count == 0: raise RuntimeError("Failed to initialize any Gemini clients") - async def acquire(self, client_id: str | None = None) -> GeminiClientWrapper: - """Return a healthy client by id or using round-robin.""" + async def acquire( + self, client_id: str | None = None, require_account: bool = False + ) -> GeminiClientWrapper: + """Return a healthy client by id or using round-robin. + + `require_account` excludes guest sessions, for requests they cannot serve at all - file + uploads. Otherwise a guest is used only once no authenticated client is left. + """ if not self._round_robin: raise RuntimeError("No Gemini clients configured") @@ -65,12 +71,32 @@ async def acquire(self, client_id: str | None = None) -> GeminiClientWrapper: f"Gemini client {client_id} is not running and could not be restarted" ) - for _ in range(len(self._round_robin)): - client = self._round_robin[0] - self._round_robin.rotate(-1) - if await self._ensure_client_ready(client): - return client - + # Authenticated clients first. A client whose cookies expired keeps answering text + # prompts as a guest, so it stays usable and must not take the pool down, but it has no + # history, no uploads and no model choice - traffic belongs elsewhere while it can. + for account_only in (True,) if require_account else (True, False): + for _ in range(len(self._round_robin)): + client = self._round_robin[0] + self._round_robin.rotate(-1) + # Rechecked after readiness: a restart can itself land in a guest session. + if account_only and client.is_guest(): + continue + if await self._ensure_client_ready(client) and not ( + account_only and client.is_guest() + ): + return client + + if account_only and not require_account and any(c.is_guest() for c in self._clients): + logger.warning( + "No authenticated Gemini client is available; falling back to a guest " + "session until cookies are refreshed." + ) + + if require_account: + raise RuntimeError( + "No authenticated Gemini client is available. This request needs a file upload, " + "which a guest session cannot do - refresh the client cookies." + ) raise RuntimeError("No Gemini clients are currently available") async def _ensure_client_ready(self, client: GeminiClientWrapper) -> bool: diff --git a/app/utils/config.py b/app/utils/config.py index 6cffa54..3305f67 100644 --- a/app/utils/config.py +++ b/app/utils/config.py @@ -1,10 +1,8 @@ -import ast import os import sys from enum import StrEnum from typing import Any, Literal, cast, get_args -import orjson from curl_cffi import BrowserTypeLiteral from loguru import logger from pydantic import BaseModel, Field, ValidationError, field_validator @@ -72,28 +70,6 @@ def _validate_impersonate(cls, value: str | None) -> str | None: return value -class GeminiModelConfig(BaseModel): - """Configuration for a custom Gemini model.""" - - model_name: str | None = Field(default=None, description="Name of the model") - model_header: dict[str, str | None] | None = Field( - default=None, description="Header for the model" - ) - - @field_validator("model_header", mode="before") - @classmethod - def _parse_json_string(cls, v: Any) -> Any: - if isinstance(v, str) and v.strip().startswith("{"): - try: - return orjson.loads(v) - except orjson.JSONDecodeError: - try: - return ast.literal_eval(v) - except (ValueError, SyntaxError): - return v - return v - - class ChatMode(StrEnum): """Chat mode options for Gemini conversation handling.""" @@ -107,11 +83,6 @@ class GeminiConfig(BaseModel): clients: list[GeminiClientSettings] = Field( ..., description="List of Gemini client credential pairs" ) - models: list[GeminiModelConfig] = Field(default=[], description="List of custom Gemini models") - model_strategy: Literal["append", "overwrite"] = Field( - default="append", - description="Strategy for loading models: 'append' merges custom with default, 'overwrite' uses only custom", - ) timeout: int = Field(default=450, ge=30, description="Init timeout in seconds") watchdog_timeout: int = Field(default=120, ge=30, description="Watchdog timeout in seconds") auto_refresh: bool = Field(True, description="Enable auto-refresh for Gemini sessions") @@ -146,39 +117,6 @@ class GeminiConfig(BaseModel): ), ) - @field_validator("models", mode="before") - @classmethod - def _parse_models_json(cls, v: Any) -> Any: - if isinstance(v, str) and v.strip().startswith("["): - try: - return orjson.loads(v) - except orjson.JSONDecodeError: - try: - return ast.literal_eval(v) - except (ValueError, SyntaxError) as e: - logger.warning(f"Failed to parse models JSON or Python literal: {e}") - return v - return v - - @field_validator("models") - @classmethod - def _filter_valid_models(cls, v: list[GeminiModelConfig]) -> list[GeminiModelConfig]: - """Filter out models that don't have all required fields set.""" - valid_models = [] - for model in v: - if model.model_name and model.model_header: - valid_models.append(model) - else: - missing = [] - if not model.model_name: - missing.append("model_name") - if not model.model_header: - missing.append("model_header") - logger.warning( - f"Discarding custom model due to missing {', '.join(missing)}: {model}" - ) - return valid_models - class CORSConfig(BaseModel): """CORS configuration""" @@ -329,81 +267,21 @@ def _merge_clients_with_env( return result_clients or base_clients or [] -def extract_gemini_models_env() -> dict[int, dict[str, Any]]: - """Extract and remove all Gemini models related environment variables, supporting nested fields.""" - root_key = "CONFIG_GEMINI__MODELS" - env_overrides: dict[int, dict[str, Any]] = {} - - if root_key in os.environ: - val = os.environ[root_key] - models_list = None - parsed_successfully = False - - try: - models_list = orjson.loads(val) - parsed_successfully = True - except orjson.JSONDecodeError: - try: - models_list = ast.literal_eval(val) - parsed_successfully = True - except (ValueError, SyntaxError) as e: - logger.warning(f"Failed to parse {root_key} as JSON or Python literal: {e}") - - if parsed_successfully and isinstance(models_list, list): - for idx, model_data in enumerate(models_list): - if isinstance(model_data, dict): - env_overrides[idx] = cast(dict[str, Any], model_data) - - del os.environ[root_key] - - return env_overrides - - -def _merge_models_with_env( - base_models: list[GeminiModelConfig] | None, - env_overrides: dict[int, dict[str, Any]], -): - """Override base_models with env_overrides using standard update (replace whole fields).""" - if not env_overrides: - return base_models or [] - result_models: list[GeminiModelConfig] = [] - if base_models: - result_models = [model.model_copy() for model in base_models] - - for idx in sorted(env_overrides): - overrides = env_overrides[idx] - if idx < len(result_models): - model_dict = result_models[idx].model_dump() - model_dict.update(overrides) - result_models[idx] = GeminiModelConfig(**model_dict) - elif idx == len(result_models): - new_model = GeminiModelConfig(**overrides) - result_models.append(new_model) - else: - raise IndexError( - f"Model index {idx} in env is out of range (current count: {len(result_models)}). " - "Model indices must be contiguous starting from 0." - ) - return result_models - - def initialize_config() -> Config: """ Initialize configuration from environment variables and the YAML settings source. Returns: - Config: Configuration object with Gemini client and model overrides merged + Config: Configuration object with Gemini client overrides merged """ try: env_clients_overrides = extract_gemini_clients_env() - env_models_overrides = extract_gemini_models_env() settings_cls: type[Any] = Config config = cast(Config, settings_cls()) config.gemini.clients = _merge_clients_with_env( config.gemini.clients, env_clients_overrides ) - config.gemini.models = _merge_models_with_env(config.gemini.models, env_models_overrides) return config except ValidationError as e: diff --git a/config/config.yaml b/config/config.yaml index 15bd99c..fc2ef85 100644 --- a/config/config.yaml +++ b/config/config.yaml @@ -36,8 +36,6 @@ gemini: # WARNING: Google may close a temporary window at any time mid-conversation. The reply can then come back without the earlier # context instead of erroring, so the loss may be silent. Prefer "normal" for long or context-sensitive conversations. chat_mode: "normal" - model_strategy: "append" # Strategy: 'append' (default + custom) or 'overwrite' (custom only) - models: [] storage: path: "data/lmdb" # Database storage path diff --git a/uv.lock b/uv.lock index 18e0e5b..e3366ce 100644 --- a/uv.lock +++ b/uv.lock @@ -179,8 +179,8 @@ dev = [{ name = "gemini-fastapi", extras = ["dev"] }] [[package]] name = "gemini-webapi" -version = "0.0.post259" -source = { git = "https://github.com/luuquangvu/Gemini-API.git?rev=enable-guest-mode#1698ec212ddec202a950e9d9ed15f09f3f365c76" } +version = "0.0.post261" +source = { git = "https://github.com/luuquangvu/Gemini-API.git?rev=enable-guest-mode#04174cdc73b5788c8391cfc6aa36985a06d4c5af" } dependencies = [ { name = "curl-cffi" }, { name = "loguru" }, From c8e0a83f6173da7d0056d247f73f219dae61dc66 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C6=B0u=20Quang=20V=C5=A9?= Date: Thu, 13 Aug 2026 22:31:55 +0700 Subject: [PATCH 282/291] Deprecate the old hardcoded `Model` and switch entirely to using the dynamic `AvailableModel` --- uv.lock | 40 ++++++++++++++++++++-------------------- 1 file changed, 20 insertions(+), 20 deletions(-) diff --git a/uv.lock b/uv.lock index e3366ce..e89cc3b 100644 --- a/uv.lock +++ b/uv.lock @@ -180,7 +180,7 @@ dev = [{ name = "gemini-fastapi", extras = ["dev"] }] [[package]] name = "gemini-webapi" version = "0.0.post261" -source = { git = "https://github.com/luuquangvu/Gemini-API.git?rev=enable-guest-mode#04174cdc73b5788c8391cfc6aa36985a06d4c5af" } +source = { git = "https://github.com/luuquangvu/Gemini-API.git?rev=enable-guest-mode#e0deb8a231dd004282d8493df748d4bee5f984d1" } dependencies = [ { name = "curl-cffi" }, { name = "loguru" }, @@ -390,27 +390,27 @@ wheels = [ [[package]] name = "ruff" -version = "0.16.2" +version = "0.16.3" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/73/e1/4508a569211b35599016e84ba65c1a992b7a4004b4b6c4bea02a851cba1b/ruff-0.16.2.tar.gz", hash = "sha256:c3d7828d12e8927a6fc65fe38e2c2541b9e762d360a1786d752cb1b8883b3c9c", size = 4885811, upload-time = "2026-08-07T13:31:01.432Z" } +sdist = { url = "https://files.pythonhosted.org/packages/61/b3/3213589383f8f1b3938781bd1278713f6d18621a14992b3e81fefb8a5ef9/ruff-0.16.3.tar.gz", hash = "sha256:e76d33a347661a84b5be6d043d0347fdc745dfdcf825a8f4fed64b5e26eebdf2", size = 4891904, upload-time = "2026-08-13T15:17:13.381Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/14/57/db19951540f98859c956b50bdb4d31089b4d91e9f15e2968e7d5193806d5/ruff-0.16.2-py3-none-linux_armv6l.whl", hash = "sha256:3c8de4cf2181f01d57946d87d777aa52916976fc09942aed89938fab5e013318", size = 10847925, upload-time = "2026-08-07T13:30:14.468Z" }, - { url = "https://files.pythonhosted.org/packages/13/5a/995fe85a8470d3e391ac0f7fa8054bb454eaf33ee138196d6172ed1079c0/ruff-0.16.2-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:9a48cc05c6fbc811ca81b5d7ba95375affea6582d1b8024e455e41afbbf55344", size = 11072662, upload-time = "2026-08-07T13:30:18.143Z" }, - { url = "https://files.pythonhosted.org/packages/32/53/370d767c61c71a971a4ace36703a7ecd8c393956349a7325d7fab2b56827/ruff-0.16.2-py3-none-macosx_11_0_arm64.whl", hash = "sha256:a2c0d14fcbb26c91f0f867a6dc9bd71bbc30b1b6151829c884f23faeab2e5700", size = 10566771, upload-time = "2026-08-07T13:30:20.899Z" }, - { url = "https://files.pythonhosted.org/packages/85/d6/9d96948caf5a632be62d62202d5ec914d6856f204fd79eb036e5915e79ea/ruff-0.16.2-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:335c621622c4650330be50842561c6586ac6971bb8ab5407fe34dcc9efb16bbe", size = 10975825, upload-time = "2026-08-07T13:30:23.517Z" }, - { url = "https://files.pythonhosted.org/packages/3b/92/ea87129b3414acb0b5770563779c51804d37ac67675c7ba35447ddb14773/ruff-0.16.2-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:20e66910f2c37cc753f9ef6580c914a621b80c4fa3549d3e3521e29d0f5bfc3f", size = 10649437, upload-time = "2026-08-07T13:30:26.097Z" }, - { url = "https://files.pythonhosted.org/packages/ac/43/f8f291dcd4af5bb7872b74fdfa41a7cd7c856ca1d4069670971cf1b9f5cb/ruff-0.16.2-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c7e36fbfba65510548156902bcf1350a979a958ce0347ce0f90d73894036b39f", size = 11446761, upload-time = "2026-08-07T13:30:28.752Z" }, - { url = "https://files.pythonhosted.org/packages/71/4a/ef991fb2fcf516ab71f0808adcdd8da5e18c8cde447f4ceaf5f47a5132a5/ruff-0.16.2-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f0eab35f80df8f134aae5d1630e751901321d317cc8e50dc39e36fa3ed34cd12", size = 12336364, upload-time = "2026-08-07T13:30:31.468Z" }, - { url = "https://files.pythonhosted.org/packages/f3/24/f615e74f307e6ca0e56a482872477b856c70d530aa356abfb6dfe5ca8a80/ruff-0.16.2-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:40ea8c0594feb894e89c8c61ab9c103d38b0ea72dfde6c594107147ca31b1140", size = 11630720, upload-time = "2026-08-07T13:30:34.426Z" }, - { url = "https://files.pythonhosted.org/packages/c5/d3/8ef50149e8412a77f7ab409efdef0e2b23803707a3863da4fc64cb23d459/ruff-0.16.2-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ab3d62dde0b19facdd632008cc4827fc28ada7736c6bd35ab6f1050f0bfed53f", size = 11466130, upload-time = "2026-08-07T13:30:36.958Z" }, - { url = "https://files.pythonhosted.org/packages/dd/a7/a19334985c4dea8c381981fa252cd854c7ee52dc4b1686dc16f4a911c702/ruff-0.16.2-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:e43e1f5b8388da9eca1b9e88328d47a5cec794633ccf6f7484ac2dd15eee92c0", size = 11523634, upload-time = "2026-08-07T13:30:39.822Z" }, - { url = "https://files.pythonhosted.org/packages/6e/6c/96d192b0e742412ceda08c0a50f9669b253dde9fd6a60ea1a10c9fa79a63/ruff-0.16.2-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:c24788a980581e1d7ea3a0cbe4344c4fbeb0a6a9b1f4713aa46bb104f8294690", size = 10949807, upload-time = "2026-08-07T13:30:42.745Z" }, - { url = "https://files.pythonhosted.org/packages/fa/51/e26599ceca11e79ee255c7df515995561edf87e9ca1893284e44d98f5a86/ruff-0.16.2-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:81806b08329130005dd4a8a8394a0c9da8c6f4cafb16ba438d2a2ee6a18bedf1", size = 10646891, upload-time = "2026-08-07T13:30:45.522Z" }, - { url = "https://files.pythonhosted.org/packages/68/01/800c4b1f97bc8d7c6029e06b1f20473a3cf1e13c4933d8f3342add83fc55/ruff-0.16.2-py3-none-musllinux_1_2_i686.whl", hash = "sha256:4ce4e02bad779bef557f541a1b31f20d6abeae1cc05ed1b1ac019d4ffd1044c8", size = 11162063, upload-time = "2026-08-07T13:30:48.131Z" }, - { url = "https://files.pythonhosted.org/packages/e4/d0/1477ea50fc5a0d4b0b71d1d63d50770bdd794d90b43e37a7618e63ec9894/ruff-0.16.2-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:e0422abdf70070255fc4073ce9dfc814cc03db577013761ddd09bc1e4a9a4fbd", size = 11556038, upload-time = "2026-08-07T13:30:50.686Z" }, - { url = "https://files.pythonhosted.org/packages/b8/76/a7776f32048d991e16d4fa8ff91790b877342d3596cc3ed04acdbf1aaedc/ruff-0.16.2-py3-none-win32.whl", hash = "sha256:bf3a63d78fb39f4bf5ac8ae52051c5520505301abe19ba4e204c453b3f09bb0b", size = 10872850, upload-time = "2026-08-07T13:30:53.471Z" }, - { url = "https://files.pythonhosted.org/packages/00/0d/929c800d920e61397d82a01b60bffc68da3052c17d31de59efaad2e4ed75/ruff-0.16.2-py3-none-win_amd64.whl", hash = "sha256:bcabe2f6d0fc7819f1431793005af4e4de7371927d037345bf941252b195b9fa", size = 12023338, upload-time = "2026-08-07T13:30:56.193Z" }, - { url = "https://files.pythonhosted.org/packages/5b/6c/93e26c22c5f78ff87363e07da49c84955affbeb1098bd1936bf3b3f293bf/ruff-0.16.2-py3-none-win_arm64.whl", hash = "sha256:d614e95cedf38a2053fd351c55b103ba30d017d61688fdbfd40ee0412852a99f", size = 11374065, upload-time = "2026-08-07T13:30:58.775Z" }, + { url = "https://files.pythonhosted.org/packages/bf/96/493770daebd68c0a67f1549fdf519f53be51fc435186c0585bcc272fd76c/ruff-0.16.3-py3-none-linux_armv6l.whl", hash = "sha256:0c5710e247a58a4521e66e124ba9a74655b414f61ba3a2e9e3811e11098f48f7", size = 10902799, upload-time = "2026-08-13T15:16:27.382Z" }, + { url = "https://files.pythonhosted.org/packages/5e/e6/2becf3942fddc29a29b8df47691d456fb1085391a694f74d84513251418c/ruff-0.16.3-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:fe155130631a2471fd2e14a7a664a4dfbd7194b8229c3d7b2a40b21178639081", size = 11135539, upload-time = "2026-08-13T15:16:30.87Z" }, + { url = "https://files.pythonhosted.org/packages/3e/1e/4b8b72f0d006dbf19326aa99f9ca0ee2ff374187c4d301cf529a51aa06fe/ruff-0.16.3-py3-none-macosx_11_0_arm64.whl", hash = "sha256:e2ed719e14aa64d895c2ee922594a90a43c861a93f0575a95ff8c47cdbd13eb9", size = 10475095, upload-time = "2026-08-13T15:16:33.259Z" }, + { url = "https://files.pythonhosted.org/packages/92/32/2201fa49ba1f6c101ee321e83f051ac7a4b8d07b0ef6b4d3f2772b302275/ruff-0.16.3-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9e0b1da805eb043654645d74d5de1e5ce2edc686e40790d2b86f56d71cc06a84", size = 10668771, upload-time = "2026-08-13T15:16:35.65Z" }, + { url = "https://files.pythonhosted.org/packages/c3/66/4afc5c8363bd04d45effce1b7c8713ca037d7a6740b7451a2403a6e3a972/ruff-0.16.3-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a37bdea0bbe21780f590bf437d6412c8c4e1b6cd010f91a65c2c40c5e5f5f870", size = 10699568, upload-time = "2026-08-13T15:16:38.195Z" }, + { url = "https://files.pythonhosted.org/packages/53/fd/c67d246bf36bf1698551c56de39e95cd07f70e64433e0098e6267d77061b/ruff-0.16.3-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:09571e6d1288ed9be475207a3ac04ada404f1cd898104be0f6ab8d7df438575b", size = 11499365, upload-time = "2026-08-13T15:16:40.623Z" }, + { url = "https://files.pythonhosted.org/packages/67/0b/00ecbceb99a263af7b12f6f05ac3c92bc47b905e91adc3f207a836e3bc01/ruff-0.16.3-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2c18c5a101eb540010638cc1ff3c84944d3adb3df62b8d98ca8f22ba484d3413", size = 12311728, upload-time = "2026-08-13T15:16:43.564Z" }, + { url = "https://files.pythonhosted.org/packages/54/b2/b7b3bb54f4d3f7db504e476ad4ab8de530dceebe2c061384b2757ee419e8/ruff-0.16.3-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8457c44f15033c85ddbb77b15d451df9e24e4bd03b628396dd3610cedc3b8f82", size = 11699896, upload-time = "2026-08-13T15:16:46.209Z" }, + { url = "https://files.pythonhosted.org/packages/c7/30/4c468429ac195addc5ee1b717b6ab1b66632786737ca3b2ed3443fb0c26a/ruff-0.16.3-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:294b95c4ae0cda9388525c2047778aa758d6b8d4bb876fd4e9eaa3ebc92343eb", size = 11058736, upload-time = "2026-08-13T15:16:48.823Z" }, + { url = "https://files.pythonhosted.org/packages/43/67/7a113cdaddf24b64d7f75b1242a99d04c82fcef4f6921fdbb832beaffb5f/ruff-0.16.3-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:3d0c7c40c87c2a820509c31ba007968da6e1306468c067b2d82fbfdbcd0e8474", size = 11586911, upload-time = "2026-08-13T15:16:51.913Z" }, + { url = "https://files.pythonhosted.org/packages/f1/c1/2e66f24c0f3ead25a5e660111778685e505e5da353c82802bf49f0cbe7b9/ruff-0.16.3-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:9f738c0fdfa8eed0b2ce7fb27ee7258208a92a68d7949e62aa15164bc7b389da", size = 10954265, upload-time = "2026-08-13T15:16:54.763Z" }, + { url = "https://files.pythonhosted.org/packages/c2/ba/4cee23bf52cba9a058d3726de623624daf50ef9638868edd86f4126157f6/ruff-0.16.3-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:fb785f0be25abe69d320415cd4f833b59e17ba7613d9ba6a958023b6bceb0a50", size = 10709886, upload-time = "2026-08-13T15:16:57.339Z" }, + { url = "https://files.pythonhosted.org/packages/82/df/7da7194fa5d9dc0a285f7e6fa5a4722e7c63faac0b45b614ded9314363a1/ruff-0.16.3-py3-none-musllinux_1_2_i686.whl", hash = "sha256:c5536e3acfbf9563085aa2be7b13c629c3077e902afc5b941ac44024dbb9f506", size = 11210392, upload-time = "2026-08-13T15:17:00.171Z" }, + { url = "https://files.pythonhosted.org/packages/35/85/7795f6e817af050e7517bf3e7aa9b061cce70ef33d280aad902c956c1ecf/ruff-0.16.3-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:a2d85c02f9b8e165d85e6779184d38c4132de12603dab59c51c28e22584f9e4d", size = 11626910, upload-time = "2026-08-13T15:17:03.299Z" }, + { url = "https://files.pythonhosted.org/packages/78/9b/475b927cf27a5cbbda3c7bafb69ed6ff77e1d7923d5d85f17c2749d7ae32/ruff-0.16.3-py3-none-win32.whl", hash = "sha256:388cdf2166642bd9b13d52b5932d3170f34f8abed7e8d9a855f1d84b83645a0a", size = 10931415, upload-time = "2026-08-13T15:17:05.726Z" }, + { url = "https://files.pythonhosted.org/packages/b2/99/e2a2bfc4fbf0a1e8a916bc9ebe6fe6c58cc34c28e0ffc6ce281d572d1c2e/ruff-0.16.3-py3-none-win_amd64.whl", hash = "sha256:e80a7d69ca2a6d1c4d352ec91458cdca6e56c83cdbcabd93e4abe1e53591d948", size = 11445993, upload-time = "2026-08-13T15:17:08.353Z" }, + { url = "https://files.pythonhosted.org/packages/69/3e/4132e539aed78c148854d4997a2685b0ed4dc4e87110b59ce528564e184e/ruff-0.16.3-py3-none-win_arm64.whl", hash = "sha256:b8ca152da82c1acc1fa8d5874b15951935f0eef46f10e6954c83859011b6178a", size = 11399302, upload-time = "2026-08-13T15:17:10.908Z" }, ] [[package]] From 85d7a89027ec137ddaa9b5b6b6aa5795173d0cdb Mon Sep 17 00:00:00 2001 From: Callum Vincent Date: Sat, 15 Aug 2026 08:20:22 +0100 Subject: [PATCH 283/291] Reconciled survivors from PR #5: /v1beta surface, SSRF guard, truncation recovery, script restore (#6) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Survivors from reconciled fork (PR #5 follow-up): C1 v1beta, SSRF guard, truncation recovery, model-resolution fix, start script vs luuquangvu/main: - app/server/gemini.py + app/models/gemini_models.py + app/models/__init__.py + app/main.py + app/server/middleware.py: native Gemini /v1beta REST surface (models, generateContent, streamGenerateContent, x-goog-api-key auth, Google error envelopes) - unobjected in review - app/utils/helper.py + app/utils/config.py + config/config.yaml: SSRF guard (reject_unsafe_url, allow_private_url_fetch, url_fetch_timeout); recovery_timeout knob - app/services/client.py + app/server/chat.py: truncated-stream recovery via LIST_CONVERSATION_TURNS polling; fix chat.py:688 calling nonexistent client.resolve_model (lib: _resolve_model_by_name) - scripts/start-gemini-api.sh: restore local start/stop helper (deleted in 71c0db2 with no replacement) - app/services/pool.py: extract _init_one/_init_attempt (behavior-preserving) * Drop unused video-generation helper from PR surface (dead against luuquangvu models) * Remove VideoGeneration import (dead on PR surface) * Fix CI: move MIME registry below imports and use contextlib.suppress Ruff E402/SIM105 in app/main.py (self-introduced by the survivors main.py edit): the mimetypes loop ran before module imports and used try/except-pass. Imports first, loop after (still at import time, before any upload), no noqa needed. All 4 CI steps now pass: ruff check, ruff format, ty, pyright. * Update the PR to align with the current flow: - removing _recover_stream_output, as it’s now handled upstream. - removing fetch_last_model_turn since it’s already included upstream. - eliminating the unnecessary looks_like_html function. --- .gitattributes | 1 + .gitignore | 3 +- app/main.py | 19 + app/models/__init__.py | 1 + app/models/gemini_models.py | 283 ++++++++++++ app/server/gemini.py | 854 ++++++++++++++++++++++++++++++++++++ app/server/middleware.py | 30 ++ app/services/pool.py | 24 +- app/utils/config.py | 10 + app/utils/helper.py | 104 ++++- config/config.yaml | 2 + pyproject.toml | 2 +- scripts/start-gemini-api.sh | 62 +++ uv.lock | 50 +-- 14 files changed, 1392 insertions(+), 53 deletions(-) create mode 100644 .gitattributes create mode 100644 app/models/gemini_models.py create mode 100644 app/server/gemini.py create mode 100755 scripts/start-gemini-api.sh diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..fcadb2c --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +* text eol=lf diff --git a/.gitignore b/.gitignore index bea920f..6983284 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ .python-version +.DS_Store .vscode .cursor .idea @@ -10,4 +11,4 @@ __pycache__ .env config.debug.yaml -data/ \ No newline at end of file +data/ diff --git a/app/main.py b/app/main.py index a1c275c..c9d998e 100644 --- a/app/main.py +++ b/app/main.py @@ -1,4 +1,6 @@ import asyncio +import contextlib +import mimetypes from contextlib import asynccontextmanager from fastapi import FastAPI @@ -6,6 +8,8 @@ from .server.chat import refresh_available_models_cache from .server.chat import router as chat_router +from .server.gemini import add_gemini_exception_handlers +from .server.gemini import router as gemini_router from .server.health import router as health_router from .server.media import router as media_router from .server.middleware import ( @@ -15,6 +19,19 @@ ) from .services import GeminiClientPool, LMDBConversationStore +# Canonical audio MIME types: Python's platform mapping yields legacy "x-" +# types (audio/x-wav, audio/x-aac, audio/x-flac) that Google's upload +# endpoint does not classify as audio, so the attached file never reaches +# the model as an audible attachment. Registered at import time, before any upload. +for _ext, _mime in ( + (".wav", "audio/wav"), + (".aac", "audio/aac"), + (".flac", "audio/flac"), + (".m4a", "audio/mp4"), +): + with contextlib.suppress(ValueError): + mimetypes.add_type(_mime, _ext) + RETENTION_CLEANUP_INTERVAL_SECONDS = 6 * 60 * 60 # Check every 6 hours @@ -106,9 +123,11 @@ def create_app() -> FastAPI: add_cors_middleware(app) add_exception_handler(app) + add_gemini_exception_handlers(app) app.include_router(health_router, tags=["Health"]) app.include_router(chat_router, tags=["Chat"]) app.include_router(media_router, tags=["Media"]) + app.include_router(gemini_router, tags=["Gemini"]) return app diff --git a/app/models/__init__.py b/app/models/__init__.py index 6fa671f..d83841b 100644 --- a/app/models/__init__.py +++ b/app/models/__init__.py @@ -1,4 +1,5 @@ # ruff: noqa: F403 from .core import * +from .gemini_models import * from .models import * diff --git a/app/models/gemini_models.py b/app/models/gemini_models.py new file mode 100644 index 0000000..4ee2ef3 --- /dev/null +++ b/app/models/gemini_models.py @@ -0,0 +1,283 @@ +# ruff: noqa: N815 # camelCase field names are the Gemini REST wire format +"""Gemini REST API 原生格式的 Pydantic 数据模型。 + +覆盖 generateContent / streamGenerateContent / models.list / models.get 端点所需的 +请求体和响应体结构, 遵循 Google Gemini REST API v1beta 规范。 +(Adapted from fork fujunchao — unchanged, per C1 contract.) +""" + +from __future__ import annotations + +from typing import Any, Literal + +from pydantic import BaseModel, Field + +# --------------------------------------------------------------------------- +# 请求模型 — 内部 Part 类型 +# --------------------------------------------------------------------------- + + +class GeminiInlineData(BaseModel): + """内嵌二进制数据(图片等)。""" + + mimeType: str + data: str # base64 + + +class GeminiFileData(BaseModel): + """通过 Files API 上传的文件引用。""" + + mimeType: str + fileUri: str + + +class GeminiFunctionCall(BaseModel): + """模型发起的函数调用。""" + + name: str + args: dict[str, Any] = Field(default_factory=dict) + + +class GeminiFunctionResponse(BaseModel): + """用户提交的函数执行结果。""" + + name: str + response: dict[str, Any] = Field(default_factory=dict) + + +class GeminiPart(BaseModel): + """Content 中的一个 part, 可能是文本/思考、内嵌数据、函数调用或函数响应。""" + + text: str | None = None + thought: bool | None = None + inlineData: GeminiInlineData | None = None + functionCall: GeminiFunctionCall | None = None + functionResponse: GeminiFunctionResponse | None = None + fileData: GeminiFileData | None = None + + +# --------------------------------------------------------------------------- +# 请求模型 — 顶层结构 +# --------------------------------------------------------------------------- + + +class GeminiContent(BaseModel): + """一条消息内容(用户 / 模型 / 函数角色)。""" + + role: Literal["user", "model", "function"] | None = None + parts: list[GeminiPart] = Field(default_factory=list) + + +class GeminiSystemInstruction(BaseModel): + """系统指令(顶层, 不在 contents 中)。""" + + parts: list[GeminiPart] = Field(default_factory=list) + + +class GeminiFunctionDeclaration(BaseModel): + """函数声明。""" + + name: str + description: str | None = None + parameters: dict[str, Any] | None = None + + +class GeminiTool(BaseModel): + """工具集合。""" + + functionDeclarations: list[GeminiFunctionDeclaration] = Field(default_factory=list) + + +class GeminiFunctionCallingConfig(BaseModel): + """函数调用配置。""" + + mode: Literal["AUTO", "NONE", "ANY"] = "AUTO" + allowedFunctionNames: list[str] | None = None + + +class GeminiToolConfig(BaseModel): + """工具配置。""" + + functionCallingConfig: GeminiFunctionCallingConfig | None = None + + +class GeminiSafetySetting(BaseModel): + """安全设置。""" + + category: str + threshold: str + + +class GeminiThinkingConfig(BaseModel): + """思考配置(控制模型推理行为)。""" + + includeThoughts: bool | None = None + thinkingBudget: int | None = None + thinkingLevel: str | None = None # OFF | LOW | MEDIUM | HIGH + + +class GeminiGenerationConfig(BaseModel): + """生成参数。""" + + temperature: float | None = None + topP: float | None = None + topK: int | None = None + maxOutputTokens: int | None = None + stopSequences: list[str] | None = None + responseMimeType: str | None = None + responseSchema: dict[str, Any] | None = None + responseJsonSchema: dict[str, Any] | None = None + candidateCount: int | None = None + thinkingConfig: GeminiThinkingConfig | None = None + + +class GeminiGenerateContentRequest(BaseModel): + """generateContent / streamGenerateContent 请求体。""" + + contents: list[GeminiContent] = Field(default_factory=list) + systemInstruction: GeminiSystemInstruction | None = None + tools: list[GeminiTool] | None = None + toolConfig: GeminiToolConfig | None = None + safetySettings: list[GeminiSafetySetting] | None = None + generationConfig: GeminiGenerationConfig | None = None + cachedContent: str | None = None + + +# --------------------------------------------------------------------------- +# 响应模型 +# --------------------------------------------------------------------------- + + +class GeminiSafetyRating(BaseModel): + """安全评级。""" + + category: str + probability: str + + +class GeminiCitationSource(BaseModel): + """单条引用来源。""" + + startIndex: int | None = None + endIndex: int | None = None + uri: str | None = None + license: str | None = None + + +class GeminiCitationMetadata(BaseModel): + """引用元数据。""" + + citationSources: list[GeminiCitationSource] = Field(default_factory=list) + + +class GeminiGroundingChunkWeb(BaseModel): + """溯源来源网页信息。""" + + uri: str | None = None + title: str | None = None + + +class GeminiGroundingChunk(BaseModel): + """溯源来源块。""" + + web: GeminiGroundingChunkWeb | None = None + + +class GeminiSearchEntryPoint(BaseModel): + """搜索入口点。""" + + renderedContent: str | None = None + sdkBlob: str | None = None + + +class GeminiGroundingSupport(BaseModel): + """溯源支撑片段。""" + + segment: dict[str, Any] | None = None + groundingChunkIndices: list[int] = Field(default_factory=list) + confidenceScores: list[float] = Field(default_factory=list) + + +class GeminiGroundingMetadata(BaseModel): + """溯源元数据(Google Search 等)。""" + + webSearchQueries: list[str] = Field(default_factory=list) + groundingChunks: list[GeminiGroundingChunk] = Field(default_factory=list) + searchEntryPoint: GeminiSearchEntryPoint | None = None + groundingSupports: list[GeminiGroundingSupport] = Field(default_factory=list) + + +class GeminiCandidate(BaseModel): + """生成候选项。""" + + content: GeminiContent | None = None + finishReason: str | None = None + index: int = 0 + safetyRatings: list[GeminiSafetyRating] = Field(default_factory=list) + citationMetadata: GeminiCitationMetadata | None = None + groundingMetadata: GeminiGroundingMetadata | None = None + tokenCount: int | None = None + avgLogprobs: float | None = None + + +class GeminiUsageMetadata(BaseModel): + """用量统计。""" + + promptTokenCount: int = 0 + candidatesTokenCount: int = 0 + totalTokenCount: int = 0 + thoughtsTokenCount: int | None = None + cachedContentTokenCount: int | None = None + toolUsePromptTokenCount: int | None = None + + +class GeminiGenerateContentResponse(BaseModel): + """generateContent / streamGenerateContent 响应体。""" + + candidates: list[GeminiCandidate] = Field(default_factory=list) + usageMetadata: GeminiUsageMetadata | None = None + modelVersion: str | None = None + + +# --------------------------------------------------------------------------- +# models.list / models.get 响应 +# --------------------------------------------------------------------------- + + +class GeminiModelInfo(BaseModel): + """单个模型信息。""" + + name: str + version: str | None = None + displayName: str | None = None + description: str | None = None + inputTokenLimit: int | None = None + outputTokenLimit: int | None = None + supportedGenerationMethods: list[str] = Field(default_factory=list) + + +class GeminiModelListResponse(BaseModel): + """models.list 响应体。""" + + models: list[GeminiModelInfo] = Field(default_factory=list) + nextPageToken: str | None = None + + +# --------------------------------------------------------------------------- +# 错误响应 +# --------------------------------------------------------------------------- + + +class GeminiErrorDetail(BaseModel): + """Google API 标准错误详情。""" + + code: int + message: str + status: str + details: list[dict[str, Any]] = Field(default_factory=list) + + +class GeminiErrorResponse(BaseModel): + """Google API 标准错误包装。""" + + error: GeminiErrorDetail diff --git a/app/server/gemini.py b/app/server/gemini.py new file mode 100644 index 0000000..f16096d --- /dev/null +++ b/app/server/gemini.py @@ -0,0 +1,854 @@ +"""Gemini REST API v1beta native endpoints (ported from fork fujunchao, adapted to our tree). + +Endpoints (Google Gemini API compatible): +- GET /v1beta/models — list models +- GET /v1beta/models/{model} — get one model +- POST /v1beta/models/{model}:generateContent — non-streaming generation +- POST /v1beta/models/{model}:streamGenerateContent — streaming (SSE) + +Reuses OUR chat.py helpers; no chat.py changes were made (C1 parallel-safety). +Adaptation deltas vs the fork (see .metacog/fork-evals/fujunchao.md §5): +- helpers live in app/utils/helper.py as calculate_usage / process_llm_output / + normalize_llm_text (no _-prefix, no chat.py copies) +- image store helpers are get_media_store_dir / get_media_token; media served at + /media/{fname}?token= (not /images/) +- _find_reusable_session returns a 4-tuple here (session, client, remain, conv) +- _persist_conversation takes the client wrapper and no thoughts argument here +- _get_available_models requires a pool: pass GeminiClientPool() (a singleton that + lifespan already initialized) +- GeminiClientWrapper.extract_output does not exist here; use normalize_llm_text +- internal message types are AppMessage/AppContentItem/AppToolCall(+Function), + tools are FunctionTool (flat schema) +- Gemini fileData (Files API URI) parts are logged and skipped: our pipeline only + accepts base64 file_data items, and a Google fileUri cannot be fetched locally. +""" + +from __future__ import annotations + +import io +import reprlib +import uuid +from pathlib import Path +from typing import Any, Literal, cast + +import orjson +from fastapi import APIRouter, Depends, FastAPI, Request +from fastapi.exception_handlers import http_exception_handler, request_validation_exception_handler +from fastapi.exceptions import RequestValidationError +from fastapi.responses import JSONResponse, StreamingResponse +from gemini_webapi import ModelOutput +from loguru import logger +from starlette.exceptions import HTTPException as StarletteHTTPException + +from app.models import ( + AppContentItem, + AppMessage, + AppToolCall, + AppToolCallFunction, + FunctionTool, + ToolChoiceFunction, +) +from app.models.gemini_models import ( + GeminiCandidate, + GeminiContent, + GeminiErrorDetail, + GeminiErrorResponse, + GeminiFunctionCall, + GeminiGenerateContentRequest, + GeminiGenerateContentResponse, + GeminiInlineData, + GeminiModelInfo, + GeminiModelListResponse, + GeminiPart, + GeminiUsageMetadata, +) + +# 从 chat.py 导入已有辅助函数(不修改 chat.py) +from app.server.chat import ( + StreamingOutputFilter, + _build_structured_requirement, + _find_reusable_session, + _get_available_models, + _image_to_base64, + _persist_conversation, + _prepare_messages_for_model, + _resolve_model_name, + _send_with_split, +) +from app.server.middleware import ( + get_media_store_dir, + get_media_token, + get_temp_dir, + verify_gemini_api_key, +) +from app.services import GeminiClientPool, GeminiClientWrapper, LMDBConversationStore +from app.utils.helper import calculate_usage, normalize_llm_text, process_llm_output + +router = APIRouter() + + +def add_gemini_exception_handlers(app: FastAPI) -> None: + """Register Google-style HTTP and validation errors for /v1beta routes.""" + + @app.exception_handler(StarletteHTTPException) + async def gemini_http_exception_handler(request: Request, exc: StarletteHTTPException): + if not request.url.path.startswith("/v1beta/"): + return await http_exception_handler(request, exc) + + grpc_status = { + 400: "INVALID_ARGUMENT", + 401: "UNAUTHENTICATED", + 403: "PERMISSION_DENIED", + 404: "NOT_FOUND", + 429: "RESOURCE_EXHAUSTED", + 503: "UNAVAILABLE", + }.get(exc.status_code, "INTERNAL") + err = _to_gemini_error(exc.status_code, str(exc.detail), grpc_status) + return JSONResponse(status_code=exc.status_code, content=err.model_dump(mode="json")) + + @app.exception_handler(RequestValidationError) + async def gemini_validation_exception_handler(request: Request, exc: RequestValidationError): + """Convert Gemini-route 422 validation errors into Google API error format.""" + if request.url.path.startswith("/v1beta/"): + detail = str(exc.errors()) if exc.errors() else str(exc) + err = GeminiErrorResponse( + error=GeminiErrorDetail( + code=400, + message=f"Invalid request: {detail}", + status="INVALID_ARGUMENT", + ) + ) + return JSONResponse(status_code=400, content=err.model_dump(mode="json")) + return await request_validation_exception_handler(request, exc) + + +# --------------------------------------------------------------------------- +# Gemini ↔ 内部格式转换函数 +# --------------------------------------------------------------------------- + + +def _gemini_contents_to_messages( + contents: list[GeminiContent], + system_instruction: Any | None = None, +) -> list[AppMessage]: + """Convert Gemini contents + systemInstruction into internal AppMessage list. + + Handles: + - role="model" + functionCall → assistant message + tool_calls + - role="user"/"function" + functionResponse → tool messages + - multiple functionResponse parts → multiple tool messages + - multi-modal parts keep original order (text/image interleaved) + """ + messages: list[AppMessage] = [] + + if system_instruction: + sys_parts = ( + system_instruction.parts + if hasattr(system_instruction, "parts") + else (system_instruction.get("parts") or []) + ) + if sys_texts := [p.text for p in sys_parts if p.text]: + messages.append(AppMessage(role="system", content="\n".join(sys_texts))) + + # Track the previous assistant message's tool_call IDs for functionResponse mapping + last_tool_call_ids: dict[str, str] = {} # function_name → call_id + + for content in contents: + role = content.role or "user" + parts = content.parts or [] + + internal_role = cast(Literal["system", "user", "assistant", "tool"], role) + if role == "model": + internal_role = "assistant" + elif role == "function": + internal_role = "tool" + + text_fragments: list[str] = [] + content_items: list[AppContentItem] = [] + tool_calls: list[AppToolCall] = [] + function_responses: list[tuple[str | None, str]] = [] # (name, content_json) + + for part in parts: + if part.text is not None: + text_fragments.append(part.text) + + if part.inlineData: + data_url = f"data:{part.inlineData.mimeType};base64,{part.inlineData.data}" + content_items.append(AppContentItem(type="image_url", url=data_url)) + + if part.fileData: + # Our pipeline only accepts base64 file_data items; a Google Files API + # fileUri cannot be fetched locally. Log and skip (disclosed in C1 report). + logger.warning( + "[Gemini API] Skipping fileData part " + f"(unsupported): {reprlib.repr(part.fileData.fileUri)}" + ) + + if part.functionCall: + call_id = f"call_{uuid.uuid4().hex[:24]}" + tool_calls.append( + AppToolCall( + id=call_id, + type="function", + function=AppToolCallFunction( + name=part.functionCall.name, + arguments=( + orjson.dumps(part.functionCall.args).decode("utf-8") + if part.functionCall.args + else "{}" + ), + ), + ) + ) + last_tool_call_ids[part.functionCall.name] = call_id + + if part.functionResponse: + resp_content = orjson.dumps(part.functionResponse.response).decode("utf-8") + function_responses.append((part.functionResponse.name, resp_content)) + + if function_responses: + # functionResponse → tool messages regardless of original role + for fn_name, fn_content in function_responses: + call_id = last_tool_call_ids.get(fn_name or "", f"call_{uuid.uuid4().hex[:24]}") + messages.append( + AppMessage( + role="tool", + content=fn_content, + name=fn_name, + tool_call_id=call_id, + ) + ) + if text_fragments and internal_role != "tool": + messages.append(AppMessage(role=internal_role, content="\n".join(text_fragments))) + elif tool_calls: + msg_content = "\n".join(text_fragments) if text_fragments else None + messages.append( + AppMessage(role="assistant", content=msg_content, tool_calls=tool_calls) + ) + elif content_items or text_fragments: + if content_items: + # keep original interleaved text/image order + ordered_items: list[AppContentItem] = [] + text_idx, media_idx = 0, 0 + for part in parts: + if part.text is not None and text_idx < len(text_fragments): + ordered_items.append( + AppContentItem(type="text", text=text_fragments[text_idx]) + ) + text_idx += 1 + elif (part.inlineData or part.fileData) and media_idx < len(content_items): + ordered_items.append(content_items[media_idx]) + media_idx += 1 + messages.append(AppMessage(role=internal_role, content=ordered_items)) + else: + messages.append(AppMessage(role=internal_role, content="\n".join(text_fragments))) + else: + # empty parts: still create a message to keep conversation structure + messages.append(AppMessage(role=internal_role, content="")) + + return messages + + +def _gemini_tools_to_internal( + tools: list[Any] | None, + tool_config: Any | None = None, +) -> tuple[ + list[FunctionTool] | None, + Literal["none", "auto", "required"] | ToolChoiceFunction | None, +]: + """Convert Gemini tools + toolConfig into internal FunctionTool list and tool_choice.""" + if not tools: + return None, None + + internal_tools: list[FunctionTool] = [] + for tool in tools: + internal_tools.extend( + FunctionTool( + type="function", + name=decl.name, + description=decl.description, + parameters=decl.parameters, + ) + for decl in tool.functionDeclarations or [] + ) + tool_choice: Literal["none", "auto", "required"] | ToolChoiceFunction | None = None + if tool_config and tool_config.functionCallingConfig: + mode = tool_config.functionCallingConfig.mode.upper() + if mode == "NONE": + tool_choice = cast(Literal["none", "auto", "required"], "none") + elif mode == "ANY": + tool_choice = cast(Literal["none", "auto", "required"], "required") + else: # AUTO + tool_choice = "auto" + + return internal_tools or None, tool_choice + + +def _to_gemini_response( + visible_text: str | None, + tool_calls: list[Any], + thoughts: str | None, + usage_tuple: tuple[int, int, int, int], + model_name: str, + image_parts: list[GeminiPart] | None = None, +) -> GeminiGenerateContentResponse: + """Convert the internal processing result into a Gemini API response.""" + parts: list[GeminiPart] = [] + + if thoughts: + parts.append(GeminiPart(text=thoughts, thought=True)) + + if visible_text: + parts.append(GeminiPart(text=visible_text)) + + if image_parts: + parts.extend(image_parts) + + for tc in tool_calls: + fn = tc.function if hasattr(tc, "function") else tc.get("function", {}) + fn_name = fn.name if hasattr(fn, "name") else fn.get("name", "") + fn_args_raw = fn.arguments if hasattr(fn, "arguments") else fn.get("arguments", "{}") + try: + fn_args = orjson.loads(fn_args_raw) if isinstance(fn_args_raw, str) else fn_args_raw + except orjson.JSONDecodeError: + fn_args = {} + parts.append(GeminiPart(functionCall=GeminiFunctionCall(name=fn_name, args=fn_args))) + + finish_reason = "STOP" + p_tok, c_tok, t_tok, r_tok = usage_tuple + + candidate = GeminiCandidate( + content=GeminiContent(role="model", parts=parts), + finishReason=finish_reason, + index=0, + ) + + usage_meta = GeminiUsageMetadata( + promptTokenCount=p_tok, + candidatesTokenCount=c_tok - r_tok, + totalTokenCount=t_tok, + thoughtsTokenCount=r_tok if r_tok > 0 else None, + ) + + return GeminiGenerateContentResponse( + candidates=[candidate], + usageMetadata=usage_meta, + modelVersion=model_name, + ) + + +def _to_gemini_error(status_code: int, message: str, grpc_status: str) -> GeminiErrorResponse: + """Build a Google API standard error response.""" + return GeminiErrorResponse( + error=GeminiErrorDetail( + code=status_code, + message=message, + status=grpc_status, + ) + ) + + +def _validate_gemini_request(request: GeminiGenerateContentRequest) -> str | None: + """Reject only structures that would otherwise be silently dropped from the prompt.""" + if not request.contents: + return "contents is required and cannot be empty." + + for content in request.contents: + if not content.parts: + return "Each content entry must contain at least one part." + if any(part.fileData is not None for part in content.parts): + return "fileData is not supported; provide the data using inlineData instead." + if request.systemInstruction and any( + part.fileData is not None for part in request.systemInstruction.parts + ): + return "fileData is not supported; provide the data using inlineData instead." + return None + + +def _strip_model_prefix(model: str) -> str: + """Strip a leading 'models/' prefix if present.""" + return model[len("models/") :] if model.startswith("models/") else model + + +def _model_data_to_gemini_info(model_data: Any) -> GeminiModelInfo: + """Convert internal ModelData into GeminiModelInfo.""" + return GeminiModelInfo( + name=f"models/{model_data.id}", + displayName=model_data.id, + description=f"Gemini model: {model_data.id}", + supportedGenerationMethods=["generateContent", "streamGenerateContent"], + ) + + +# --------------------------------------------------------------------------- +# 路由端点 +# --------------------------------------------------------------------------- + + +@router.get("/v1beta/models") +async def gemini_list_models(api_key: str = Depends(verify_gemini_api_key)): + """List available models (Gemini API format).""" + models = await _get_available_models(GeminiClientPool()) + + logger.info(f"[Gemini API] Retrieved {len(models)} models") + if not models: + logger.warning("[Gemini API] Model list is empty") + + gemini_models = [_model_data_to_gemini_info(m) for m in models] + return GeminiModelListResponse(models=gemini_models) + + +@router.get("/v1beta/models/{model:path}") +async def gemini_get_model(model: str, api_key: str = Depends(verify_gemini_api_key)): + """Get one model's info (Gemini API format).""" + model_name = _strip_model_prefix(model) + try: + _resolve_model_name(GeminiClientPool(), model_name) + except ValueError as exc: + err = _to_gemini_error(404, str(exc), "NOT_FOUND") + return JSONResponse(status_code=404, content=err.model_dump(mode="json")) + + all_models = await _get_available_models(GeminiClientPool()) + for m in all_models: + if m.id == model_name: + return _model_data_to_gemini_info(m) + + # Model exists but not listed (e.g. resolved directly from gemini-webapi constants) + return GeminiModelInfo( + name=f"models/{model_name}", + displayName=model_name, + description=f"Gemini model: {model_name}", + supportedGenerationMethods=["generateContent", "streamGenerateContent"], + ) + + +@router.post("/v1beta/models/{model:path}:generateContent") +async def gemini_generate_content( + model: str, + request: GeminiGenerateContentRequest, + raw_request: Request, + api_key: str = Depends(verify_gemini_api_key), + tmp_dir: Path = Depends(get_temp_dir), +): + """Non-streaming content generation (Gemini API format).""" + model_name = _strip_model_prefix(model) + + try: + model_obj = _resolve_model_name(GeminiClientPool(), model_name) + except ValueError as exc: + err = _to_gemini_error(400, str(exc), "INVALID_ARGUMENT") + return JSONResponse(status_code=400, content=err.model_dump(mode="json")) + + if validation_error := _validate_gemini_request(request): + err = _to_gemini_error(400, validation_error, "INVALID_ARGUMENT") + return JSONResponse(status_code=400, content=err.model_dump(mode="json")) + + messages = _gemini_contents_to_messages(request.contents, request.systemInstruction) + + internal_tools, tool_choice = _gemini_tools_to_internal(request.tools, request.toolConfig) + + structured_requirement = None + if request.generationConfig: + gen_cfg = request.generationConfig + schema = gen_cfg.responseSchema or gen_cfg.responseJsonSchema + if gen_cfg.responseMimeType == "application/json" and schema: + structured_requirement = _build_structured_requirement( + {"type": "json_schema", "json_schema": {"schema": schema}} + ) + + extra_instr = [structured_requirement.instruction] if structured_requirement else None + + msgs = _prepare_messages_for_model(messages, internal_tools, tool_choice, extra_instr) + + pool, db = GeminiClientPool(), LMDBConversationStore() + + session, client, remain, _conv = await _find_reusable_session(db, pool, model_obj, msgs) + + if session: + if not remain: + err = _to_gemini_error(400, "No new messages to send.", "INVALID_ARGUMENT") + return JSONResponse(status_code=400, content=err.model_dump(mode="json")) + + input_msgs = _prepare_messages_for_model( + remain, internal_tools, tool_choice, extra_instr, False + ) + m_input, files = await GeminiClientWrapper.process_conversation(input_msgs, tmp_dir) + logger.debug( + f"[Gemini API] Reusing session {reprlib.repr(session.metadata)}" + f" - sending {len(input_msgs)} message(s)." + ) + else: + try: + client = await pool.acquire() + session = client.start_chat(model=model_obj) + m_input, files = await GeminiClientWrapper.process_conversation(msgs, tmp_dir) + except Exception as e: + logger.exception("[Gemini API] Failed to prepare session") + err = _to_gemini_error(503, str(e), "UNAVAILABLE") + return JSONResponse(status_code=503, content=err.model_dump(mode="json")) + + try: + assert session is not None + assert client is not None + logger.debug( + f"[Gemini API] Client: {client.id}, input len: {len(m_input)}, files: {len(files)}" + ) + resp = await _send_with_split( + session, m_input, files=cast("list[Path | str | io.BytesIO]", files), stream=False + ) + except Exception as e: + logger.exception("[Gemini API] Gemini call failed") + err = _to_gemini_error(502, str(e), "INTERNAL") + return JSONResponse(status_code=502, content=err.model_dump(mode="json")) + + try: + assert isinstance(resp, ModelOutput) + thoughts = normalize_llm_text(resp.thoughts or "") + raw_clean = normalize_llm_text(resp.text or "") + except Exception: + logger.exception("[Gemini API] Output parsing failed") + err = _to_gemini_error(502, "Malformed response.", "INTERNAL") + return JSONResponse(status_code=502, content=err.model_dump(mode="json")) + + thoughts, visible_output, storage_output, tool_calls = process_llm_output( + thoughts, raw_clean, structured_requirement + ) + + # Images: collect Gemini images → inlineData parts + markdown URL for LMDB persistence + image_parts: list[GeminiPart] = [] + seen_hashes: set[str] = set() + image_store = get_media_store_dir() + base_url = str(raw_request.base_url).rstrip("/") + for image in resp.images or []: + try: + b64_str, _w, _h, fname, file_hash = await _image_to_base64(image, image_store) + if file_hash in seen_hashes: + (image_store / fname).unlink(missing_ok=True) + continue + seen_hashes.add(file_hash) + suffix = Path(fname).suffix.lower() + mime_map = { + ".png": "image/png", + ".jpg": "image/jpeg", + ".jpeg": "image/jpeg", + ".webp": "image/webp", + ".gif": "image/gif", + } + mime_type = mime_map.get(suffix, "image/png") + image_parts.append( + GeminiPart(inlineData=GeminiInlineData(mimeType=mime_type, data=b64_str)) + ) + token = get_media_token(fname) + img_url = f"{base_url}/media/{fname}?token={token}" + storage_output += f"\n\n![{fname}]({img_url})" + except Exception as exc: + logger.warning(f"[Gemini API] Failed to process image: {exc}") + + usage_tuple = calculate_usage(messages, visible_output, tool_calls, thoughts) + + _persist_conversation( + db, + model_obj, + client, + session.metadata, + msgs, + storage_output, + tool_calls, + ) + + return _to_gemini_response( + visible_output, tool_calls, thoughts, usage_tuple, model_name, image_parts + ) + + +@router.post("/v1beta/models/{model:path}:streamGenerateContent") +async def gemini_stream_generate_content( + model: str, + request: GeminiGenerateContentRequest, + raw_request: Request, + api_key: str = Depends(verify_gemini_api_key), + tmp_dir: Path = Depends(get_temp_dir), +): + """Streaming content generation (Gemini API format, SSE).""" + model_name = _strip_model_prefix(model) + + try: + model_obj = _resolve_model_name(GeminiClientPool(), model_name) + except ValueError as exc: + err = _to_gemini_error(400, str(exc), "INVALID_ARGUMENT") + return JSONResponse(status_code=400, content=err.model_dump(mode="json")) + + if validation_error := _validate_gemini_request(request): + err = _to_gemini_error(400, validation_error, "INVALID_ARGUMENT") + return JSONResponse(status_code=400, content=err.model_dump(mode="json")) + + messages = _gemini_contents_to_messages(request.contents, request.systemInstruction) + internal_tools, tool_choice = _gemini_tools_to_internal(request.tools, request.toolConfig) + + structured_requirement = None + if request.generationConfig: + gen_cfg = request.generationConfig + schema = gen_cfg.responseSchema or gen_cfg.responseJsonSchema + if gen_cfg.responseMimeType == "application/json" and schema: + structured_requirement = _build_structured_requirement( + {"type": "json_schema", "json_schema": {"schema": schema}} + ) + + extra_instr = [structured_requirement.instruction] if structured_requirement else None + msgs = _prepare_messages_for_model(messages, internal_tools, tool_choice, extra_instr) + + pool, db = GeminiClientPool(), LMDBConversationStore() + session, client, remain, _conv = await _find_reusable_session(db, pool, model_obj, msgs) + + if session: + if not remain: + err = _to_gemini_error(400, "No new messages to send.", "INVALID_ARGUMENT") + return JSONResponse(status_code=400, content=err.model_dump(mode="json")) + + input_msgs = _prepare_messages_for_model( + remain, internal_tools, tool_choice, extra_instr, False + ) + m_input, files = await GeminiClientWrapper.process_conversation(input_msgs, tmp_dir) + else: + try: + client = await pool.acquire() + session = client.start_chat(model=model_obj) + m_input, files = await GeminiClientWrapper.process_conversation(msgs, tmp_dir) + except Exception as e: + logger.exception("[Gemini API] Failed to prepare streaming session") + err = _to_gemini_error(503, str(e), "UNAVAILABLE") + return JSONResponse(status_code=503, content=err.model_dump(mode="json")) + + try: + assert session is not None + assert client is not None + generator = await _send_with_split( + session, m_input, files=cast("list[Path | str | io.BytesIO]", files), stream=True + ) + except Exception as e: + logger.exception("[Gemini API] Gemini streaming call failed") + err = _to_gemini_error(502, str(e), "INTERNAL") + return JSONResponse(status_code=502, content=err.model_dump(mode="json")) + + return _create_gemini_streaming_response( + generator=generator, + model_name=model_name, + messages=msgs, + original_messages=messages, + db=db, + model=model_obj, + client_wrapper=client, + session=session, + structured_requirement=structured_requirement, + base_url=str(raw_request.base_url).rstrip("/"), + ) + + +def _create_gemini_streaming_response( + generator, + model_name: str, + messages: list[AppMessage], + original_messages: list[AppMessage], + db: LMDBConversationStore, + model, + client_wrapper: GeminiClientWrapper, + session, + structured_requirement=None, + base_url: str = "", +) -> StreamingResponse: + """Create a Gemini-format SSE streaming response.""" + + async def generate_stream(): + full_thoughts, full_text = "", "" + last_chunk: ModelOutput | None = None + all_images: list[Any] = [] # images from all chunks (url-deduped) + seen_image_urls: set[str] = set() + suppressor = StreamingOutputFilter() + + try: + async for chunk in generator: + last_chunk = chunk + + if chunk.images: + for img in chunk.images: + if img.url not in seen_image_urls: + all_images.append(img) + seen_image_urls.add(img.url) + + if t_delta := chunk.thoughts_delta: + full_thoughts += t_delta + think_resp = GeminiGenerateContentResponse( + candidates=[ + GeminiCandidate( + content=GeminiContent( + role="model", + parts=[GeminiPart(text=t_delta, thought=True)], + ), + index=0, + ) + ], + ) + yield f"data: {orjson.dumps(think_resp.model_dump(mode='json', exclude_none=True)).decode('utf-8')}\n\n" + + if text_delta := chunk.text_delta: + full_text += text_delta + if visible_delta := suppressor.process(text_delta): + chunk_resp = GeminiGenerateContentResponse( + candidates=[ + GeminiCandidate( + content=GeminiContent( + role="model", + parts=[GeminiPart(text=visible_delta)], + ), + index=0, + ) + ], + ) + yield f"data: {orjson.dumps(chunk_resp.model_dump(mode='json', exclude_none=True)).decode('utf-8')}\n\n" + + except Exception as e: + logger.exception(f"[Gemini API] Streaming error: {e}") + err_resp = _to_gemini_error(500, "Streaming error occurred.", "INTERNAL") + yield f"data: {orjson.dumps(err_resp.model_dump(mode='json')).decode('utf-8')}\n\n" + return + + # Use the final chunk's full text if available + if last_chunk is not None: + if last_chunk.text: + full_text = last_chunk.text + if last_chunk.thoughts: + full_thoughts = last_chunk.thoughts + + if remaining_text := suppressor.flush(): + chunk_resp = GeminiGenerateContentResponse( + candidates=[ + GeminiCandidate( + content=GeminiContent( + role="model", + parts=[GeminiPart(text=remaining_text)], + ), + index=0, + ) + ], + ) + yield f"data: {orjson.dumps(chunk_resp.model_dump(mode='json', exclude_none=True)).decode('utf-8')}\n\n" + + # --- post-processing: protective layer so the SSE tail survives errors --- + try: + _thoughts, visible_output, storage_output, tool_calls = process_llm_output( + full_thoughts, full_text, structured_requirement + ) + + image_store = get_media_store_dir() + seen_hashes: set[str] = set() + for image in all_images: + try: + b64_str, _w, _h, fname, file_hash = await _image_to_base64(image, image_store) + if file_hash in seen_hashes: + (image_store / fname).unlink(missing_ok=True) + continue + seen_hashes.add(file_hash) + suffix = Path(fname).suffix.lower() + mime_map = { + ".png": "image/png", + ".jpg": "image/jpeg", + ".jpeg": "image/jpeg", + ".webp": "image/webp", + ".gif": "image/gif", + } + mime_type = mime_map.get(suffix, "image/png") + img_chunk = GeminiGenerateContentResponse( + candidates=[ + GeminiCandidate( + content=GeminiContent( + role="model", + parts=[ + GeminiPart( + inlineData=GeminiInlineData( + mimeType=mime_type, data=b64_str + ) + ) + ], + ), + index=0, + ) + ], + ) + yield f"data: {orjson.dumps(img_chunk.model_dump(mode='json', exclude_none=True)).decode('utf-8')}\n\n" + token = get_media_token(fname) + img_url = f"{base_url}/media/{fname}?token={token}" + storage_output += f"\n\n![{fname}]({img_url})" + except Exception as exc: + logger.warning(f"[Gemini API] Failed to process streaming image: {exc}") + # Final chunk (finishReason + usageMetadata) + usage_tuple = calculate_usage(original_messages, visible_output, tool_calls, _thoughts) + p_tok, c_tok, t_tok, r_tok = usage_tuple + + final_parts: list[GeminiPart] = [] + if tool_calls: + for tc in tool_calls: + tc_any: Any = tc + fn = ( + tc_any.function + if hasattr(tc_any, "function") + else tc_any.get("function", {}) + ) + fn_name = fn.name if hasattr(fn, "name") else fn.get("name", "") + fn_args_raw = ( + fn.arguments if hasattr(fn, "arguments") else fn.get("arguments", "{}") + ) + try: + fn_args = ( + orjson.loads(fn_args_raw) + if isinstance(fn_args_raw, str) + else fn_args_raw + ) + except orjson.JSONDecodeError: + fn_args = {} + final_parts.append( + GeminiPart(functionCall=GeminiFunctionCall(name=fn_name, args=fn_args)) + ) + + final_resp = GeminiGenerateContentResponse( + candidates=[ + GeminiCandidate( + content=GeminiContent(role="model", parts=final_parts) + if final_parts + else None, + finishReason="STOP", + index=0, + ) + ], + usageMetadata=GeminiUsageMetadata( + promptTokenCount=p_tok, + candidatesTokenCount=c_tok - r_tok, + totalTokenCount=t_tok, + thoughtsTokenCount=r_tok if r_tok > 0 else None, + ), + modelVersion=model_name, + ) + yield f"data: {orjson.dumps(final_resp.model_dump(mode='json', exclude_none=True)).decode('utf-8')}\n\n" + + _persist_conversation( + db, + model.model_name, + client_wrapper, + session.metadata, + messages, + storage_output, + tool_calls, + ) + except Exception as exc: + logger.exception(f"[Gemini API] Post-processing error: {exc}") + err_resp = _to_gemini_error(500, "Post-processing error.", "INTERNAL") + yield f"data: {orjson.dumps(err_resp.model_dump(mode='json')).decode('utf-8')}\n\n" + + return StreamingResponse( + generate_stream(), + media_type="text/event-stream", + headers={ + "Cache-Control": "no-cache", + "Connection": "keep-alive", + "X-Accel-Buffering": "no", + }, + ) diff --git a/app/server/middleware.py b/app/server/middleware.py index 57bb258..cad5423 100644 --- a/app/server/middleware.py +++ b/app/server/middleware.py @@ -86,6 +86,36 @@ def get_temp_dir(): temp_dir.cleanup() +def verify_gemini_api_key(request: Request): + """Gemini-style auth: x-goog-api-key header, key= query param, or Bearer fallback.""" + if not g_config.server.api_key: + return "" + + # 1) x-goog-api-key header + api_key = request.headers.get("x-goog-api-key") + if api_key: + if api_key != g_config.server.api_key: + raise HTTPException(status.HTTP_403_FORBIDDEN, detail="Wrong API key") + return api_key + + # 2) key= query parameter + api_key = request.query_params.get("key") + if api_key: + if api_key != g_config.server.api_key: + raise HTTPException(status.HTTP_403_FORBIDDEN, detail="Wrong API key") + return api_key + + # 3) fall back to Authorization: Bearer + auth_header = request.headers.get("authorization", "") + if auth_header.lower().startswith("bearer "): + api_key = auth_header[7:] + if api_key != g_config.server.api_key: + raise HTTPException(status.HTTP_403_FORBIDDEN, detail="Wrong API key") + return api_key + + raise HTTPException(status.HTTP_401_UNAUTHORIZED, detail="Invalid or missing API key") + + def verify_api_key( credentials: HTTPAuthorizationCredentials = Depends(HTTPBearer(auto_error=False)), ): diff --git a/app/services/pool.py b/app/services/pool.py index dfc6c27..9ebdcdc 100644 --- a/app/services/pool.py +++ b/app/services/pool.py @@ -32,14 +32,23 @@ def __init__(self) -> None: self._round_robin.append(client) self._restart_locks[c.id] = asyncio.Lock() + async def _init_one(self, client: GeminiClientWrapper) -> bool: + """Initialize a single client; returns True on success.""" + return await self._init_attempt(client) + + async def _init_attempt(self, client: GeminiClientWrapper) -> bool: + """Run library init; returns True on success.""" + try: + await client.init() + return True + except Exception: + return False + async def init(self) -> None: """Initialize all clients in the pool with staggered start times.""" clients_to_init = [c for c in self._clients if not c.running()] for i, client in enumerate(clients_to_init): - try: - await client.init() - except Exception: - logger.error(f"Failed to initialize client {client.id}") + await self._init_one(client) if i < len(clients_to_init) - 1: delay = random.uniform(5, 30) @@ -112,13 +121,10 @@ async def _ensure_client_ready(self, client: GeminiClientWrapper) -> bool: if client.running(): return True - try: - await client.init() + if await self._init_attempt(client): logger.info(f"Restarted Gemini client {client.id} after it stopped.") return True - except Exception: - logger.exception(f"Failed to restart Gemini client {client.id}") - return False + return False @property def clients(self) -> list[GeminiClientWrapper]: diff --git a/app/utils/config.py b/app/utils/config.py index 3305f67..5de9176 100644 --- a/app/utils/config.py +++ b/app/utils/config.py @@ -116,6 +116,16 @@ class GeminiConfig(BaseModel): "reply can then come back without the earlier context instead of erroring" ), ) + allow_private_url_fetch: bool = Field( + default=False, + description="Allow server-side fetching of private/loopback image URLs (SSRF risk; default blocks them)", + ) + url_fetch_timeout: int = Field( + default=15, + ge=1, + le=120, + description="Timeout in seconds for server-side URL image fetches", + ) class CORSConfig(BaseModel): diff --git a/app/utils/helper.py b/app/utils/helper.py index 045726d..5f17206 100644 --- a/app/utils/helper.py +++ b/app/utils/helper.py @@ -1,9 +1,11 @@ import base64 import hashlib import html +import ipaddress import mimetypes import re import reprlib +import socket import struct import tempfile import unicodedata @@ -30,6 +32,9 @@ ToolChoiceFunction, ToolChoiceTypes, ) +from app.utils import g_config + +MAX_REMOTE_FETCH_BYTES = 20 * 1024 * 1024 type JsonValue = bool | int | float | str | list[JsonValue] | dict[str, JsonValue] | None @@ -224,10 +229,44 @@ async def save_file_to_tempfile( return Path(tmp.name) +def reject_unsafe_url(url: str) -> None: + """Reject remote URLs that could target internal/private networks (SSRF guard). + + Allows only http/https. When `gemini.allow_private_url_fetch` is false (default), + any resolved address that is loopback, RFC1918-private, link-local, reserved, + multicast or unspecified is refused. DNS-rebinding TOCTOU between resolve and + fetch is a known residual; the opt-out knob exists for localhost-served images. + """ + parsed = urlparse(url) + if parsed.scheme not in ("http", "https"): + raise ValueError(f"Unsupported URL scheme: {parsed.scheme!r} (only http/https allowed)") + + host = parsed.hostname + if not host: + raise ValueError("URL must include a hostname") + + if g_config.gemini.allow_private_url_fetch: + return + + try: + addrinfos = socket.getaddrinfo(host, None) + except socket.gaierror as e: + raise ValueError(f"Could not resolve host {host!r}: {e}") from e + + for addrinfo in addrinfos: + ip = ipaddress.ip_address(addrinfo[4][0]) + # `is_global` covers private, loopback, link-local, reserved, unspecified, + # documentation and shared-address ranges. Multicast is the exception: + # ipaddress reports it as global even though it is not a valid fetch target. + if not ip.is_global or ip.is_multicast: + raise ValueError( + f"Refusing to fetch private/reserved address {ip} for host {host!r} " + "(set gemini.allow_private_url_fetch=true to override)" + ) + + async def save_url_to_tempfile(url: str, tempdir: Path | None = None) -> Path: """Download content from a URL and save to a temporary file.""" - data: bytes | None = None - suffix: str | None = None if url.startswith("data:"): metadata_part = url.split(",")[0] mime_type = metadata_part.split(":")[1].split(";")[0] @@ -235,21 +274,52 @@ async def save_url_to_tempfile(url: str, tempdir: Path | None = None) -> Path: suffix = mimetypes.guess_extension(mime_type) or ( f".{mime_type.split('/')[1]}" if "/" in mime_type else ".bin" ) - else: - async with requests.AsyncSession( - impersonate="chrome", allow_redirects=CurlFollow.SAFE, http_version=CurlHttpVersion.NONE - ) as client: - resp = await client.get(url) - resp.raise_for_status() - data = resp.content - if content_type := resp.headers.get("content-type"): - suffix = mimetypes.guess_extension(content_type.split(";")[0].strip()) - if not suffix: - suffix = Path(urlparse(url).path).suffix or ".bin" - - with tempfile.NamedTemporaryFile(delete=False, suffix=suffix, dir=tempdir) as tmp: - tmp.write(data) - return Path(tmp.name) + with tempfile.NamedTemporaryFile(delete=False, suffix=suffix, dir=tempdir) as tmp: + tmp.write(data) + return Path(tmp.name) + + reject_unsafe_url(url) + url_suffix = Path(urlparse(url).path).suffix or ".bin" + downloaded = 0 + temp_path: Path | None = None + + try: + with tempfile.NamedTemporaryFile(delete=False, suffix=url_suffix, dir=tempdir) as tmp: + temp_path = Path(tmp.name) + + def receive_chunk(chunk: bytes) -> None: + nonlocal downloaded + downloaded += len(chunk) + if downloaded > MAX_REMOTE_FETCH_BYTES: + raise ValueError(f"Remote fetch exceeded {MAX_REMOTE_FETCH_BYTES} bytes: {url}") + tmp.write(chunk) + + async with requests.AsyncSession( + impersonate="chrome", + allow_redirects=CurlFollow.SAFE, + http_version=CurlHttpVersion.NONE, + timeout=g_config.gemini.url_fetch_timeout, + ) as client: + resp = await client.get(url, content_callback=receive_chunk) + resp.raise_for_status() + content_type = resp.headers.get("content-type") + + suffix = ( + mimetypes.guess_extension(content_type.split(";")[0].strip()) + if content_type + else None + ) + except Exception: + if temp_path is not None: + temp_path.unlink(missing_ok=True) + raise + + assert temp_path is not None + if suffix and suffix != temp_path.suffix: + final_path = temp_path.with_suffix(suffix) + temp_path.rename(final_path) + return final_path + return temp_path def strip_tagged_blocks(text: str) -> str: diff --git a/config/config.yaml b/config/config.yaml index fc2ef85..d91e165 100644 --- a/config/config.yaml +++ b/config/config.yaml @@ -32,6 +32,8 @@ gemini: verbose: true # Enable verbose logging for Gemini requests extended_thinking: false # Enable Gemini extended thinking mode for message generation max_chars_per_request: 1000000 # Maximum characters Gemini Web accepts per request. Non-pro users might have a lower limit + allow_private_url_fetch: false # Allow server-side fetching of private/loopback image URLs (SSRF risk; default blocks them) + url_fetch_timeout: 15 # Timeout in seconds for server-side URL image fetches # "normal" uses standard Google chats; "temporary" uses Google's temporary mode (not saved to the account) with a tighter input limit. # WARNING: Google may close a temporary window at any time mid-conversation. The reply can then come back without the earlier # context instead of erroring, so the loss may be silent. Prefer "normal" for long or context-sensitive conversations. diff --git a/pyproject.toml b/pyproject.toml index fb615a0..6c37720 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -13,7 +13,7 @@ dependencies = [ "loguru>=0.7.3", "orjson>=3.11.9", "pydantic-settings[yaml]>=2.15.0", - "uvicorn>=0.52.2", + "uvicorn>=0.52.3", "uvloop>=0.22.1; sys_platform != 'win32'", ] diff --git a/scripts/start-gemini-api.sh b/scripts/start-gemini-api.sh new file mode 100755 index 0000000..63b55d8 --- /dev/null +++ b/scripts/start-gemini-api.sh @@ -0,0 +1,62 @@ +#!/bin/bash +# start-gemini-api.sh — launch/stop the local Gemini-FastAPI server for opencode2. +# Usage: ./start-gemini-api.sh start | stop | status +set -u +REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +PORT="${GEMINI_API_PORT:-8000}" +HEALTH_URL="http://127.0.0.1:${PORT}/health" +PID_FILE="${TMPDIR:-/tmp}/gemini-api.pid" +LOG_FILE="${TMPDIR:-/tmp}/gemini-api.log" + +start() { + if [ -f "$PID_FILE" ] && kill -0 "$(cat "$PID_FILE")" 2>/dev/null; then + echo "gemini-api already running (pid $(cat "$PID_FILE"), $HEALTH_URL)" + exit 0 + fi + echo "Starting gemini-api from $REPO ..." + ( cd "$REPO" && exec uv run python run.py ) >"$LOG_FILE" 2>&1 & + echo $! > "$PID_FILE" + for _ in $(seq 1 "${GEMINI_START_TIMEOUT_LOOPS:-40}"); do + code=$(curl -s -m 2 -o /dev/null -w "%{http_code}" "$HEALTH_URL" 2>/dev/null) + if [ "$code" = "200" ] && kill -0 "$(cat "$PID_FILE")" 2>/dev/null; then + echo "gemini-api healthy at $HEALTH_URL (pid $(cat "$PID_FILE"))" + exit 0 + fi + sleep 3 + done + if [ -f "$PID_FILE" ] && kill -0 "$(cat "$PID_FILE")" 2>/dev/null; then + kill "$(cat "$PID_FILE")" 2>/dev/null + fi + echo "ERROR: gemini-api did not become healthy within the timeout (${GEMINI_START_TIMEOUT_LOOPS:-40} x 3s). Log: $LOG_FILE" >&2 + tail -5 "$LOG_FILE" >&2 + exit 1 +} + +stop() { + if [ -f "$PID_FILE" ] && kill -0 "$(cat "$PID_FILE")" 2>/dev/null; then + kill "$(cat "$PID_FILE")" + for _ in $(seq 1 10); do + kill -0 "$(cat "$PID_FILE")" 2>/dev/null || break + sleep 1 + done + rm -f "$PID_FILE" + echo "gemini-api stopped" + else + echo "gemini-api not running" + fi +} + +status() { + if [ -f "$PID_FILE" ] && kill -0 "$(cat "$PID_FILE")" 2>/dev/null; then + curl -s -m 3 "$HEALTH_URL" && echo && echo "pid $(cat "$PID_FILE")" + else + echo "gemini-api not running" + fi +} + +case "${1:-start}" in + start) start ;; + stop) stop ;; + status) status ;; + *) echo "usage: $0 start|stop|status" >&2; exit 2 ;; +esac diff --git a/uv.lock b/uv.lock index e89cc3b..52340bd 100644 --- a/uv.lock +++ b/uv.lock @@ -169,7 +169,7 @@ requires-dist = [ { name = "pyright", marker = "extra == 'dev'" }, { name = "ruff", marker = "extra == 'dev'" }, { name = "ty", marker = "extra == 'dev'" }, - { name = "uvicorn", specifier = ">=0.52.2" }, + { name = "uvicorn", specifier = ">=0.52.3" }, { name = "uvloop", marker = "sys_platform != 'win32'", specifier = ">=0.22.1" }, ] provides-extras = ["dev"] @@ -179,8 +179,8 @@ dev = [{ name = "gemini-fastapi", extras = ["dev"] }] [[package]] name = "gemini-webapi" -version = "0.0.post261" -source = { git = "https://github.com/luuquangvu/Gemini-API.git?rev=enable-guest-mode#e0deb8a231dd004282d8493df748d4bee5f984d1" } +version = "0.0.post262" +source = { git = "https://github.com/luuquangvu/Gemini-API.git?rev=enable-guest-mode#b38110e82fe0e41bbf53a03646ef87ddc88a996a" } dependencies = [ { name = "curl-cffi" }, { name = "loguru" }, @@ -427,27 +427,27 @@ wheels = [ [[package]] name = "ty" -version = "0.0.71" +version = "0.0.72" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/dd/e2/f6e716371b5913a31190db1ad250ac2b5c68b3ca2db71afeba3f98f5fe50/ty-0.0.71.tar.gz", hash = "sha256:c2a24f2745294946c27cef8cc012b84fb2db5405ecefddbe845be4162833da01", size = 6624721, upload-time = "2026-08-13T00:39:31.035Z" } +sdist = { url = "https://files.pythonhosted.org/packages/d5/df/656e684bafb13c1d146e7d5b5f3e7978ca177232acc84998ff36427e9462/ty-0.0.72.tar.gz", hash = "sha256:ec2b8066b618df18cab4cb8e992f8da45d360332acb23fa34df7fa29cd1b9d3a", size = 6654939, upload-time = "2026-08-14T21:35:42.612Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/4a/e4/8d6d17827c5335d0efe54241fb54fed3cb6ca2d2eca62f3f2382be196b91/ty-0.0.71-py3-none-linux_armv6l.whl", hash = "sha256:a309c9a35e69f45d7053e9205c7fe2295fac09e07e3a2fdb791524f30440a9cc", size = 12576435, upload-time = "2026-08-13T00:38:50.617Z" }, - { url = "https://files.pythonhosted.org/packages/d5/cb/d4c48832ee3d162abc6c834ba62242ec27630cb93014a0e1be82e86c3ee3/ty-0.0.71-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:2d42e9ae4b754ce1f34dd1b545f1cdcfc8e704d7460b584c898be156f048828f", size = 12177806, upload-time = "2026-08-13T00:38:53.068Z" }, - { url = "https://files.pythonhosted.org/packages/f0/97/e04782c9eaa1a0b634830f61b0e18cd1b3415332143d45a28882802c8ea1/ty-0.0.71-py3-none-macosx_11_0_arm64.whl", hash = "sha256:4d0b1f2002adc03f3a53aeb70b5cafcea634bb48726d82a307b0f15580d2f74b", size = 12015265, upload-time = "2026-08-13T00:38:55.209Z" }, - { url = "https://files.pythonhosted.org/packages/4f/5c/d716b9049c11a7b85b3b74ec3547d2dbab9a261347a6558420dcae37083d/ty-0.0.71-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:165e6086363f5c149ae4acacfbc36eea0093552a60f4153ef48321e4a3a15c4c", size = 12119608, upload-time = "2026-08-13T00:38:57.336Z" }, - { url = "https://files.pythonhosted.org/packages/b8/78/560ce2d874467d50605b7783e19e45b571fea7a89f664bd0ccdf252adf8a/ty-0.0.71-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:067e16f80855afbda024e7ff6fa5ed3ada5290c403badc63800f0534bd617c04", size = 12353762, upload-time = "2026-08-13T00:38:59.729Z" }, - { url = "https://files.pythonhosted.org/packages/a3/7b/f6a22c0bf2c0dbfe20b7ce90b51bc09efa9a9e245f1c35e160e39ed5dea9/ty-0.0.71-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:416149a550bcb678e619b4e2cd1cca9066d28edc52df76ad9d3640b36e6b5bf1", size = 13088120, upload-time = "2026-08-13T00:39:02.34Z" }, - { url = "https://files.pythonhosted.org/packages/a3/95/6f5382781f2e4fa933296d303376c61b8b3475d58ed60125c54de665fd21/ty-0.0.71-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:39e04c41e6d0e74f73cf8b3247d4dde3642ec38aeda7f4674110155b3da416a5", size = 13545140, upload-time = "2026-08-13T00:39:04.655Z" }, - { url = "https://files.pythonhosted.org/packages/6d/e7/8f0ad7b6c4804f8682e08fb161eaf710dd1765006b1b6df7c657b9707c95/ty-0.0.71-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7fb696bdea4a3554a0d0a7bbb36e4c5f508acb2bbad435e91c7fc812c1de6bfb", size = 13266730, upload-time = "2026-08-13T00:39:06.858Z" }, - { url = "https://files.pythonhosted.org/packages/ac/06/b83158fdf1473c2486fba0de337a963e9cc21317ccaa3e54ab003d419737/ty-0.0.71-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e3ec4ca9d4ba3e11ecc282c3d50d0730a2e181da7142734219d9f213fcbaaa00", size = 12673786, upload-time = "2026-08-13T00:39:09.281Z" }, - { url = "https://files.pythonhosted.org/packages/7c/9a/10f37c0550277722ac1d2c8096e492e0f3fc1aa2e587082439dd066fdb8d/ty-0.0.71-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:48a231253b32639ff4b19f74e476bacdba0150182603011d3792d1f1a335b932", size = 13140294, upload-time = "2026-08-13T00:39:11.659Z" }, - { url = "https://files.pythonhosted.org/packages/3b/e6/5710de1da7eb8aa755d289aa25316691e545b72b4ee2ab14172612eec1c9/ty-0.0.71-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:1a082f57d1fcbe209afdcacff37870defd4cea15ce69e2c6c652a9208462722f", size = 12171216, upload-time = "2026-08-13T00:39:14.382Z" }, - { url = "https://files.pythonhosted.org/packages/80/c5/8d9113e3cf0d4c6c0a9c9e088cee93e41facb278026bea6f6e12533b09dd/ty-0.0.71-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:cbe1c962f6c9e8964180cd171cc6ad35d687f74d51c07f60b670d3f1c5d58ffe", size = 12360626, upload-time = "2026-08-13T00:39:16.747Z" }, - { url = "https://files.pythonhosted.org/packages/7d/a0/0e167507c4c863814d3ea7602bde958baefd03346bd38bae3a430dff1ea9/ty-0.0.71-py3-none-musllinux_1_2_i686.whl", hash = "sha256:25f641b988916b3975e50b2a59cbc5179f2a466d181f207f3032e1e6bc617a88", size = 12637309, upload-time = "2026-08-13T00:39:19.14Z" }, - { url = "https://files.pythonhosted.org/packages/1d/c6/ff6719b91e4916985e9a92f9bddb10d9fe3cb3bdf5abf0f9019a67aebe21/ty-0.0.71-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:c5fe916b6e5b152ef4f583324e1efd770ac3316894776dc1b57c354cb767b8ee", size = 12937996, upload-time = "2026-08-13T00:39:21.533Z" }, - { url = "https://files.pythonhosted.org/packages/ee/3e/21a0873da8f1ece28e166fbbaf696cd48c55fd7cd203dba0266a1fe05ceb/ty-0.0.71-py3-none-win32.whl", hash = "sha256:a273fe0dcc453e94cf2e1955076efec8b739e6e1a890862de29d3c5a1c9ddaff", size = 11953321, upload-time = "2026-08-13T00:39:23.804Z" }, - { url = "https://files.pythonhosted.org/packages/69/fa/ce6614c748f7abfc546d12983d4bad1085116b6458b8c84d9372eb713f08/ty-0.0.71-py3-none-win_amd64.whl", hash = "sha256:65f5f980551ed79f68a0f6c0f2fb71b39a8d54ed0625d2529b01f6c919db72c5", size = 12570891, upload-time = "2026-08-13T00:39:26.346Z" }, - { url = "https://files.pythonhosted.org/packages/77/7f/0fb022535c66fd96e7dd2a1f9c14e3a0e39544a4c3f35a451e8835562730/ty-0.0.71-py3-none-win_arm64.whl", hash = "sha256:6d5552078b9934d359bd5f381dbcb160f1fc5addfca59a960021adca38a73741", size = 12338716, upload-time = "2026-08-13T00:39:28.987Z" }, + { url = "https://files.pythonhosted.org/packages/e2/3b/f51461239a4e66565d4b362f97a3b55fe7fdba2e944068341f87c62f6743/ty-0.0.72-py3-none-linux_armv6l.whl", hash = "sha256:fda86db153ffd85ee52000cf175d6a3f1c0223772cf7c5b6f726200bf92c7b44", size = 12621989, upload-time = "2026-08-14T21:35:01.676Z" }, + { url = "https://files.pythonhosted.org/packages/ca/fb/79ddf683affc679ca856f3510b5640ec3a88a842ba5f654f5d4bc78f1786/ty-0.0.72-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:ceb944c612529b9023acfdc9cf4c0dcbb722549f9d17d46baecd1141baf01d7f", size = 12233910, upload-time = "2026-08-14T21:35:04.334Z" }, + { url = "https://files.pythonhosted.org/packages/5d/45/10562a0d84802158db8fa4ec46de54aa9fdcecdeeaabbfe3639ae7042b66/ty-0.0.72-py3-none-macosx_11_0_arm64.whl", hash = "sha256:108d76218333d6c092e5f1cebf8e9b06f25738613a0236a28e2dd47c936ee52c", size = 12084108, upload-time = "2026-08-14T21:35:06.686Z" }, + { url = "https://files.pythonhosted.org/packages/a1/dc/1fe1aef8d697e3509face271a5331700c7aa1d1e44a4b622707bdfa41d4b/ty-0.0.72-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7f3943f186f741a2499a31053872169250c9264a9a49684920e48d8fcf4ef4f5", size = 12132640, upload-time = "2026-08-14T21:35:09.305Z" }, + { url = "https://files.pythonhosted.org/packages/14/46/41ceb265e96969487311a2014bd0e53abb4fbc1395efb2ebe411fcb4db62/ty-0.0.72-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:cf283c07dc3cc52ca48a3ad8ab100fb5aec3aebbd03ef6a12d5f910b8e596fc5", size = 12402489, upload-time = "2026-08-14T21:35:11.555Z" }, + { url = "https://files.pythonhosted.org/packages/2b/45/30bf43cb4fd505c5c2dd30fda27dde5f05208686cd21217adec77c954204/ty-0.0.72-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:95f3b6462c38f9f115d10cee21f47fedf715fcf2040daf36eef210359300bc7c", size = 13130835, upload-time = "2026-08-14T21:35:13.746Z" }, + { url = "https://files.pythonhosted.org/packages/31/2f/03bba754d2613f640df168335c41f83f41db150bb515839c60d80e3a7880/ty-0.0.72-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:30caf658feb8ffb250d9e9e47107657a78f5f3425c227df1664d8df2ebe38880", size = 13590392, upload-time = "2026-08-14T21:35:16.839Z" }, + { url = "https://files.pythonhosted.org/packages/04/c7/03c67f00e63005ec41585653dc3096064570b1e6273742baae2798cd242f/ty-0.0.72-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:27bdc012ddfbeec8948e4a6036c0dc39ac7cf2c8ec7c7d48dc7d2fd56d57b399", size = 13309629, upload-time = "2026-08-14T21:35:19.169Z" }, + { url = "https://files.pythonhosted.org/packages/c1/df/102d3b264eb7f2a58dd11952f229bb5150bb5668d176a6154976a6675981/ty-0.0.72-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:802c5970a77d7739e6f499921fbb6984fb7ad8a31d95e1ff42fd46f3642e4f3b", size = 12734028, upload-time = "2026-08-14T21:35:22.099Z" }, + { url = "https://files.pythonhosted.org/packages/61/85/d0737c8c54d0ba67366ddfb9f31d88edf0b02299e65923e6945ae60ebcb5/ty-0.0.72-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:47dce65114fdc615c68ca0edb393b433df0956447e4267df0e264137a789598d", size = 13174832, upload-time = "2026-08-14T21:35:24.71Z" }, + { url = "https://files.pythonhosted.org/packages/1e/31/497f5a96c36d9b586ab6afe0574986835c6fd5b835a89773d2bec4711b49/ty-0.0.72-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:325144fa07e2675d0faa337fcc864213c272a499eb0cfe5bde2fdc62282d27bc", size = 12215005, upload-time = "2026-08-14T21:35:26.892Z" }, + { url = "https://files.pythonhosted.org/packages/df/7d/46e65b17b4966c7cd0140f134380d33d8e84fe6efccd761533ce793dc502/ty-0.0.72-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:a5c9f15d0f58e43707d8848274be1821a0ef408eccb8aa7dda28a4a9eddf7640", size = 12421298, upload-time = "2026-08-14T21:35:29.301Z" }, + { url = "https://files.pythonhosted.org/packages/08/2a/12ada4ec17700b3cb1d4fd3bc3e5b1852df9e6885288429318cade87b3c1/ty-0.0.72-py3-none-musllinux_1_2_i686.whl", hash = "sha256:8ee508d64b381871529cc22c412b41071bf5e908b7aa5d66a38f3f6b2573a806", size = 12669242, upload-time = "2026-08-14T21:35:31.444Z" }, + { url = "https://files.pythonhosted.org/packages/1c/1a/4692536880790fb550ed6d44a6096778dc71bb112f2c6d615cebb01a57e5/ty-0.0.72-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:3699e2ec7921d44da79d6b089f7bf239b2cc53c4e45a5a38430adc34ee9e9a55", size = 12988199, upload-time = "2026-08-14T21:35:33.749Z" }, + { url = "https://files.pythonhosted.org/packages/9a/0d/f5e5a50322e9c45865e7b7a428ba6cd6527387cf0f2472492ac3cf746243/ty-0.0.72-py3-none-win32.whl", hash = "sha256:f25f72a67bd36cd247707c4784e52fad0b6b4f42a1b7dd14804110fa95c486ed", size = 11939708, upload-time = "2026-08-14T21:35:36.006Z" }, + { url = "https://files.pythonhosted.org/packages/3f/4e/8af3534b2e4214e6184a5a59c34101e94a68d578f081f97b995866bab1bf/ty-0.0.72-py3-none-win_amd64.whl", hash = "sha256:cdeee869341717e1736cea2e2d7856738c6957c320f584ed2f68c8f90100d2f5", size = 12643876, upload-time = "2026-08-14T21:35:38.141Z" }, + { url = "https://files.pythonhosted.org/packages/ff/ea/a2606e654c7276bd08586391a2525b0af3f3bf60228a8c57b2d248f273f9/ty-0.0.72-py3-none-win_arm64.whl", hash = "sha256:1bd3ac3ed4424a6d6990a85dc388556aea012bd752de21349a84b685951de0d8", size = 12394857, upload-time = "2026-08-14T21:35:40.277Z" }, ] [[package]] @@ -473,15 +473,15 @@ wheels = [ [[package]] name = "uvicorn" -version = "0.52.2" +version = "0.52.3" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "click" }, { name = "h11" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/c3/53/be79eff13cc289570b4c6875fa4641a91a1dc51ece7f6213f364b0a58c4c/uvicorn-0.52.2.tar.gz", hash = "sha256:4294500b9c8f7a3ef3e975d9e4be08c3eb76441af449a9e6e10146c6a182ffec", size = 100685, upload-time = "2026-08-13T07:03:57.377Z" } +sdist = { url = "https://files.pythonhosted.org/packages/2e/28/64ca011edf31c715b4fad359c587ea52391aaffa125065695590241ff617/uvicorn-0.52.3.tar.gz", hash = "sha256:18857b9e6579300be55c91c0a1cfd37d9a2cf0cabea33b88275f199eb73b8b58", size = 100621, upload-time = "2026-08-13T16:50:02.899Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/c1/08/0d834cf3e61e476c9f422e856a9e37d12d708d7ada942a2774b1f42aee6c/uvicorn-0.52.2-py3-none-any.whl", hash = "sha256:0b916d247cf5500b320638ea11abf0fed7f348e8e1c7fde53a0e6b6319803512", size = 79912, upload-time = "2026-08-13T07:03:55.658Z" }, + { url = "https://files.pythonhosted.org/packages/dc/2b/ebd108734a8204c6b4b93c681c9a38c5273b3ccd5d129fee4ffc1d97772c/uvicorn-0.52.3-py3-none-any.whl", hash = "sha256:116af2710dbf47c80f463cd20ee4884b6662f4c9f227d797ddc7279d2fcc2c7c", size = 79859, upload-time = "2026-08-13T16:50:01.323Z" }, ] [[package]] From 09f320428b270f9c91d06d437789f3414f40f964 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C6=B0u=20Quang=20V=C5=A9?= Date: Sat, 15 Aug 2026 15:27:58 +0700 Subject: [PATCH 284/291] Optimize system hints for tool calling --- app/utils/helper.py | 83 +++++++++++++++++++++++++-------------------- 1 file changed, 46 insertions(+), 37 deletions(-) diff --git a/app/utils/helper.py b/app/utils/helper.py index 5f17206..8c41c86 100644 --- a/app/utils/helper.py +++ b/app/utils/helper.py @@ -40,12 +40,11 @@ VALID_TAG_ROLES = {"user", "assistant", "system", "tool"} TOOL_WRAP_HINT = ( - "\n\n### SYSTEM: TOOL CALLING PROTOCOL (MANDATORY) ###\n" - "If tool execution is required, you MUST adhere to this EXACT protocol. No exceptions.\n\n" - "1. OUTPUT RESTRICTION: Your response MUST contain ONLY the [ToolCalls] block. Conversational filler, preambles, or concluding remarks are STRICTLY PROHIBITED.\n" - "2. WRAPPING LOGIC: Every parameter value MUST be enclosed in a markdown code block. Use 3 backticks (```) by default. If the value contains backticks, the outer fence MUST be longer than any sequence inside (e.g., ````).\n" - "3. TAG SYMMETRY: All tags MUST be balanced and closed in the exact reverse order of opening. Incomplete or unclosed blocks are strictly prohibited.\n\n" - "REQUIRED SYNTAX:\n" + "\n\nSYSTEM: TOOL CALLING PROTOCOL\n" + "When required, you MUST output ONLY the complete [ToolCalls] block; otherwise, you MUST NOT emit tool tags and MUST follow the requested format.\n" + "Names MUST match the schemas; values MUST use their JSON types. Every parameter value MUST be fenced with 3 backticks by default; use a longer outer fence than any internal backtick sequence.\n" + "Tags MUST be balanced and closed in reverse order. No prose or extra protocol tags.\n\n" + "FORMAT:\n" "[ToolCalls]\n" "[Call:tool_name]\n" "[CallParameter:parameter_name]\n" @@ -55,17 +54,43 @@ "[/CallParameter]\n" "[/Call]\n" "[/ToolCalls]\n\n" - "CRITICAL: Do NOT mix natural language with protocol tags. Either respond naturally OR provide the protocol block alone. There is no middle ground." + "CRITICAL: NEVER mix natural language with the [ToolCalls] block." ) STRUCTURED_JSON_WRAP_HINT = ( - "\n\n### SYSTEM: STRUCTURED JSON PROTOCOL (MANDATORY) ###\n" - "Return ONLY one markdown code block containing a single strict JSON document that conforms to the provided JSON Schema.\n" - "Use ```json by default. If the JSON contains backticks, the outer fence MUST be longer than any backtick sequence inside (e.g., ````json).\n" - "REQUIRED SYNTAX:\n" + "\n\nSYSTEM: STRUCTURED JSON PROTOCOL\n" + "The structured response MUST be ONLY one fenced block containing exactly one valid JSON document conforming to the JSON Schema below. No prose or extra blocks.\n" + "Use ```json; the outer fence MUST exceed any internal backtick sequence.\n" + "FORMAT:\n" "```json\n" '{"field":"value"}\n' "```\n\n" - "CRITICAL: Do NOT mix natural language with the fenced JSON block. Provide the protocol block alone. There is no middle ground." + "CRITICAL: The JSON inside the fence MUST be the complete structured response." +) +TOOL_INTERFACE_PROMPT = ( + "SYSTEM INTERFACE: You MUST use an available tool when the request requires one, subject to the selected " + "tool-choice directive. You MUST follow each tool's JSON Schema exactly." +) +TOOL_DESCRIPTION_PROMPT = "Tool `{name}`: {description}" +TOOL_ARGUMENTS_SCHEMA_PROMPT = "Parameters JSON Schema:" +TOOL_EMPTY_ARGUMENTS_SCHEMA_PROMPT = "Parameters JSON Schema: {}" +TOOL_CHOICE_NONE_PROMPT = ( + "TOOL CHOICE NONE: You MUST NOT call tools or emit tool tags. Return the requested response." +) +TOOL_CHOICE_REQUIRED_PROMPT = ( + "TOOL CHOICE REQUIRED: You MUST call at least one tool before the final response." +) +TOOL_CHOICE_NAMED_PROMPT = ( + "TOOL CHOICE REQUIRED: You MUST call only `{target_name}`; MUST NOT call any other tool." +) +IMAGE_GENERATION_PROMPT = "\n\n".join( + ( + "IMAGE PROTOCOL: Image requests MUST return a generated image.", + "New requests MUST produce a new image; edits MUST return the edited image.", + "Return NO explanation, apology, placeholder, or text-only status.", + ) +) +IMAGE_GENERATION_FORCED_PROMPT = ( + "IMAGE REQUIRED: You MUST return at least one generated image; text-only is invalid." ) TOOL_BLOCK_RE = re.compile( r"\\?\[ToolCalls\\?](.*?)\\?\[\\?/ToolCalls\\?]", @@ -776,33 +801,25 @@ def build_tool_prompt( if not tools: return "" - lines: list[str] = [ - "SYSTEM INTERFACE: You have access to the following technical tools. You MUST invoke them when necessary to fulfill the request, strictly adhering to the provided JSON schemas." - ] + lines: list[str] = [TOOL_INTERFACE_PROMPT] for tool in tools: name, description, parameters = extract_tool_info(tool) if not name: continue - lines.append(f"Tool `{name}`: {description}") + lines.append(TOOL_DESCRIPTION_PROMPT.format(name=name, description=description)) if parameters: schema_text = orjson.dumps(parameters, option=orjson.OPT_SORT_KEYS).decode("utf-8") - lines.extend(("Arguments JSON schema:", schema_text)) + lines.extend((TOOL_ARGUMENTS_SCHEMA_PROMPT, schema_text)) else: - lines.append("Arguments JSON schema: {}") + lines.append(TOOL_EMPTY_ARGUMENTS_SCHEMA_PROMPT) if tool_choice == "none": - lines.append( - "For this request you must not call any tool. Provide the best possible natural language answer." - ) + lines.append(TOOL_CHOICE_NONE_PROMPT) elif tool_choice == "required": - lines.append( - "You must call at least one tool before responding to the user. Do not provide a final user-facing answer until a tool call has been issued." - ) + lines.append(TOOL_CHOICE_REQUIRED_PROMPT) elif (target_name := extract_named_tool_choice(tool_choice)) is not None: - lines.append( - f"You are required to call the tool named `{target_name}`. Do not call any other tool." - ) + lines.append(TOOL_CHOICE_NAMED_PROMPT.format(target_name=target_name)) lines.append(TOOL_WRAP_HINT) @@ -820,18 +837,10 @@ def build_image_generation_instruction( if not has_forced_choice and primary is None: return None - instructions: list[str] = [ - "IMAGE GENERATION ENABLED: When an image is requested, you MUST return a real generated image directly.", - "1. For new requests, generate new images matching the description immediately.", - "2. For edits to existing images, apply changes and return a new generated version.", - "3. CRITICAL: Provide ZERO text explanation, prologue, or apologies. Do not describe the creation process.", - "4. NEVER send placeholder text or descriptions like 'Generating image...' without an actual image attachment.", - ] + instructions = [IMAGE_GENERATION_PROMPT] if has_forced_choice: - instructions.append( - "Image generation was explicitly requested. You MUST return at least one generated image. Any response without an image will be treated as a failure." - ) + instructions.append(IMAGE_GENERATION_FORCED_PROMPT) return "\n\n".join(instructions) From dfcca6370ebc991f20e3aaad418adf225a8cd517 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C6=B0u=20Quang=20V=C5=A9?= Date: Sat, 15 Aug 2026 17:50:12 +0700 Subject: [PATCH 285/291] Revise the prompts for greater accuracy and precision, strictly enforcing behavior while keeping them concise --- app/server/chat.py | 3 +- app/utils/helper.py | 167 +++++++++++++++++++++++++++----------------- 2 files changed, 103 insertions(+), 67 deletions(-) diff --git a/app/server/chat.py b/app/server/chat.py index f60cd9e..fc2b70e 100644 --- a/app/server/chat.py +++ b/app/server/chat.py @@ -65,6 +65,7 @@ from app.utils import g_config from app.utils.config import ChatMode from app.utils.helper import ( + STREAM_FLUSH_TAIL_RE, STREAM_MASTER_RE, STREAM_TAIL_RE, STRUCTURED_JSON_WRAP_HINT, @@ -1140,7 +1141,7 @@ def flush(self) -> str: res = "" if self._is_outputting(): res = self.buffer - if tail_match := STREAM_TAIL_RE.search(res): + if tail_match := STREAM_FLUSH_TAIL_RE.search(res): res = res[: -len(tail_match.group(0))] self.buffer = "" diff --git a/app/utils/helper.py b/app/utils/helper.py index 8c41c86..a14a1f0 100644 --- a/app/utils/helper.py +++ b/app/utils/helper.py @@ -40,11 +40,13 @@ VALID_TAG_ROLES = {"user", "assistant", "system", "tool"} TOOL_WRAP_HINT = ( - "\n\nSYSTEM: TOOL CALLING PROTOCOL\n" - "When required, you MUST output ONLY the complete [ToolCalls] block; otherwise, you MUST NOT emit tool tags and MUST follow the requested format.\n" - "Names MUST match the schemas; values MUST use their JSON types. Every parameter value MUST be fenced with 3 backticks by default; use a longer outer fence than any internal backtick sequence.\n" - "Tags MUST be balanced and closed in reverse order. No prose or extra protocol tags.\n\n" - "FORMAT:\n" + "\n\nSYSTEM: TOOL CALLING PROTOCOL (MANDATORY)\n" + "Either emit the tool-call block alone, or answer in natural language with no protocol tags. Never both.\n\n" + "1. Names MUST match the schemas exactly; every required parameter MUST be present with its declared JSON type.\n" + "2. Each value MUST stand alone between two fences of 3 backticks; if it contains a backtick run, both fences MUST be longer.\n" + "3. Every opening tag MUST be closed in reverse order of opening. A fence closes only itself, never a tag. An unclosed tag voids the call.\n" + "4. Emit the block and nothing else. No preamble or commentary.\n\n" + "REQUIRED SYNTAX, reproduce literally:\n" "[ToolCalls]\n" "[Call:tool_name]\n" "[CallParameter:parameter_name]\n" @@ -54,43 +56,46 @@ "[/CallParameter]\n" "[/Call]\n" "[/ToolCalls]\n\n" - "CRITICAL: NEVER mix natural language with the [ToolCalls] block." + "END TOOL CALLING PROTOCOL" ) STRUCTURED_JSON_WRAP_HINT = ( - "\n\nSYSTEM: STRUCTURED JSON PROTOCOL\n" - "The structured response MUST be ONLY one fenced block containing exactly one valid JSON document conforming to the JSON Schema below. No prose or extra blocks.\n" - "Use ```json; the outer fence MUST exceed any internal backtick sequence.\n" - "FORMAT:\n" + "\n\nSYSTEM: STRUCTURED JSON PROTOCOL (MANDATORY)\n" + "1. Return exactly one fenced block holding one strict JSON document that validates against the JSON Schema below. No prose, no second block.\n" + "2. Open with ```json and close with a fence of the same length; if the JSON contains a backtick run, both fences MUST be longer.\n" + "3. Emit every required field with its declared type. NEVER truncate the document or omit the closing fence.\n\n" + "REQUIRED SYNTAX:\n" "```json\n" '{"field":"value"}\n' "```\n\n" - "CRITICAL: The JSON inside the fence MUST be the complete structured response." + "END STRUCTURED JSON PROTOCOL" ) TOOL_INTERFACE_PROMPT = ( - "SYSTEM INTERFACE: You MUST use an available tool when the request requires one, subject to the selected " - "tool-choice directive. You MUST follow each tool's JSON Schema exactly." + "SYSTEM INTERFACE: Call an available tool whenever the request requires one, with arguments that " + "validate against its JSON Schema. Never invent an undeclared tool or parameter." ) TOOL_DESCRIPTION_PROMPT = "Tool `{name}`: {description}" TOOL_ARGUMENTS_SCHEMA_PROMPT = "Parameters JSON Schema:" -TOOL_EMPTY_ARGUMENTS_SCHEMA_PROMPT = "Parameters JSON Schema: {}" +TOOL_EMPTY_ARGUMENTS_SCHEMA_PROMPT = "Parameters JSON Schema: {} (takes no parameters)" TOOL_CHOICE_NONE_PROMPT = ( - "TOOL CHOICE NONE: You MUST NOT call tools or emit tool tags. Return the requested response." + "TOOL CHOICE = none: You MUST NOT call a tool or emit any protocol tag this turn. " + "Answer in natural language." ) TOOL_CHOICE_REQUIRED_PROMPT = ( - "TOOL CHOICE REQUIRED: You MUST call at least one tool before the final response." + "TOOL CHOICE = required: You MUST call at least one tool this turn; " + "a natural-language answer alone is invalid." ) TOOL_CHOICE_NAMED_PROMPT = ( - "TOOL CHOICE REQUIRED: You MUST call only `{target_name}`; MUST NOT call any other tool." + "TOOL CHOICE = `{target_name}`: You MUST call `{target_name}` this turn and no other tool." ) IMAGE_GENERATION_PROMPT = "\n\n".join( ( - "IMAGE PROTOCOL: Image requests MUST return a generated image.", - "New requests MUST produce a new image; edits MUST return the edited image.", - "Return NO explanation, apology, placeholder, or text-only status.", + "IMAGE PROTOCOL: Every image request MUST be answered with a generated image attachment.", + "A new request MUST produce a new image; an edit MUST return the edited image.", + "NEVER substitute text for the image: no explanation, apology, progress note, or placeholder.", ) ) IMAGE_GENERATION_FORCED_PROMPT = ( - "IMAGE REQUIRED: You MUST return at least one generated image; text-only is invalid." + "IMAGE REQUIRED: You MUST return at least one generated image; a text-only reply is a failure." ) TOOL_BLOCK_RE = re.compile( r"\\?\[ToolCalls\\?](.*?)\\?\[\\?/ToolCalls\\?]", @@ -122,23 +127,33 @@ COMMONMARK_UNESCAPE_RE = re.compile(r"\\([!\"#$%&'()*+,\-./:;<=>?@\[\\\]^_`{|}~])") PARAM_FENCE_RE = re.compile(r"^(?P`{3,})") TOOL_HINT_STRIPPED = TOOL_WRAP_HINT.strip() -_hint_lines = [line.strip() for line in TOOL_WRAP_HINT.split("\n") if line.strip()] -TOOL_HINT_LINE_START = _hint_lines[0] if _hint_lines else "" -TOOL_HINT_LINE_END = _hint_lines[-1] if _hint_lines else "" -TOOL_HINT_START_ESC = re.escape(TOOL_HINT_LINE_START) if TOOL_HINT_LINE_START else "" -TOOL_HINT_END_ESC = re.escape(TOOL_HINT_LINE_END) if TOOL_HINT_LINE_END else "" - -HINT_FULL_RE = ( - re.compile(rf"\n?{TOOL_HINT_START_ESC}:?.*?{TOOL_HINT_END_ESC}\n?", re.DOTALL | re.IGNORECASE) - if TOOL_HINT_START_ESC and TOOL_HINT_END_ESC - else None -) -HINT_START_RE = ( - re.compile(rf"\n?{TOOL_HINT_START_ESC}:?\s*", re.IGNORECASE) if TOOL_HINT_START_ESC else None -) -HINT_END_RE = ( - re.compile(rf"\s*{TOOL_HINT_END_ESC}\n?", re.IGNORECASE) if TOOL_HINT_END_ESC else None -) +SYSTEM_HINTS = (TOOL_WRAP_HINT, STRUCTURED_JSON_WRAP_HINT) + + +def _hint_anchors(hint: str) -> tuple[str, str]: + """Return a hint's first and last non-empty lines, used to locate echoed copies.""" + lines = [line.strip() for line in hint.split("\n") if line.strip()] + return (lines[0], lines[-1]) if lines else ("", "") + + +HINT_START_ANCHORS: list[str] = [] +HINT_END_ANCHORS: list[str] = [] +HINT_FULL_RES: list[re.Pattern[str]] = [] +HINT_START_RES: list[re.Pattern[str]] = [] +HINT_END_RES: list[re.Pattern[str]] = [] + +for _hint in SYSTEM_HINTS: + _start, _end = _hint_anchors(_hint) + if not _start or not _end: + continue + _start_esc, _end_esc = re.escape(_start), re.escape(_end) + HINT_START_ANCHORS.append(_start) + HINT_END_ANCHORS.append(_end) + HINT_FULL_RES.append( + re.compile(rf"\n?{_start_esc}:?.*?{_end_esc}\n?", re.DOTALL | re.IGNORECASE) + ) + HINT_START_RES.append(re.compile(rf"\n?{_start_esc}:?\s*", re.IGNORECASE)) + HINT_END_RES.append(re.compile(rf"\s*{_end_esc}\n?", re.IGNORECASE)) # --- Streaming Specific Patterns --- _START_PATTERNS = { @@ -154,16 +169,40 @@ _PROTOCOL_ENDS = r"\\?\[\\?/(?:ToolCalls|Call|ToolResults|CallParameter|ToolResult|Result)\\?]" _TAG_END = r"\\?<\\?\|im\\?_end\\?\|\\?>" -if TOOL_HINT_START_ESC and TOOL_HINT_END_ESC: - _START_PATTERNS["HINT"] = rf"\n?{TOOL_HINT_START_ESC}:?\s*" +if HINT_START_ANCHORS and HINT_END_ANCHORS: + _starts = "|".join(re.escape(anchor) for anchor in HINT_START_ANCHORS) + _START_PATTERNS["HINT"] = rf"\n?(?:{_starts}):?\s*" _master_parts = [f"(?P<{name}_START>{pattern})" for name, pattern in _START_PATTERNS.items()] _master_parts.extend((f"(?P{_PROTOCOL_ENDS})", f"(?P{_TAG_END})")) -if TOOL_HINT_START_ESC and TOOL_HINT_END_ESC: - _master_parts.append(f"(?P{TOOL_HINT_END_ESC}\n?)") +if HINT_START_ANCHORS and HINT_END_ANCHORS: + _ends = "|".join(re.escape(anchor) for anchor in HINT_END_ANCHORS) + _master_parts.append(f"(?P(?:{_ends})\n?)") STREAM_MASTER_RE = re.compile("|".join(_master_parts), re.IGNORECASE) -STREAM_TAIL_RE = re.compile( + +# Partial markers held back until the next chunk completes them. +_PARTIAL_MARKER = r"\\|\\?\[[^]]*|\\?<\\?\|?i?m?\\?_?(?:s?t?a?r?t?|e?n?d?)\\?\|?\\?>?" + +# Hint anchors are prose, so a chunk boundary inside one leaks the header. +# The line-start requirement spares ordinary words sharing a prefix. +_partial_anchors = sorted( + { + anchor[:length] + for anchor in (*HINT_START_ANCHORS, *HINT_END_ANCHORS) + for length in range(1, len(anchor)) + }, + key=len, + reverse=True, +) +if _partial_anchors: + _partial_anchor_alt = "|".join(re.escape(prefix) for prefix in _partial_anchors) + _PARTIAL_MARKER = rf"{_PARTIAL_MARKER}|(?:^|\n)(?:{_partial_anchor_alt})" + +STREAM_TAIL_RE = re.compile(rf"(?:{_PARTIAL_MARKER})$", re.IGNORECASE) + +# Flush discards what it matches, so it may only drop genuine protocol fragments. +STREAM_FLUSH_TAIL_RE = re.compile( r"(?:\\|\\?\[[^]]*|\\?<\\?\|?i?m?\\?_?(?:s?t?a?r?t?|e?n?d?)\\?\|?\\?>?)$", re.IGNORECASE, ) @@ -387,14 +426,12 @@ def strip_system_hints(text: str) -> str: t_unescaped = unescape_text(text) - cleaned = t_unescaped.replace(TOOL_WRAP_HINT, "").replace(TOOL_HINT_STRIPPED, "") + cleaned = t_unescaped + for hint in SYSTEM_HINTS: + cleaned = cleaned.replace(hint, "").replace(hint.strip(), "") - if HINT_FULL_RE: - cleaned = HINT_FULL_RE.sub("", cleaned) - if HINT_START_RE: - cleaned = HINT_START_RE.sub("", cleaned) - if HINT_END_RE: - cleaned = HINT_END_RE.sub("", cleaned) + for pattern in (*HINT_FULL_RES, *HINT_START_RES, *HINT_END_RES): + cleaned = pattern.sub("", cleaned) cleaned = strip_tagged_blocks(cleaned) cleaned = CONTROL_TOKEN_RE.sub("", cleaned) @@ -426,23 +463,21 @@ def _create_tool_call(name: str, raw_args: str) -> None: name = unescape_text(name.strip()) raw_args = unescape_text(raw_args) + # Leftovers mean the call was cut short: drop it rather than emit partial arguments. + residue = TAGGED_ARG_RE.sub("", raw_args).strip() + if residue: + logger.warning( + f"Dropping malformed tool call '{name}'. Unparsed content: {reprlib.repr(residue)}" + ) + return + arg_matches = TAGGED_ARG_RE.findall(raw_args) - if arg_matches: - args_dict = { - arg_name.strip(): _parse_tool_argument_value(arg_value) - for arg_name, arg_value in arg_matches - } - arguments = orjson.dumps(args_dict).decode("utf-8") - logger.debug(f"Successfully parsed {len(args_dict)} arguments for tool: {name}") - else: - cleaned_raw = raw_args.strip() - if not cleaned_raw: - logger.debug(f"Successfully parsed 0 arguments for tool: {name}") - else: - logger.warning( - f"Malformed arguments for tool '{name}'. Text found but no valid tags: {reprlib.repr(cleaned_raw)}" - ) - arguments = "{}" + args_dict = { + arg_name.strip(): _parse_tool_argument_value(arg_value) + for arg_name, arg_value in arg_matches + } + arguments = orjson.dumps(args_dict).decode("utf-8") + logger.debug(f"Successfully parsed {len(args_dict)} arguments for tool: {name}") index = len(tool_calls) seed = f"{name}:{arguments}:{index}".encode() From 11158948e3fb0684820fb14c6f751695c515bfc2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C6=B0u=20Quang=20V=C5=A9?= Date: Sun, 16 Aug 2026 20:53:02 +0700 Subject: [PATCH 286/291] Refine the logic flows to enhance accuracy, precision, and performance --- .github/workflows/ci.yaml | 3 + Dockerfile | 28 +- README.md | 63 +- README.zh.md | 55 +- app/main.py | 9 + app/models/core.py | 36 +- app/models/gemini_models.py | 3 +- app/models/models.py | 23 +- app/server/chat.py | 2045 ++++++++++++++++++------------- app/server/gemini.py | 347 +++++- app/server/health.py | 16 +- app/server/media.py | 29 +- app/server/middleware.py | 105 +- app/services/client.py | 4 +- app/services/lmdb.py | 142 ++- app/utils/config.py | 17 + app/utils/helper.py | 287 ++++- config/config.yaml | 5 + pyproject.toml | 13 +- scripts/rotate_lmdb.py | 91 +- tests/test_api_compatibility.py | 895 ++++++++++++++ tests/test_middleware.py | 167 +++ tests/test_storage.py | 210 ++++ tests/test_streaming.py | 425 +++++++ uv.lock | 470 ++++++- 25 files changed, 4450 insertions(+), 1038 deletions(-) create mode 100644 tests/test_api_compatibility.py create mode 100644 tests/test_middleware.py create mode 100644 tests/test_storage.py create mode 100644 tests/test_streaming.py diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 166afca..71b7305 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -46,3 +46,6 @@ jobs: - name: Run Pyright run: uv run pyright + + - name: Run Pytest + run: uv run pytest diff --git a/Dockerfile b/Dockerfile index 78716cf..2f67703 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,16 @@ -FROM ghcr.io/astral-sh/uv:python3.13-trixie-slim +FROM ghcr.io/astral-sh/uv:python3.13-trixie-slim AS builder + +WORKDIR /app + +ENV UV_COMPILE_BYTECODE=1 \ + UV_LINK_MODE=copy \ + UV_PYTHON_DOWNLOADS=0 + +COPY pyproject.toml uv.lock ./ +RUN --mount=type=cache,target=/root/.cache/uv \ + uv sync --locked --no-install-project --no-dev + +FROM python:3.13-slim-trixie AS runtime LABEL org.opencontainers.image.title="Gemini-FastAPI" \ org.opencontainers.image.description="Web-based Gemini models wrapped into an OpenAI-compatible API." @@ -8,26 +20,22 @@ USER root WORKDIR /app RUN apt-get update && apt-get install -y --no-install-recommends \ - tini curl ca-certificates git \ + ca-certificates tini \ && rm -rf /var/lib/apt/lists/* -ENV UV_COMPILE_BYTECODE=1 \ +ENV PATH="/app/.venv/bin:$PATH" \ PYTHONUNBUFFERED=1 \ PYTHONDONTWRITEBYTECODE=1 -COPY pyproject.toml uv.lock ./ -RUN uv sync --refresh --frozen --no-install-project --no-dev - +COPY --from=builder /app/.venv .venv/ COPY app/ app/ COPY config/ config/ COPY run.py . -ENV PATH="/app/.venv/bin:$PATH" - EXPOSE 8000 -HEALTHCHECK --interval=30s --timeout=10s --start-period=300s --retries=3 \ - CMD curl -f http://localhost:8000/health || exit 1 +HEALTHCHECK --interval=30s --timeout=10s --start-period=600s --retries=3 \ + CMD ["python", "-c", "import urllib.request; from app.utils import g_config; urllib.request.urlopen(f'http://127.0.0.1:{g_config.server.port}/health', timeout=5).close()"] ENTRYPOINT ["/usr/bin/tini", "--"] diff --git a/README.md b/README.md index c6e6dfc..e975ebf 100644 --- a/README.md +++ b/README.md @@ -24,7 +24,7 @@ Web-based Gemini models wrapped into an OpenAI-compatible API. Powered by [Hanao ### Prerequisites -- Python 3.13 +- Python >= 3.13 - Google account with Gemini access on web (Enable **[Gemini Apps activity](https://myactivity.google.com/product/gemini)** for best conversation persistence) - `secure_1psid` and `secure_1psidts` cookies from Gemini web interface @@ -81,22 +81,65 @@ The server provides several endpoints, including OpenAI-compatible ones. ### OpenAI-Compatible Endpoints -These endpoints are designed to be compatible with OpenAI's API structure, allowing you to use Gemini as a drop-in replacement. +These endpoints use OpenAI-compatible wire formats while translating requests to Gemini Web. +Compatibility is intentionally broader than the controls exposed by the Gemini Web client. Valid +but unforwardable options are accepted for client compatibility, ignored, and recorded at debug +level so they do not prevent an otherwise representable request from running. - **`GET /v1/models`**: Lists all supported Gemini models. - **`POST /v1/chat/completions`**: Unified chat interface. - **Streaming**: Set `stream: true` to receive real-time delta chunks. - **Multi-modal**: Supports text, images, and file uploads. - **Tool Calling**: Supports function calling via the `tools` parameter. - - **Structured Output**: Supports `response_format` for JSON schema enforcement. + - **Structured Output**: Supports every `response_format` mode. `json_schema` is validated + server-side against the supplied schema; `json_object` (JSON mode) only requires that the + reply parses as JSON; `text` is the default and imposes nothing. ### Advanced Endpoints -- **`POST /v1/responses`**: An alternative endpoint for complex interaction patterns, supporting rich output items including generated images and tool calls. +- **`POST /v1/responses`**: Supports current `text.format` structured output (`text`, + `json_object` and `json_schema`), external/inline file inputs, generated images, and tool + calls. Files API `file_id` references are rejected because this wrapper does not expose an + OpenAI Files API. + +Schema enforcement follows OpenAI's own guarantee. With `strict: true`, a reply that does not +match the schema is an error. With `strict: false` or JSON mode, only a best effort is promised, +so a non-conforming reply is returned as text instead of failing the request. A turn that returns +a tool call is never judged against the schema, which constrains the final answer only. + +`strict` defaults to `false` on both OpenAI surfaces, matching OpenAI itself, so one schema +behaves the same whichever endpoint it is sent to. The flag matters more here than upstream, +because Gemini Web has no constrained decoding — the schema is asked for in the prompt, so a +strict requirement the model narrowly misses costs the caller the whole reply. The Gemini-native +`generationConfig.responseSchema` / `responseJsonSchema` is always best-effort for the same +reason: it has no `strict` flag to turn off. + +Only the model's own failures are enforced. A schema this wrapper cannot evaluate — one that is +not valid JSON Schema, or whose `$ref`s do not resolve — is still shown to the model but is not +used to judge the reply, and a schema whose regex keywords exhaust +`server.schema_validation_budget_seconds` leaves the reply unverified. None of these fail the +request, even under `strict`: they are gaps on this side, not violations by the model. + +Because a JSON document can only be validated once it is complete, a streamed response carrying +a structured requirement is delivered as a single chunk after validation rather than +incrementally. This applies to Gemini-native `responseMimeType: application/json` as well. + +Generation controls that Gemini Web does not expose—such as `temperature`, `top_p`, maximum +output-token limits, `parallel_tool_calls`, Gemini `generationConfig` fields, and safety settings—are +accepted but cannot affect upstream generation. They are ignored with a debug log. Only malformed +input, or content this wrapper cannot resolve at all (an unresolved Files API ID, a `cachedContent` +handle), is rejected—dropping those silently would change what the model is answering. + +On the Gemini surface, `generationConfig.responseSchema` is the OpenAPI 3.0 subset (uppercase +type names, `nullable`) and is translated to JSON Schema before use; `responseJsonSchema` is +already JSON Schema and is validated as such. `toolConfig.functionCallingConfig.allowedFunctionNames` +narrows the tool list only in the `ANY` and `VALIDATED` modes that act on it. ### Utility Endpoints -- **`GET /health`**: Health check endpoint. Returns the status of the server, configured Gemini clients, and conversation storage. +- **`GET /health`**: Readiness endpoint. Returns HTTP 503 when conversation storage is unavailable + or no Gemini client is usable; individual degraded clients are reported without taking a pool + with another usable client out of service. - **`GET /media/{filename}`**: Internal endpoint to serve generated media. Requires a valid token (automatically included in image URLs returned by the API). ## Docker Deployment @@ -187,8 +230,18 @@ export CONFIG_GEMINI__CLIENTS__0__IMPERSONATE="chrome" # Override conversation storage size limit export CONFIG_STORAGE__MAX_SIZE=268435456 # 256 MB + +# Override the local HTTP-body resource guard (0 disables it) +export CONFIG_SERVER__MAX_REQUEST_BODY_BYTES=268435456 + +# Override the JSON Schema regex evaluation budget, in seconds +export CONFIG_SERVER__SCHEMA_VALIDATION_BUDGET_SECONDS=1.0 ``` +`max_request_body_bytes` is only a wrapper-side memory/resource safety ceiling. It is not an +OpenAI or Gemini API compatibility limit and does not claim to describe Gemini Web capacity. +Gemini Web remains authoritative for whether a request that passes this local guard is accepted. + ### Client IDs and Conversation Reuse Conversations are stored with the ID of the client that generated them. diff --git a/README.zh.md b/README.zh.md index ff13f4e..e88a9fd 100644 --- a/README.zh.md +++ b/README.zh.md @@ -24,7 +24,7 @@ ### 前置条件 -- Python 3.13 +- Python >= 3.13 - 拥有网页版 Gemini 访问权限的 Google 账号 (开启 **[Gemini Apps 应用活动](https://myactivity.google.com/product/gemini)** 以获得最佳会话持久化体验) - 从 Gemini 网页获取的 `secure_1psid` 和 `secure_1psidts` Cookie @@ -81,22 +81,57 @@ python run.py ### OpenAI 兼容接口 -这些接口遵循 OpenAI 的 API 规范,允许你将 Gemini 作为 **Drop-in 替代方案** 直接接入现有的 AI 应用。 +这些接口使用 OpenAI 兼容的传输格式,并将请求转换后发送给 Gemini 网页端。兼容范围有意 +覆盖 Gemini 网页端客户端实际暴露的控制项:对于客户端无法转发但请求模型已识别的有效选项, +服务会正常接受并忽略,同时在调试日志中记录选项名称,避免其阻止其他可表示的请求内容执行。 - **`GET /v1/models`**: 列出所有可用的 Gemini 模型。 - **`POST /v1/chat/completions`**: 统一聊天对话接口。 - **流式传输**: 设置 `stream: true` 即可实时接收增量响应 (Stream Delta)。 - **多模态支持**: 支持在消息中包含文本、图片以及文件上传。 - **工具调用**: 支持通过 `tools` 参数进行函数调用 (Function Calling)。 - - **结构化输出**: 支持 `response_format`,可严格遵循 JSON Schema。 + - **结构化输出**: 支持 `response_format` 的全部模式。`json_schema` 会在服务器端按所给 + Schema 验证;`json_object`(JSON 模式)只要求回复能解析为 JSON;`text` 为默认值,不作限制。 ### 高级接口 -- **`POST /v1/responses`**: 用于复杂交互模式的专用接口,支持分步输出、生成图片及工具调用等更丰富的响应项。 +- **`POST /v1/responses`**: 支持当前的 `text.format` 结构化输出(`text`、`json_object` + 与 `json_schema`)、外部或内联文件输入、图片生成及工具调用。由于本项目没有实现 + OpenAI Files API,因此会拒绝 `file_id` 引用。 + +Schema 的强制程度与 OpenAI 自身的承诺保持一致:`strict: true` 时,回复不符合 Schema 即视为 +错误;`strict: false` 或 JSON 模式仅承诺尽力而为,因此不符合的回复会以文本形式返回而不会让 +请求失败。返回工具调用的轮次不受 Schema 约束——Schema 只约束最终答案。 + +`strict` 在两个 OpenAI 接口上均默认为 `false`,与 OpenAI 自身一致,因此同一个 Schema 在任一 +接口上的行为相同。该开关在本项目中比上游更关键:Gemini 网页端没有受约束解码,Schema 只能 +通过提示词表达,因此一旦启用严格模式,模型的细微偏差就会让调用方彻底失去这次回复。出于同样 +的原因,Gemini 原生接口的 `generationConfig.responseSchema` / `responseJsonSchema` 始终按尽力 +而为处理——它没有可供关闭的 `strict` 开关。 + +只有模型自身的失败才会被追究。本服务无法求值的 Schema(不是合法的 JSON Schema,或 `$ref` +无法解析)仍会展示给模型,但不会用于判定回复;正则关键字耗尽 +`server.schema_validation_budget_seconds` 时,回复将保持未校验状态。即使在 `strict` 下,这些 +情况都不会让请求失败——它们是本服务的能力缺口,而非模型的违规。 + +由于 JSON 文档只有在完整后才能校验,带结构化要求的流式响应会在校验完成后作为单个分块返回, +而非逐步下发。Gemini 原生接口的 `responseMimeType: application/json` 同样适用。 + +Gemini 网页端未暴露的生成控制项,例如 `temperature`、`top_p`、最大输出 Token 数、 +`parallel_tool_calls`、Gemini `generationConfig` 字段及安全设置,仍会被接受,但无法影响上游生成。 +服务会忽略这些已识别选项并写入调试日志。只有格式错误的输入,或本服务完全无法解析的内容 +(例如无法解析的 Files API ID、`cachedContent` 句柄)才会被拒绝——静默丢弃这类内容会改变 +模型实际回答的问题。未知字段遵循 Pydantic 的默认忽略行为。 + +在 Gemini 接口上,`generationConfig.responseSchema` 属于 OpenAPI 3.0 子集(大写类型名、 +`nullable`),使用前会转换为 JSON Schema;`responseJsonSchema` 本身就是 JSON Schema,按其 +标准验证。`toolConfig.functionCallingConfig.allowedFunctionNames` 仅在真正生效的 `ANY` 与 +`VALIDATED` 模式下才会收窄工具列表。 ### 实用工具接口 -- **`GET /health`**: 健康检查接口。返回服务器、已配置的 Gemini 客户端以及对话存储的状态。 +- **`GET /health`**: 就绪状态接口。当对话存储不可用或没有任何可用 Gemini 客户端时返回 + HTTP 503;如果客户端池中仅有个别客户端降级、但仍有其他客户端可用,则不会将整个服务判为不可用。 - **`GET /media/{filename}`**: 用于分发生成的媒体内容的内部接口。需要有效的 Token(API 返回的图片 URL 中已自动包含该 Token)。 ## Docker 部署 @@ -187,8 +222,18 @@ export CONFIG_GEMINI__CLIENTS__0__IMPERSONATE="chrome" # 覆盖对话存储大小限制 export CONFIG_STORAGE__MAX_SIZE=268435456 # 256 MB + +# 覆盖本地 HTTP 请求体资源保护上限(设为 0 可禁用) +export CONFIG_SERVER__MAX_REQUEST_BODY_BYTES=268435456 + +# 覆盖 JSON Schema 正则求值预算(单位:秒) +export CONFIG_SERVER__SCHEMA_VALIDATION_BUDGET_SECONDS=1.0 ``` +`max_request_body_bytes` 仅是封装层用于保护内存和本地资源的可配置上限,并非 OpenAI 或 +Gemini API 的兼容性限制,也不代表 Gemini 网页端的容量。通过本地检查后,请求最终是否可被 +接受仍由 Gemini 网页端决定。 + ### 客户端 ID 与会话重用 会话在保存时会绑定创建它的客户端 ID。请在配置中保持这些 `id` 值稳定, diff --git a/app/main.py b/app/main.py index c9d998e..ce4c929 100644 --- a/app/main.py +++ b/app/main.py @@ -15,6 +15,7 @@ from .server.middleware import ( add_cors_middleware, add_exception_handler, + add_request_size_limit_middleware, cleanup_expired_media, ) from .services import GeminiClientPool, LMDBConversationStore @@ -78,6 +79,11 @@ async def lifespan(app: FastAPI): logger.exception(f"Failed to initialize Gemini clients: {e}") raise + try: + LMDBConversationStore().prune_stale_indexes() + except Exception: + logger.exception("Failed to prune stale LMDB indexes; continuing with startup.") + cleanup_task = asyncio.create_task(_run_retention_cleanup(cleanup_stop_event)) # Give the tasks a chance to start and surface immediate failures. @@ -121,6 +127,9 @@ def create_app() -> FastAPI: lifespan=lifespan, ) + # Order matters: the last middleware added is the outermost, so the body limit has to be + # registered first for its 413 to still pass back out through CORS. + add_request_size_limit_middleware(app) add_cors_middleware(app) add_exception_handler(app) add_gemini_exception_handlers(app) diff --git a/app/models/core.py b/app/models/core.py index a165a86..195d6d4 100644 --- a/app/models/core.py +++ b/app/models/core.py @@ -1,7 +1,11 @@ +from __future__ import annotations + +import base64 +import hashlib from datetime import datetime from typing import Any, Literal -from pydantic import BaseModel, Field +from pydantic import BaseModel, Field, model_validator class AppToolCallFunction(BaseModel): @@ -22,6 +26,36 @@ class AppContentItem(BaseModel): file_data: str | bytes | None = Field(default=None, exclude=True) filename: str | None = None raw_data: dict[str, Any] | None = None + content_digest: str | None = None + + @model_validator(mode="after") + def populate_content_digest(self) -> AppContentItem: + """Persist a digest for inline media whose raw bytes are intentionally excluded. + + Only `file_data` and inline data URLs need one. Every other field survives + serialization, so it can be compared directly instead of through a fingerprint. + """ + if self.content_digest: + return self + + encoded = self.file_data + if encoded is None and self.url and self.url.startswith("data:"): + encoded = self.url.partition(",")[2] + if encoded is None: + return self + + # utf-8, not ascii: a data URL may carry non-Base64 text, and a codec error here + # would abort model construction. + raw = encoded.encode("utf-8", "surrogatepass") if isinstance(encoded, str) else encoded + if raw.startswith(b"data:"): + raw = raw.partition(b",")[2] + try: + digest_input = base64.b64decode(b"".join(raw.split()), validate=True) + except ValueError: + digest_input = raw + + self.content_digest = hashlib.sha256(digest_input).hexdigest() + return self type AppMessageRole = Literal["system", "user", "assistant", "tool"] diff --git a/app/models/gemini_models.py b/app/models/gemini_models.py index 4ee2ef3..3e9d4f3 100644 --- a/app/models/gemini_models.py +++ b/app/models/gemini_models.py @@ -91,7 +91,7 @@ class GeminiTool(BaseModel): class GeminiFunctionCallingConfig(BaseModel): """函数调用配置。""" - mode: Literal["AUTO", "NONE", "ANY"] = "AUTO" + mode: Literal["AUTO", "NONE", "ANY", "VALIDATED"] = "AUTO" allowedFunctionNames: list[str] | None = None @@ -129,6 +129,7 @@ class GeminiGenerationConfig(BaseModel): responseJsonSchema: dict[str, Any] | None = None candidateCount: int | None = None thinkingConfig: GeminiThinkingConfig | None = None + responseFormat: dict[str, Any] | None = None class GeminiGenerateContentRequest(BaseModel): diff --git a/app/models/models.py b/app/models/models.py index 9b7b2c8..adc73f8 100644 --- a/app/models/models.py +++ b/app/models/models.py @@ -4,7 +4,7 @@ from dataclasses import dataclass from typing import Any, Literal -from pydantic import BaseModel, Field, model_validator +from pydantic import BaseModel, Field, StrictBool, model_validator @dataclass @@ -15,6 +15,13 @@ class StructuredOutputRequirement: schema: dict[str, Any] instruction: str raw_format: dict[str, Any] + strict: bool = True + """Whether schema adherence is guaranteed to the client. + + Mirrors OpenAI's `strict` flag: Structured Outputs (`strict: true`) promise the response + matches the schema, so a violation has to surface as an error. JSON mode and `strict: false` + only promise a best effort, so a violation degrades to the raw text instead. + """ class FunctionCall(BaseModel): @@ -388,6 +395,12 @@ class ResponseFormatText(BaseModel): type: Literal["text"] = Field(default="text") +class ResponseFormatJSONObject(BaseModel): + """Legacy JSON mode: valid JSON is promised, schema conformance is not.""" + + type: Literal["json_object"] = Field(default="json_object") + + class ResponseFormatTextJSONSchemaConfig(BaseModel): """JSON-schema-constrained output format.""" @@ -399,13 +412,15 @@ class ResponseFormatTextJSONSchemaConfig(BaseModel): default=None, alias="schema", serialization_alias="schema" ) description: str | None = Field(default=None) + # Unset, not False: the resolved value is stamped back onto the echoed response. + strict: StrictBool | None = Field(default=None) class ResponseTextConfig(BaseModel): """Top-level text configuration block in a Responses API response.""" - format: ResponseFormatText | ResponseFormatTextJSONSchemaConfig = Field( - default_factory=ResponseFormatText + format: ResponseFormatTextJSONSchemaConfig | ResponseFormatJSONObject | ResponseFormatText = ( + Field(default_factory=ResponseFormatText) ) @@ -438,6 +453,8 @@ class ResponseCreateRequest(BaseModel): ) store: bool | None = Field(default=None) prompt_cache_key: str | None = Field(default=None) + text: ResponseTextConfig | None = Field(default=None) + # Backward-compatible project extension. Current OpenAI Responses requests use `text.format`. response_format: dict[str, Any] | None = Field(default=None) metadata: dict[str, Any] | None = Field(default=None) parallel_tool_calls: bool | None = Field(default=True) diff --git a/app/server/chat.py b/app/server/chat.py index fc2b70e..48f56c9 100644 --- a/app/server/chat.py +++ b/app/server/chat.py @@ -41,6 +41,8 @@ ModelListResponse, ResponseCreateRequest, ResponseCreateResponse, + ResponseFormatJSONObject, + ResponseFormatText, ResponseFormatTextJSONSchemaConfig, ResponseFunctionToolCall, ResponseInputMessage, @@ -65,10 +67,13 @@ from app.utils import g_config from app.utils.config import ChatMode from app.utils.helper import ( + SCHEMA_ADHERENCE_PROMPT, STREAM_FLUSH_TAIL_RE, STREAM_MASTER_RE, STREAM_TAIL_RE, + STRICT_SCHEMA_ADHERENCE_PROMPT, STRUCTURED_JSON_WRAP_HINT, + StructuredOutputValidationError, append_tool_hint_to_last_user_message, build_image_generation_instruction, build_tool_prompt, @@ -83,6 +88,7 @@ serialize_tool_choice_for_response, serialize_tools_for_response, strip_system_hints, + validate_json_schema, ) MAX_CHARS_PER_REQUEST = int(g_config.gemini.max_chars_per_request * 0.9) @@ -206,6 +212,7 @@ def _create_responses_standard_payload( response_contents: list[ResponseOutputContent], usage: ResponseUsage, request: ResponseCreateRequest, + structured_requirement: StructuredOutputRequirement | None = None, full_thoughts: str | None = None, message_id: str | None = None, reason_id: str | None = None, @@ -253,9 +260,19 @@ def _create_responses_standard_payload( output_items.extend(image_call_items) - text_config = ResponseTextConfig() + text_config = request.text.model_copy(deep=True) if request.text else ResponseTextConfig() if request.response_format and request.response_format.get("type") == "json_schema": - text_config.format = ResponseFormatTextJSONSchemaConfig() + legacy_config = request.response_format.get("json_schema") or {} + text_config.format = ResponseFormatTextJSONSchemaConfig( + name=legacy_config.get("name"), + schema=legacy_config.get("schema"), + description=legacy_config.get("description"), + ) + + # Report the enforcement applied, not the value the request carried: a client that reads + # `strict: true` back may skip its own validation. + if isinstance(text_config.format, ResponseFormatTextJSONSchemaConfig): + text_config.format.strict = bool(structured_requirement and structured_requirement.strict) return ResponseCreateResponse( id=response_id, @@ -362,53 +379,208 @@ def _persist_conversation( def _build_structured_requirement( response_format: dict[str, Any] | None, ) -> StructuredOutputRequirement | None: - """Translate OpenAI-style response_format into helper-managed fenced JSON instructions.""" + """Translate an OpenAI-style response_format into fenced-JSON prompt instructions. + + `json_schema` becomes an enforced requirement, `json_object` a best-effort one carrying an + empty schema, and every other type - including the `text` default - is dropped, since a mode + this wrapper cannot represent must not block the rest of the request. + + `strict` is read from the payload and defaults to False, matching OpenAI's own default for + Chat Completions. It has teeth here: Gemini Web has no constrained decoding, so a strict + requirement that the model misses costs the caller the whole answer. Callers that ask for + `strict: true` have opted into that; callers that say nothing get the reply as text. + A schema that is well-formed JSON but not valid JSON Schema is asked for without being + enforced, rather than failing the request; only a malformed `response_format` is rejected. + """ if not response_format or not isinstance(response_format, dict): return None - if response_format.get("type") != "json_schema": - logger.warning( - f"Unsupported response_format type requested: {reprlib.repr(response_format)}" + format_type = response_format.get("type") + if format_type == "json_object": + return StructuredOutputRequirement( + schema_name="response", + schema={}, + instruction=STRUCTURED_JSON_WRAP_HINT, + raw_format=response_format, + strict=False, ) + + if format_type != "json_schema": + logger.debug(f"Ignoring response_format type unsupported by Gemini Web: {format_type!r}") return None json_schema = response_format.get("json_schema") if not isinstance(json_schema, dict): - logger.warning( - f"Invalid json_schema payload in response_format: {reprlib.repr(response_format)}" - ) - return None + raise ValueError("response format must contain a json_schema object") schema = json_schema.get("schema") if not isinstance(schema, dict): - logger.warning( - f"Missing `schema` object in response_format payload: {reprlib.repr(response_format)}" - ) - return None + raise ValueError("json_schema must contain a schema object") schema_name = json_schema.get("name") or "response" - strict = json_schema.get("strict", True) + requested_strict = json_schema.get("strict", False) + if not isinstance(requested_strict, bool): + raise ValueError("json_schema.strict must be a boolean") + + enforced_schema, strict = schema, requested_strict + try: + validate_json_schema(schema) + except ValueError as exc: + # Same stance as the Gemini surface: a schema we cannot evaluate is still shown to the + # model, it just cannot be used to judge the reply. Failing the request instead would + # lose a usable answer over a gap on our side. + logger.warning( + f"Asking for schema {schema_name!r} without enforcing it, it is not representable " + f"as JSON Schema: {exc}" + ) + enforced_schema, strict = {}, False pretty_schema = orjson.dumps(schema, option=orjson.OPT_SORT_KEYS).decode("utf-8") instruction_parts = [ STRUCTURED_JSON_WRAP_HINT, + SCHEMA_ADHERENCE_PROMPT, f'Schema name: "{schema_name}"', "JSON Schema:", pretty_schema, ] - if strict: - instruction_parts.insert( - 1, - "Strict schema adherence is required: the JSON must conform exactly to the schema.", - ) + # Prompted from what the caller asked for, enforced from what we can actually check. + if requested_strict: + instruction_parts.insert(1, STRICT_SCHEMA_ADHERENCE_PROMPT) instruction = "\n\n".join(instruction_parts) return StructuredOutputRequirement( schema_name=schema_name, - schema=schema, + schema=enforced_schema, instruction=instruction, raw_format=response_format, + strict=strict, + ) + + +def _responses_response_format(request: ResponseCreateRequest) -> dict[str, Any] | None: + """Reduce Responses `text.format` and the legacy `response_format` to one format dict.""" + text_format = request.text.format if request.text is not None else None + + if request.response_format is not None: + # The legacy project extension only loses to a `text.format` that actually asks for a + # format; a default or plain-text block alongside it is not a conflict. + if isinstance(text_format, ResponseFormatText) or text_format is None: + return request.response_format + raise ValueError("Use either text.format or response_format, not both") + + if isinstance(text_format, ResponseFormatJSONObject): + return {"type": "json_object"} + if not isinstance(text_format, ResponseFormatTextJSONSchemaConfig): + return None + if not isinstance(text_format.schema_, dict): + raise ValueError("text.format.schema is required for json_schema output") + return { + "type": "json_schema", + "json_schema": { + "name": text_format.name or "response", + "schema": text_format.schema_, + "description": text_format.description, + # Same default as Chat Completions and as OpenAI: an omitted `strict` is best-effort, + # so one schema cannot hard-fail on one surface and degrade on the other. + "strict": text_format.strict if text_format.strict is not None else False, + }, + } + + +def _log_ignored_openai_options( + request: ChatCompletionRequest | ResponseCreateRequest, +) -> None: + """Debug-log generation controls that were accepted but cannot reach Gemini Web.""" + control_fields = ( + ("temperature", "top_p", "max_completion_tokens", "parallel_tool_calls") + if isinstance(request, ChatCompletionRequest) + else ("temperature", "top_p", "max_output_tokens", "parallel_tool_calls") ) + if ignored := {name for name in control_fields if name in request.model_fields_set}: + logger.debug( + "Ignoring option(s) unsupported by the Gemini Web upstream: " + f"{', '.join(sorted(ignored))}" + ) + + +def _tool_choice_failure( + tool_choice: Any, + tool_calls: list[AppToolCall], + *, + has_images: bool = False, + has_image_tool: bool = False, +) -> str | None: + """Describe how the model failed a forced tool_choice, or None if it honored it. + + Gemini Web has no constrained decoding, so a forced choice is only ever a prompt + instruction; this is the check that the instruction actually took. + + An image only discharges `required` when an image-generation tool was declared, so that an + image Gemini volunteers on its own cannot stand in for the function call that was forced. + """ + if tool_choice == "required" and not tool_calls and not (has_images and has_image_tool): + return "The model did not return a required tool result" + if ( + target_name := ( + tool_choice.function.name + if isinstance(tool_choice, ChatCompletionNamedToolChoice) + else (tool_choice.name if isinstance(tool_choice, ToolChoiceFunction) else None) + ) + ) and all(call.function.name != target_name for call in tool_calls): + return f"The model did not call the required function {target_name!r}" + if isinstance(tool_choice, ToolChoiceTypes) and not has_images: + return "The model did not return a required image generation result" + return None + + +def _tool_choice_declaration_error( + function_names: set[str], + has_image_tool: bool, + tool_choice: Any, +) -> str | None: + """Reject forced choices that do not name a declared compatible tool.""" + if tool_choice == "required" and not function_names and not has_image_tool: + return "tool_choice='required' requires at least one tool" + if isinstance(tool_choice, ChatCompletionNamedToolChoice): + target_name = tool_choice.function.name + if target_name not in function_names: + return f"tool_choice names undeclared function {target_name!r}" + if isinstance(tool_choice, ToolChoiceFunction) and tool_choice.name not in function_names: + return f"tool_choice names undeclared function {tool_choice.name!r}" + if isinstance(tool_choice, ToolChoiceTypes) and not has_image_tool: + return "tool_choice='image_generation' requires an image_generation tool" + return None + + +def _validate_responses_input(items: Any) -> str | None: + """Describe why an input item is unusable, or None if every part can be represented. + + Only content is judged here: a reference this wrapper cannot resolve, or a media part whose + source is missing or ambiguous. Dropping either silently would change what the model sees. + """ + if isinstance(items, str): + return None + for item in items: + parts = ( + item.output if isinstance(item, FunctionCallOutput) else getattr(item, "content", None) + ) + if not isinstance(parts, list): + continue + for part in parts: + if getattr(part, "file_id", None): + return "file_id inputs are not supported; use file_url or inline Base64 data" + if getattr(part, "type", None) == "input_file": + sources = [ + getattr(part, "file_url", None), + getattr(part, "file_data", None), + ] + if sum(value is not None for value in sources) != 1: + return "input_file must contain exactly one of file_url or file_data" + if getattr(part, "type", None) == "input_image" and not getattr( + part, "image_url", None + ): + return "input_image must contain image_url" + return None def _prepare_messages_for_model( @@ -541,7 +713,28 @@ def _convert_responses_to_app_messages( ) ) elif isinstance(item, FunctionCallOutput): - output_content = str(item.output) if isinstance(item.output, list) else item.output + output_content: str | list[AppContentItem] | None + if isinstance(item.output, list): + converted_output: list[AppContentItem] = [] + for part in item.output: + if part.type == "input_text": + converted_output.append(AppContentItem(type="text", text=part.text or "")) + elif part.type == "input_image" and part.image_url: + converted_output.append( + AppContentItem(type="image_url", url=part.image_url) + ) + elif part.type == "input_file": + converted_output.append( + AppContentItem( + type="file", + url=part.file_url, + file_data=part.file_data, + filename=part.filename, + ) + ) + output_content = converted_output or None + else: + output_content = item.output messages.append( AppMessage( role="tool", @@ -1178,6 +1371,18 @@ async def _process_media_item( # --- Response Builders & Streaming --- +def _sse_error( + message: str, error_type: str, param: str | None = None, code: str | None = None +) -> str: + """Render a Chat Completions SSE error frame followed by the stream terminator. + + The status line is already committed by the time these fire, so the terminator is the only + way left to tell a client the stream ended on purpose rather than being cut off. + """ + payload = {"error": {"message": message, "type": error_type, "param": param, "code": code}} + return f"data: {orjson.dumps(payload).decode('utf-8')}\n\ndata: [DONE]\n\n" + + def _create_real_streaming_response( resp_or_stream: AsyncGenerator[ModelOutput] | ModelOutput, completion_id: str, @@ -1190,6 +1395,7 @@ def _create_real_streaming_response( session: ChatSession, base_url: str, structured_requirement: StructuredOutputRequirement | None = None, + tool_choice: Any = None, ) -> StreamingResponse: """ Create a real-time streaming response. @@ -1201,7 +1407,7 @@ async def generate_stream(): full_text = "" full_thoughts = "" has_started = False - all_outputs: list[ModelOutput] = [] + last_output: ModelOutput | None = None suppressor = StreamingOutputFilter() media_tasks = [] @@ -1211,6 +1417,20 @@ async def generate_stream(): async def _make_async_gen(item: ModelOutput) -> AsyncGenerator[ModelOutput]: yield item + async def discard_media_tasks() -> None: + """Cancel and reap media downloads on a path that will never publish their results. + + These tasks are spawned eagerly while chunks arrive. Abandoning them on an early + return leaves them writing files nothing references and, on failure, raises + `Task exception was never retrieved` once they are garbage collected. + """ + if not media_tasks: + return + for task in media_tasks: + task.cancel() + await asyncio.gather(*media_tasks, return_exceptions=True) + media_tasks.clear() + def make_chunk(delta_content: dict) -> str: data = { "id": completion_id, @@ -1222,212 +1442,240 @@ def make_chunk(delta_content: dict) -> str: return f"data: {orjson.dumps(data).decode('utf-8')}\n\n" try: - if hasattr(resp_or_stream, "__aiter__"): - generator = cast(AsyncGenerator[ModelOutput], resp_or_stream) - else: - generator = _make_async_gen(cast(ModelOutput, resp_or_stream)) - - async for chunk in generator: - all_outputs.append(chunk) - if not has_started: - yield make_chunk( - {"delta": {"role": "assistant", "content": ""}, "finish_reason": None} - ) - has_started = True + try: + if hasattr(resp_or_stream, "__aiter__"): + generator = cast(AsyncGenerator[ModelOutput], resp_or_stream) + else: + generator = _make_async_gen(cast(ModelOutput, resp_or_stream)) - if t_delta := chunk.thoughts_delta: - full_thoughts += t_delta - yield make_chunk( - {"delta": {"reasoning_content": t_delta}, "finish_reason": None} - ) + async for chunk in generator: + last_output = chunk + if not has_started: + yield make_chunk( + {"delta": {"role": "assistant", "content": ""}, "finish_reason": None} + ) + has_started = True - if text_delta := chunk.text_delta: - full_text += text_delta - if not structured_requirement and ( - visible_delta := suppressor.process(text_delta) - ): + if t_delta := chunk.thoughts_delta: + full_thoughts += t_delta yield make_chunk( - {"delta": {"content": visible_delta}, "finish_reason": None} + {"delta": {"reasoning_content": t_delta}, "finish_reason": None} ) - for img in chunk.images or []: - if img.url and img.url not in seen_image_urls: - seen_image_urls.add(img.url) - media_tasks.append(asyncio.create_task(_process_image_item(img))) - - m_list = (chunk.videos or []) + (chunk.media or []) - for m in m_list: - p_url = getattr(m, "url", None) or getattr(m, "mp3_url", None) - if p_url and p_url not in seen_media_urls: - seen_media_urls.add(p_url) - media_tasks.append(asyncio.create_task(_process_media_item(m))) - except Exception as e: - logger.error(f"Error during streaming: {e}") - yield f"data: {orjson.dumps({'error': {'message': f'Streaming error occurred: {e}', 'type': 'server_error', 'param': None, 'code': None}}).decode('utf-8')}\n\n" - return - - if all_outputs: - final_chunk = all_outputs[-1] - if final_chunk.thoughts: - f_thoughts = final_chunk.thoughts - ft_len, ct_len = len(f_thoughts), len(full_thoughts) - if ft_len > ct_len and f_thoughts.startswith(full_thoughts): - drift_t = f_thoughts[ct_len:] - full_thoughts = f_thoughts - yield make_chunk( - {"delta": {"reasoning_content": drift_t}, "finish_reason": None} - ) + if text_delta := chunk.text_delta: + full_text += text_delta + if not structured_requirement and ( + visible_delta := suppressor.process(text_delta) + ): + yield make_chunk( + {"delta": {"content": visible_delta}, "finish_reason": None} + ) - if final_chunk.text: - f_text = final_chunk.text - f_len, c_len = len(f_text), len(full_text) - if f_len > c_len and f_text.startswith(full_text): - drift = f_text[c_len:] - full_text = f_text - if not structured_requirement and (visible_drift := suppressor.process(drift)): + for img in chunk.images or []: + if img.url and img.url not in seen_image_urls: + seen_image_urls.add(img.url) + media_tasks.append(asyncio.create_task(_process_image_item(img))) + + m_list = (chunk.videos or []) + (chunk.media or []) + for m in m_list: + p_url = getattr(m, "url", None) or getattr(m, "mp3_url", None) + if p_url and p_url not in seen_media_urls: + seen_media_urls.add(p_url) + media_tasks.append(asyncio.create_task(_process_media_item(m))) + except Exception as e: + logger.error(f"Error during streaming: {e}") + await discard_media_tasks() + yield _sse_error(f"Streaming error occurred: {e}", "server_error") + return + + if last_output is not None: + final_chunk = last_output + if final_chunk.thoughts: + f_thoughts = final_chunk.thoughts + ft_len, ct_len = len(f_thoughts), len(full_thoughts) + if ft_len > ct_len and f_thoughts.startswith(full_thoughts): + drift_t = f_thoughts[ct_len:] + full_thoughts = f_thoughts yield make_chunk( - {"delta": {"content": visible_drift}, "finish_reason": None} + {"delta": {"reasoning_content": drift_t}, "finish_reason": None} ) - if not structured_requirement and (remaining_text := suppressor.flush()): - yield make_chunk({"delta": {"content": remaining_text}, "finish_reason": None}) - - _, visible_output, storage_output, detected_tool_calls = process_llm_output( - normalize_llm_text(full_thoughts or ""), - normalize_llm_text(full_text or ""), - structured_requirement, - ) - if structured_requirement and visible_output: - yield make_chunk({"delta": {"content": visible_output}, "finish_reason": None}) + if final_chunk.text: + f_text = final_chunk.text + f_len, c_len = len(f_text), len(full_text) + if f_len > c_len and f_text.startswith(full_text): + drift = f_text[c_len:] + full_text = f_text + if not structured_requirement and ( + visible_drift := suppressor.process(drift) + ): + yield make_chunk( + {"delta": {"content": visible_drift}, "finish_reason": None} + ) - seen_hashes = {} - seen_media_hashes = {} - media_store = get_media_store_dir() + if not structured_requirement and (remaining_text := suppressor.flush()): + yield make_chunk({"delta": {"content": remaining_text}, "finish_reason": None}) - if media_tasks: - logger.debug(f"Waiting for {len(media_tasks)} background media tasks with heartbeat...") - while media_tasks: - done, pending = await asyncio.wait( - media_tasks, timeout=5.0, return_when=asyncio.FIRST_COMPLETED + try: + _, visible_output, storage_output, detected_tool_calls = process_llm_output( + normalize_llm_text(full_thoughts or ""), + normalize_llm_text(full_text or ""), + structured_requirement, ) - media_tasks = list(pending) + except StructuredOutputValidationError as exc: + await discard_media_tasks() + yield _sse_error( + str(exc), "invalid_model_output", "response_format", "schema_validation_failed" + ) + return + # No `has_images` escape hatch here: Chat Completions has no image-generation tool, so an + # image Gemini volunteers on its own cannot stand in for a function call that was forced. + if choice_error := _tool_choice_failure(tool_choice, detected_tool_calls): + await discard_media_tasks() + yield _sse_error( + choice_error, "invalid_model_output", "tool_choice", "required_tool_missing" + ) + return + if structured_requirement and visible_output: + yield make_chunk({"delta": {"content": visible_output}, "finish_reason": None}) - if not done: - yield ": ping\n\n" - continue + seen_hashes = {} + seen_media_hashes = {} + media_store = get_media_store_dir() + + if media_tasks: + logger.debug( + f"Waiting for {len(media_tasks)} background media tasks with heartbeat..." + ) + while media_tasks: + done, pending = await asyncio.wait( + media_tasks, timeout=5.0, return_when=asyncio.FIRST_COMPLETED + ) + media_tasks = list(pending) - for task in done: - res = task.result() - if not res: + if not done: + yield ": ping\n\n" continue - rtype, original_item, media_data = res - if rtype == "image": - _, _, _, fname, fhash = media_data - if fhash in seen_hashes: - (media_store / fname).unlink(missing_ok=True) - fname = seen_hashes[fhash] - else: - seen_hashes[fhash] = fname - - img_url = f"{base_url}media/{fname}?token={get_media_token(fname)}" - title = getattr(original_item, "title", "Image") - md = f"![{title}]({img_url})" - storage_output += f"\n\n{md}" - yield make_chunk({"delta": {"content": f"\n\n{md}"}, "finish_reason": None}) - - elif rtype == "media": - m_dict = cast(ProcessedMediaData, media_data) - if not m_dict: + for task in done: + res = task.result() + if not res: continue - m_urls = {} - for mtype, (random_name, fhash) in m_dict.items(): - if fhash in seen_media_hashes: - existing_name = seen_media_hashes[fhash] - if random_name != existing_name: - (media_store / random_name).unlink(missing_ok=True) - m_urls[mtype] = ( - f"{base_url}media/{existing_name}?token={get_media_token(existing_name)}" - ) + rtype, original_item, media_data = res + if rtype == "image": + _, _, _, fname, fhash = media_data + if fhash in seen_hashes: + (media_store / fname).unlink(missing_ok=True) + fname = seen_hashes[fhash] else: - seen_media_hashes[fhash] = random_name - m_urls[mtype] = ( - f"{base_url}media/{random_name}?token={get_media_token(random_name)}" - ) - - title = getattr(original_item, "title", "Media") - video_url = m_urls.get("video") - audio_url = m_urls.get("audio") - current_thumb = m_urls.get("video_thumbnail") or m_urls.get( - "audio_thumbnail" - ) - - md_parts = [] - if video_url: - md_parts.append( - f"[![{title}]({current_thumb})]({video_url})" - if current_thumb - else f"[{title}]({video_url})" - ) - if audio_url: - md_parts.append( - f"[![{title} - Audio]({current_thumb})]({audio_url})" - if current_thumb - else f"[{title} - Audio]({audio_url})" - ) + seen_hashes[fhash] = fname - if md_parts: - md = "\n\n".join(md_parts) + img_url = f"{base_url}media/{fname}?token={get_media_token(fname)}" + title = getattr(original_item, "title", "Image") + md = f"![{title}]({img_url})" storage_output += f"\n\n{md}" yield make_chunk( {"delta": {"content": f"\n\n{md}"}, "finish_reason": None} ) - if detected_tool_calls: - for idx, call in enumerate(detected_tool_calls): - tc_dict = { - "index": idx, - "id": call.id, - "type": "function", - "function": {"name": call.function.name, "arguments": call.function.arguments}, - } + elif rtype == "media": + m_dict = cast(ProcessedMediaData, media_data) + if not m_dict: + continue + + m_urls = {} + for mtype, (random_name, fhash) in m_dict.items(): + if fhash in seen_media_hashes: + existing_name = seen_media_hashes[fhash] + if random_name != existing_name: + (media_store / random_name).unlink(missing_ok=True) + m_urls[mtype] = ( + f"{base_url}media/{existing_name}?token={get_media_token(existing_name)}" + ) + else: + seen_media_hashes[fhash] = random_name + m_urls[mtype] = ( + f"{base_url}media/{random_name}?token={get_media_token(random_name)}" + ) - yield make_chunk( - { - "delta": { - "tool_calls": [tc_dict], + title = getattr(original_item, "title", "Media") + video_url = m_urls.get("video") + audio_url = m_urls.get("audio") + current_thumb = m_urls.get("video_thumbnail") or m_urls.get( + "audio_thumbnail" + ) + + md_parts = [] + if video_url: + md_parts.append( + f"[![{title}]({current_thumb})]({video_url})" + if current_thumb + else f"[{title}]({video_url})" + ) + if audio_url: + md_parts.append( + f"[![{title} - Audio]({current_thumb})]({audio_url})" + if current_thumb + else f"[{title} - Audio]({audio_url})" + ) + + if md_parts: + md = "\n\n".join(md_parts) + storage_output += f"\n\n{md}" + yield make_chunk( + {"delta": {"content": f"\n\n{md}"}, "finish_reason": None} + ) + + if detected_tool_calls: + for idx, call in enumerate(detected_tool_calls): + tc_dict = { + "index": idx, + "id": call.id, + "type": "function", + "function": { + "name": call.function.name, + "arguments": call.function.arguments, }, - "finish_reason": None, } - ) - p_tok, c_tok, t_tok, r_tok = calculate_usage( - messages, storage_output, detected_tool_calls, full_thoughts - ) - usage = CompletionUsage( - prompt_tokens=p_tok, - completion_tokens=c_tok, - total_tokens=t_tok, - completion_tokens_details={"reasoning_tokens": r_tok}, - ) - _persist_conversation( - db, - resolved_model, - client_wrapper, - session.metadata, - messages, - storage_output, - detected_tool_calls, - ) - yield make_chunk( - { - "delta": {}, - "finish_reason": "tool_calls" if detected_tool_calls else "stop", - "usage": dump_model(usage), - } - ) - yield "data: [DONE]\n\n" + yield make_chunk( + { + "delta": { + "tool_calls": [tc_dict], + }, + "finish_reason": None, + } + ) + + p_tok, c_tok, t_tok, r_tok = calculate_usage( + messages, storage_output, detected_tool_calls, full_thoughts + ) + usage = CompletionUsage( + prompt_tokens=p_tok, + completion_tokens=c_tok, + total_tokens=t_tok, + completion_tokens_details={"reasoning_tokens": r_tok}, + ) + _persist_conversation( + db, + resolved_model, + client_wrapper, + session.metadata, + messages, + storage_output, + detected_tool_calls, + ) + yield make_chunk( + { + "delta": {}, + "finish_reason": "tool_calls" if detected_tool_calls else "stop", + "usage": dump_model(usage), + } + ) + yield "data: [DONE]\n\n" + finally: + await discard_media_tasks() return StreamingResponse(generate_stream(), media_type="text/event-stream") @@ -1445,6 +1693,7 @@ def _create_responses_real_streaming_response( request: ResponseCreateRequest, base_url: str, structured_requirement: StructuredOutputRequirement | None = None, + has_image_tool: bool = False, ) -> StreamingResponse: """ Create a real-time streaming response for the Responses API. @@ -1467,616 +1716,546 @@ def make_event(etype: str, data: dict) -> str: seq += 1 return f"event: {etype}\ndata: {orjson.dumps(data).decode()}\n\n" - yield make_event( - "response.created", - { - **base_event, - "type": "response.created", - "response": { - "id": response_id, - "object": "response", - "created_at": created_time, - "model": model_name, - "status": "in_progress", - "metadata": request.metadata or {}, - "input": None, - "tools": serialize_tools_for_response(request.tools), - "tool_choice": serialize_tool_choice_for_response(request.tool_choice), - "output": [], - "usage": None, - }, - }, - ) - yield make_event( - "response.in_progress", - { - **base_event, - "type": "response.in_progress", - "response": { - "id": response_id, - "object": "response", - "created_at": created_time, - "model": model_name, - "status": "in_progress", - "metadata": request.metadata or {}, - "output": [], - }, - }, - ) - - full_text = "" - full_thoughts = "" media_tasks = [] seen_media_urls = set() seen_image_urls = set() - all_outputs: list[ModelOutput] = [] - - thought_item_id = f"rs_{uuid.uuid4().hex[:24]}" - message_item_id = f"msg_{uuid.uuid4().hex[:24]}" - - thought_open, message_open = False, False - next_output_index = 0 - thought_index = 0 - message_index = 0 - suppressor = StreamingOutputFilter() + async def discard_media_tasks() -> None: + """Cancel downloads whose results cannot be published after a stream failure.""" + if not media_tasks: + return + for task in media_tasks: + task.cancel() + await asyncio.gather(*media_tasks, return_exceptions=True) + media_tasks.clear() try: - if hasattr(resp_or_stream, "__aiter__"): - generator = cast(AsyncGenerator[ModelOutput], resp_or_stream) - else: + yield make_event( + "response.created", + { + **base_event, + "type": "response.created", + "response": { + "id": response_id, + "object": "response", + "created_at": created_time, + "model": model_name, + "status": "in_progress", + "metadata": request.metadata or {}, + "input": None, + "tools": serialize_tools_for_response(request.tools), + "tool_choice": serialize_tool_choice_for_response(request.tool_choice), + "output": [], + "usage": None, + }, + }, + ) + yield make_event( + "response.in_progress", + { + **base_event, + "type": "response.in_progress", + "response": { + "id": response_id, + "object": "response", + "created_at": created_time, + "model": model_name, + "status": "in_progress", + "metadata": request.metadata or {}, + "output": [], + }, + }, + ) - async def _make_async_gen(item: ModelOutput) -> AsyncGenerator[ModelOutput]: - yield item + full_text = "" + full_thoughts = "" + last_output: ModelOutput | None = None - generator = _make_async_gen(cast(ModelOutput, resp_or_stream)) + thought_item_id = f"rs_{uuid.uuid4().hex[:24]}" + message_item_id = f"msg_{uuid.uuid4().hex[:24]}" - async for chunk in generator: - all_outputs.append(chunk) + thought_open, message_open = False, False + next_output_index = 0 + thought_index = 0 + message_index = 0 + suppressor = StreamingOutputFilter() - if chunk.thoughts_delta: - if not thought_open: - thought_index = next_output_index - next_output_index += 1 - yield make_event( - "response.output_item.added", - { - **base_event, - "type": "response.output_item.added", - "output_index": thought_index, - "item": dump_model( - ResponseReasoningItem( - id=thought_item_id, - type="reasoning", - status="in_progress", - summary=[], - ) - ), - }, - ) + try: + if hasattr(resp_or_stream, "__aiter__"): + generator = cast(AsyncGenerator[ModelOutput], resp_or_stream) + else: - yield make_event( - "response.reasoning_summary_part.added", - { - **base_event, - "type": "response.reasoning_summary_part.added", - "item_id": thought_item_id, - "output_index": thought_index, - "summary_index": 0, - "part": dump_model(SummaryTextContent(text="")), - }, - ) - thought_open = True + async def _make_async_gen(item: ModelOutput) -> AsyncGenerator[ModelOutput]: + yield item - full_thoughts += chunk.thoughts_delta - yield make_event( - "response.reasoning_summary_text.delta", - { - **base_event, - "type": "response.reasoning_summary_text.delta", - "item_id": thought_item_id, - "output_index": thought_index, - "summary_index": 0, - "delta": chunk.thoughts_delta, - }, - ) + generator = _make_async_gen(cast(ModelOutput, resp_or_stream)) - if chunk.text_delta: - full_text += chunk.text_delta - if thought_open: - yield make_event( - "response.reasoning_summary_text.done", - { - **base_event, - "type": "response.reasoning_summary_text.done", - "item_id": thought_item_id, - "output_index": thought_index, - "summary_index": 0, - "text": full_thoughts, - }, - ) - yield make_event( - "response.reasoning_summary_part.done", - { - **base_event, - "type": "response.reasoning_summary_part.done", - "item_id": thought_item_id, - "output_index": thought_index, - "summary_index": 0, - "part": dump_model(SummaryTextContent(text=full_thoughts)), - }, - ) - yield make_event( - "response.output_item.done", - { - **base_event, - "type": "response.output_item.done", - "output_index": thought_index, - "item": dump_model( - ResponseReasoningItem( - id=thought_item_id, - type="reasoning", - status="completed", - summary=[SummaryTextContent(text=full_thoughts)], - ) - ), - }, - ) - thought_open = False + async for chunk in generator: + last_output = chunk - if not structured_requirement: - if not message_open: - message_index = next_output_index + if chunk.thoughts_delta: + if not thought_open: + thought_index = next_output_index next_output_index += 1 yield make_event( "response.output_item.added", { **base_event, "type": "response.output_item.added", - "output_index": message_index, + "output_index": thought_index, "item": dump_model( - ResponseOutputMessage( - id=message_item_id, - type="message", + ResponseReasoningItem( + id=thought_item_id, + type="reasoning", status="in_progress", - role="assistant", - content=[], + summary=[], ) ), }, ) yield make_event( - "response.content_part.added", - { - **base_event, - "type": "response.content_part.added", - "item_id": message_item_id, - "output_index": message_index, - "content_index": 0, - "part": dump_model( - ResponseOutputText(type="output_text", text="") - ), - }, - ) - message_open = True - - if visible := suppressor.process(chunk.text_delta): - yield make_event( - "response.output_text.delta", + "response.reasoning_summary_part.added", { **base_event, - "type": "response.output_text.delta", - "item_id": message_item_id, - "output_index": message_index, - "content_index": 0, - "delta": visible, - "logprobs": [], + "type": "response.reasoning_summary_part.added", + "item_id": thought_item_id, + "output_index": thought_index, + "summary_index": 0, + "part": dump_model(SummaryTextContent(text="")), }, ) + thought_open = True - for img in chunk.images or []: - if img.url and img.url not in seen_image_urls: - seen_image_urls.add(img.url) - media_tasks.append(asyncio.create_task(_process_image_item(img))) - - m_list = (chunk.videos or []) + (chunk.media or []) - for m in m_list: - p_url = getattr(m, "url", None) or getattr(m, "mp3_url", None) - if p_url and p_url not in seen_media_urls: - seen_media_urls.add(p_url) - media_tasks.append(asyncio.create_task(_process_media_item(m))) - - except Exception as e: - logger.error(f"Error during streaming: {e}") - yield make_event( - "error", - { - **base_event, - "type": "error", - "error": {"message": f"Streaming error occurred: {e}"}, - }, - ) - return - - if all_outputs: - last = all_outputs[-1] - if last.thoughts: - l_thoughts = last.thoughts - lt_len, ct_len = len(l_thoughts), len(full_thoughts) - if lt_len > ct_len and l_thoughts.startswith(full_thoughts): - drift_t = l_thoughts[ct_len:] - full_thoughts = l_thoughts - if not thought_open: - thought_index = next_output_index - next_output_index += 1 - yield make_event( - "response.output_item.added", - { - **base_event, - "type": "response.output_item.added", - "output_index": thought_index, - "item": dump_model( - ResponseReasoningItem( - id=thought_item_id, - type="reasoning", - status="in_progress", - summary=[], - ) - ), - }, - ) + full_thoughts += chunk.thoughts_delta yield make_event( - "response.reasoning_summary_part.added", + "response.reasoning_summary_text.delta", { **base_event, - "type": "response.reasoning_summary_part.added", + "type": "response.reasoning_summary_text.delta", "item_id": thought_item_id, "output_index": thought_index, "summary_index": 0, - "part": dump_model(SummaryTextContent(text="")), + "delta": chunk.thoughts_delta, }, ) - thought_open = True - - yield make_event( - "response.reasoning_summary_text.delta", - { - **base_event, - "type": "response.reasoning_summary_text.delta", - "item_id": thought_item_id, - "output_index": thought_index, - "summary_index": 0, - "delta": drift_t, - }, - ) - if last.text: - l_text = last.text - l_len, c_len = len(l_text), len(full_text) - if l_len > c_len and l_text.startswith(full_text): - drift = l_text[c_len:] - full_text = l_text - if not structured_requirement and (visible := suppressor.process(drift)): - if not message_open: - message_index = next_output_index - next_output_index += 1 + if chunk.text_delta: + full_text += chunk.text_delta + if thought_open: yield make_event( - "response.output_item.added", + "response.reasoning_summary_text.done", { **base_event, - "type": "response.output_item.added", - "output_index": message_index, - "item": dump_model( - ResponseOutputMessage( - id=message_item_id, - type="message", - status="in_progress", - role="assistant", - content=[], - ) - ), + "type": "response.reasoning_summary_text.done", + "item_id": thought_item_id, + "output_index": thought_index, + "summary_index": 0, + "text": full_thoughts, }, ) yield make_event( - "response.content_part.added", + "response.reasoning_summary_part.done", { **base_event, - "type": "response.content_part.added", - "item_id": message_item_id, - "output_index": message_index, - "content_index": 0, - "part": dump_model( - ResponseOutputText(type="output_text", text="") + "type": "response.reasoning_summary_part.done", + "item_id": thought_item_id, + "output_index": thought_index, + "summary_index": 0, + "part": dump_model(SummaryTextContent(text=full_thoughts)), + }, + ) + yield make_event( + "response.output_item.done", + { + **base_event, + "type": "response.output_item.done", + "output_index": thought_index, + "item": dump_model( + ResponseReasoningItem( + id=thought_item_id, + type="reasoning", + status="completed", + summary=[SummaryTextContent(text=full_thoughts)], + ) ), }, ) - message_open = True - - yield make_event( - "response.output_text.delta", - { - **base_event, - "type": "response.output_text.delta", - "item_id": message_item_id, - "output_index": message_index, - "content_index": 0, - "delta": visible, - "logprobs": [], - }, - ) + thought_open = False - remaining = "" if structured_requirement else suppressor.flush() - if remaining and message_open: - yield make_event( - "response.output_text.delta", - { - **base_event, - "type": "response.output_text.delta", - "item_id": message_item_id, - "output_index": message_index, - "content_index": 0, - "delta": remaining, - "logprobs": [], - }, - ) + if not structured_requirement: + if not message_open: + message_index = next_output_index + next_output_index += 1 + yield make_event( + "response.output_item.added", + { + **base_event, + "type": "response.output_item.added", + "output_index": message_index, + "item": dump_model( + ResponseOutputMessage( + id=message_item_id, + type="message", + status="in_progress", + role="assistant", + content=[], + ) + ), + }, + ) - if thought_open: - yield make_event( - "response.reasoning_summary_text.done", - { - **base_event, - "type": "response.reasoning_summary_text.done", - "item_id": thought_item_id, - "output_index": thought_index, - "summary_index": 0, - "text": full_thoughts, - }, - ) - yield make_event( - "response.reasoning_summary_part.done", - { - **base_event, - "type": "response.reasoning_summary_part.done", - "item_id": thought_item_id, - "output_index": thought_index, - "summary_index": 0, - "part": dump_model(SummaryTextContent(text=full_thoughts)), - }, - ) - yield make_event( - "response.output_item.done", - { - **base_event, - "type": "response.output_item.done", - "output_index": thought_index, - "item": dump_model( - ResponseReasoningItem( - id=thought_item_id, - type="reasoning", - status="completed", - summary=[SummaryTextContent(text=full_thoughts)], - ) - ), - }, - ) + yield make_event( + "response.content_part.added", + { + **base_event, + "type": "response.content_part.added", + "item_id": message_item_id, + "output_index": message_index, + "content_index": 0, + "part": dump_model( + ResponseOutputText(type="output_text", text="") + ), + }, + ) + message_open = True - _, assistant_text, storage_output, detected_tool_calls = process_llm_output( - normalize_llm_text(full_thoughts or ""), - normalize_llm_text(full_text or ""), - structured_requirement, - ) + if visible := suppressor.process(chunk.text_delta): + yield make_event( + "response.output_text.delta", + { + **base_event, + "type": "response.output_text.delta", + "item_id": message_item_id, + "output_index": message_index, + "content_index": 0, + "delta": visible, + "logprobs": [], + }, + ) - if structured_requirement and assistant_text and not message_open: - message_index = next_output_index - next_output_index += 1 - yield make_event( - "response.output_item.added", - { - **base_event, - "type": "response.output_item.added", - "output_index": message_index, - "item": dump_model( - ResponseOutputMessage( - id=message_item_id, - type="message", - status="in_progress", - role="assistant", - content=[], - ) - ), - }, - ) - yield make_event( - "response.content_part.added", - { - **base_event, - "type": "response.content_part.added", - "item_id": message_item_id, - "output_index": message_index, - "content_index": 0, - "part": dump_model(ResponseOutputText(type="output_text", text="")), - }, - ) - message_open = True - yield make_event( - "response.output_text.delta", - { - **base_event, - "type": "response.output_text.delta", - "item_id": message_item_id, - "output_index": message_index, - "content_index": 0, - "delta": assistant_text, - "logprobs": [], - }, - ) + for img in chunk.images or []: + if img.url and img.url not in seen_image_urls: + seen_image_urls.add(img.url) + media_tasks.append(asyncio.create_task(_process_image_item(img))) - image_items = [] - seen_hashes = {} - seen_media_hashes = {} - media_store = get_media_store_dir() + m_list = (chunk.videos or []) + (chunk.media or []) + for m in m_list: + p_url = getattr(m, "url", None) or getattr(m, "mp3_url", None) + if p_url and p_url not in seen_media_urls: + seen_media_urls.add(p_url) + media_tasks.append(asyncio.create_task(_process_media_item(m))) - if media_tasks: - logger.debug( - f"Waiting for {len(media_tasks)} background media tasks in Responses with heartbeat..." - ) - while media_tasks: - done, pending = await asyncio.wait( - media_tasks, timeout=5.0, return_when=asyncio.FIRST_COMPLETED + except Exception as e: + logger.error(f"Error during streaming: {e}") + await discard_media_tasks() + yield make_event( + "error", + { + **base_event, + "type": "error", + "error": {"message": f"Streaming error occurred: {e}"}, + }, ) - media_tasks = list(pending) - - if not done: - yield ": ping\n\n" - continue - - for task in done: - res = task.result() - if not res: - continue - - rtype, original_item, media_data = res - if rtype == "image": - b64, w, h, fname, fhash = media_data - if fhash in seen_hashes: - (media_store / fname).unlink(missing_ok=True) - b64, w, h, fname = seen_hashes[fhash] - else: - seen_hashes[fhash] = (b64, w, h, fname) - - parts = fname.rsplit(".", 1) - img_id = parts[0] - fmt = parts[1] if len(parts) > 1 else "png" - - img_item = ImageGenerationCall( - id=img_id, - result=b64, - output_format=fmt, - size=f"{w}x{h}" if w and h else None, - ) - - img_link = ( - f"![{fname}]({base_url}media/{fname}?token={get_media_token(fname)})" - ) - md_to_add = f"\n\n{img_link}" - - img_index = next_output_index - next_output_index += 1 - yield make_event( - "response.output_item.added", - { - **base_event, - "type": "response.output_item.added", - "output_index": img_index, - "item": dump_model(img_item), - }, - ) - yield make_event( - "response.output_item.done", - { - **base_event, - "type": "response.output_item.done", - "output_index": img_index, - "item": dump_model(img_item), - }, - ) - - if not message_open: - message_index = next_output_index + return + + if last_output is not None: + last = last_output + if last.thoughts: + l_thoughts = last.thoughts + lt_len, ct_len = len(l_thoughts), len(full_thoughts) + if lt_len > ct_len and l_thoughts.startswith(full_thoughts): + drift_t = l_thoughts[ct_len:] + full_thoughts = l_thoughts + if not thought_open: + thought_index = next_output_index next_output_index += 1 yield make_event( "response.output_item.added", { **base_event, "type": "response.output_item.added", - "output_index": message_index, + "output_index": thought_index, "item": dump_model( - ResponseOutputMessage( - id=message_item_id, - type="message", + ResponseReasoningItem( + id=thought_item_id, + type="reasoning", status="in_progress", - role="assistant", - content=[], + summary=[], ) ), }, ) yield make_event( - "response.content_part.added", + "response.reasoning_summary_part.added", { **base_event, - "type": "response.content_part.added", - "item_id": message_item_id, - "output_index": message_index, - "content_index": 0, - "part": dump_model( - ResponseOutputText(type="output_text", text="") - ), + "type": "response.reasoning_summary_part.added", + "item_id": thought_item_id, + "output_index": thought_index, + "summary_index": 0, + "part": dump_model(SummaryTextContent(text="")), }, ) - message_open = True + thought_open = True yield make_event( - "response.output_text.delta", + "response.reasoning_summary_text.delta", { **base_event, - "type": "response.output_text.delta", - "item_id": message_item_id, - "output_index": message_index, - "content_index": 0, - "delta": md_to_add, - "logprobs": [], + "type": "response.reasoning_summary_text.delta", + "item_id": thought_item_id, + "output_index": thought_index, + "summary_index": 0, + "delta": drift_t, }, ) - assistant_text += md_to_add - storage_output += md_to_add - image_items.append(img_item) - - elif rtype == "media": - m_dict = cast(ProcessedMediaData, media_data) - if not m_dict: - continue - m_urls = {} - for mtype, (random_name, fhash) in m_dict.items(): - if fhash in seen_media_hashes: - existing_name = seen_media_hashes[fhash] - if random_name != existing_name: - (media_store / random_name).unlink(missing_ok=True) - m_urls[mtype] = ( - f"{base_url}media/{existing_name}?token={get_media_token(existing_name)}" + if last.text: + l_text = last.text + l_len, c_len = len(l_text), len(full_text) + if l_len > c_len and l_text.startswith(full_text): + drift = l_text[c_len:] + full_text = l_text + if not structured_requirement and (visible := suppressor.process(drift)): + if not message_open: + message_index = next_output_index + next_output_index += 1 + yield make_event( + "response.output_item.added", + { + **base_event, + "type": "response.output_item.added", + "output_index": message_index, + "item": dump_model( + ResponseOutputMessage( + id=message_item_id, + type="message", + status="in_progress", + role="assistant", + content=[], + ) + ), + }, ) - else: - seen_media_hashes[fhash] = random_name - m_urls[mtype] = ( - f"{base_url}media/{random_name}?token={get_media_token(random_name)}" + yield make_event( + "response.content_part.added", + { + **base_event, + "type": "response.content_part.added", + "item_id": message_item_id, + "output_index": message_index, + "content_index": 0, + "part": dump_model( + ResponseOutputText(type="output_text", text="") + ), + }, ) + message_open = True - title = getattr(original_item, "title", "Media") - video_url = m_urls.get("video") - audio_url = m_urls.get("audio") - current_thumb = m_urls.get("video_thumbnail") or m_urls.get( - "audio_thumbnail" - ) + yield make_event( + "response.output_text.delta", + { + **base_event, + "type": "response.output_text.delta", + "item_id": message_item_id, + "output_index": message_index, + "content_index": 0, + "delta": visible, + "logprobs": [], + }, + ) - md_parts = [] - if video_url: - md_parts.append( - f"[![{title}]({current_thumb})]({video_url})" - if current_thumb - else f"[{title}]({video_url})" + remaining = "" if structured_requirement else suppressor.flush() + if remaining and message_open: + yield make_event( + "response.output_text.delta", + { + **base_event, + "type": "response.output_text.delta", + "item_id": message_item_id, + "output_index": message_index, + "content_index": 0, + "delta": remaining, + "logprobs": [], + }, + ) + + if thought_open: + yield make_event( + "response.reasoning_summary_text.done", + { + **base_event, + "type": "response.reasoning_summary_text.done", + "item_id": thought_item_id, + "output_index": thought_index, + "summary_index": 0, + "text": full_thoughts, + }, + ) + yield make_event( + "response.reasoning_summary_part.done", + { + **base_event, + "type": "response.reasoning_summary_part.done", + "item_id": thought_item_id, + "output_index": thought_index, + "summary_index": 0, + "part": dump_model(SummaryTextContent(text=full_thoughts)), + }, + ) + yield make_event( + "response.output_item.done", + { + **base_event, + "type": "response.output_item.done", + "output_index": thought_index, + "item": dump_model( + ResponseReasoningItem( + id=thought_item_id, + type="reasoning", + status="completed", + summary=[SummaryTextContent(text=full_thoughts)], + ) + ), + }, + ) + + try: + _, assistant_text, storage_output, detected_tool_calls = process_llm_output( + normalize_llm_text(full_thoughts or ""), + normalize_llm_text(full_text or ""), + structured_requirement, + ) + except StructuredOutputValidationError as exc: + await discard_media_tasks() + yield make_event( + "error", + { + **base_event, + "type": "error", + "error": { + "message": str(exc), + "type": "invalid_model_output", + "param": "text.format", + "code": "schema_validation_failed", + }, + }, + ) + return + + if structured_requirement and assistant_text and not message_open: + message_index = next_output_index + next_output_index += 1 + yield make_event( + "response.output_item.added", + { + **base_event, + "type": "response.output_item.added", + "output_index": message_index, + "item": dump_model( + ResponseOutputMessage( + id=message_item_id, + type="message", + status="in_progress", + role="assistant", + content=[], ) - if audio_url: - md_parts.append( - f"[![{title} - Audio]({current_thumb})]({audio_url})" - if current_thumb - else f"[{title} - Audio]({audio_url})" + ), + }, + ) + yield make_event( + "response.content_part.added", + { + **base_event, + "type": "response.content_part.added", + "item_id": message_item_id, + "output_index": message_index, + "content_index": 0, + "part": dump_model(ResponseOutputText(type="output_text", text="")), + }, + ) + message_open = True + yield make_event( + "response.output_text.delta", + { + **base_event, + "type": "response.output_text.delta", + "item_id": message_item_id, + "output_index": message_index, + "content_index": 0, + "delta": assistant_text, + "logprobs": [], + }, + ) + + image_items = [] + seen_hashes = {} + seen_media_hashes = {} + media_store = get_media_store_dir() + + if media_tasks: + logger.debug( + f"Waiting for {len(media_tasks)} background media tasks in Responses with heartbeat..." + ) + while media_tasks: + done, pending = await asyncio.wait( + media_tasks, timeout=5.0, return_when=asyncio.FIRST_COMPLETED + ) + media_tasks = list(pending) + + if not done: + yield ": ping\n\n" + continue + + for task in done: + res = task.result() + if not res: + continue + + rtype, original_item, media_data = res + if rtype == "image": + b64, w, h, fname, fhash = media_data + if fhash in seen_hashes: + (media_store / fname).unlink(missing_ok=True) + b64, w, h, fname = seen_hashes[fhash] + else: + seen_hashes[fhash] = (b64, w, h, fname) + + parts = fname.rsplit(".", 1) + img_id = parts[0] + fmt = parts[1] if len(parts) > 1 else "png" + + img_item = ImageGenerationCall( + id=img_id, + result=b64, + output_format=fmt, + size=f"{w}x{h}" if w and h else None, ) - if md_parts: - media_md = "\n\n".join(md_parts) - md_to_add = f"\n\n{media_md}" + img_link = f"![{fname}]({base_url}media/{fname}?token={get_media_token(fname)})" + md_to_add = f"\n\n{img_link}" + + img_index = next_output_index + next_output_index += 1 + yield make_event( + "response.output_item.added", + { + **base_event, + "type": "response.output_item.added", + "output_index": img_index, + "item": dump_model(img_item), + }, + ) + yield make_event( + "response.output_item.done", + { + **base_event, + "type": "response.output_item.done", + "output_index": img_index, + "item": dump_model(img_item), + }, + ) if not message_open: message_index = next_output_index @@ -2127,127 +2306,248 @@ async def _make_async_gen(item: ModelOutput) -> AsyncGenerator[ModelOutput]: ) assistant_text += md_to_add storage_output += md_to_add + image_items.append(img_item) + + elif rtype == "media": + m_dict = cast(ProcessedMediaData, media_data) + if not m_dict: + continue + + m_urls = {} + for mtype, (random_name, fhash) in m_dict.items(): + if fhash in seen_media_hashes: + existing_name = seen_media_hashes[fhash] + if random_name != existing_name: + (media_store / random_name).unlink(missing_ok=True) + m_urls[mtype] = ( + f"{base_url}media/{existing_name}?token={get_media_token(existing_name)}" + ) + else: + seen_media_hashes[fhash] = random_name + m_urls[mtype] = ( + f"{base_url}media/{random_name}?token={get_media_token(random_name)}" + ) - final_response_contents: list[ResponseOutputContent] = [] - if message_open: - if assistant_text: - final_response_contents = [ - ResponseOutputText(type="output_text", text=assistant_text) - ] - else: - final_response_contents = [ResponseOutputText(type="output_text", text="")] + title = getattr(original_item, "title", "Media") + video_url = m_urls.get("video") + audio_url = m_urls.get("audio") + current_thumb = m_urls.get("video_thumbnail") or m_urls.get( + "audio_thumbnail" + ) - yield make_event( - "response.output_text.done", - { - **base_event, - "type": "response.output_text.done", - "item_id": message_item_id, - "output_index": message_index, - "content_index": 0, - }, - ) - yield make_event( - "response.content_part.done", - { - **base_event, - "type": "response.content_part.done", - "item_id": message_item_id, - "output_index": message_index, - "content_index": 0, - "part": dump_model(ResponseOutputText(type="output_text", text=assistant_text)), - }, - ) + md_parts = [] + if video_url: + md_parts.append( + f"[![{title}]({current_thumb})]({video_url})" + if current_thumb + else f"[{title}]({video_url})" + ) + if audio_url: + md_parts.append( + f"[![{title} - Audio]({current_thumb})]({audio_url})" + if current_thumb + else f"[{title} - Audio]({audio_url})" + ) - yield make_event( - "response.output_item.done", - { - **base_event, - "type": "response.output_item.done", - "output_index": message_index, - "item": dump_model( - ResponseOutputMessage( - id=message_item_id, - type="message", - status="completed", - role="assistant", - content=final_response_contents, - ) - ), - }, - ) + if md_parts: + media_md = "\n\n".join(md_parts) + md_to_add = f"\n\n{media_md}" + + if not message_open: + message_index = next_output_index + next_output_index += 1 + yield make_event( + "response.output_item.added", + { + **base_event, + "type": "response.output_item.added", + "output_index": message_index, + "item": dump_model( + ResponseOutputMessage( + id=message_item_id, + type="message", + status="in_progress", + role="assistant", + content=[], + ) + ), + }, + ) + yield make_event( + "response.content_part.added", + { + **base_event, + "type": "response.content_part.added", + "item_id": message_item_id, + "output_index": message_index, + "content_index": 0, + "part": dump_model( + ResponseOutputText(type="output_text", text="") + ), + }, + ) + message_open = True - for call in detected_tool_calls: - tc_index = next_output_index - next_output_index += 1 - tc_item = ResponseFunctionToolCall( - id=call.id, - call_id=call.id, - name=call.function.name, - arguments=call.function.arguments, - status="completed", + yield make_event( + "response.output_text.delta", + { + **base_event, + "type": "response.output_text.delta", + "item_id": message_item_id, + "output_index": message_index, + "content_index": 0, + "delta": md_to_add, + "logprobs": [], + }, + ) + assistant_text += md_to_add + storage_output += md_to_add + + final_response_contents: list[ResponseOutputContent] = [] + if choice_error := _tool_choice_failure( + request.tool_choice, + detected_tool_calls, + has_images=bool(image_items), + has_image_tool=has_image_tool, + ): + yield make_event( + "error", + { + **base_event, + "type": "error", + "error": { + "message": choice_error, + "type": "invalid_model_output", + "param": "tool_choice", + "code": "required_tool_missing", + }, + }, + ) + return + if message_open: + if assistant_text: + final_response_contents = [ + ResponseOutputText(type="output_text", text=assistant_text) + ] + else: + final_response_contents = [ResponseOutputText(type="output_text", text="")] + + yield make_event( + "response.output_text.done", + { + **base_event, + "type": "response.output_text.done", + "item_id": message_item_id, + "output_index": message_index, + "content_index": 0, + }, + ) + yield make_event( + "response.content_part.done", + { + **base_event, + "type": "response.content_part.done", + "item_id": message_item_id, + "output_index": message_index, + "content_index": 0, + "part": dump_model( + ResponseOutputText(type="output_text", text=assistant_text) + ), + }, + ) + + yield make_event( + "response.output_item.done", + { + **base_event, + "type": "response.output_item.done", + "output_index": message_index, + "item": dump_model( + ResponseOutputMessage( + id=message_item_id, + type="message", + status="completed", + role="assistant", + content=final_response_contents, + ) + ), + }, + ) + + for call in detected_tool_calls: + tc_index = next_output_index + next_output_index += 1 + tc_item = ResponseFunctionToolCall( + id=call.id, + call_id=call.id, + name=call.function.name, + arguments=call.function.arguments, + status="completed", + ) + yield make_event( + "response.output_item.added", + { + **base_event, + "type": "response.output_item.added", + "output_index": tc_index, + "item": dump_model(tc_item), + }, + ) + yield make_event( + "response.output_item.done", + { + **base_event, + "type": "response.output_item.done", + "output_index": tc_index, + "item": dump_model(tc_item), + }, + ) + + p_tok, c_tok, t_tok, r_tok = calculate_usage( + messages, storage_output, detected_tool_calls, full_thoughts ) - yield make_event( - "response.output_item.added", - { - **base_event, - "type": "response.output_item.added", - "output_index": tc_index, - "item": dump_model(tc_item), - }, + usage = ResponseUsage( + input_tokens=p_tok, + output_tokens=c_tok, + total_tokens=t_tok, + output_tokens_details={"reasoning_tokens": r_tok}, + ) + payload = _create_responses_standard_payload( + response_id, + created_time, + model_name, + detected_tool_calls, + image_items, + final_response_contents, + usage, + request, + structured_requirement, + full_thoughts, + message_item_id, + thought_item_id, + ) + _persist_conversation( + db, + resolved_model, + client_wrapper, + session.metadata, + messages, + storage_output, + detected_tool_calls, ) + yield make_event( - "response.output_item.done", + "response.completed", { **base_event, - "type": "response.output_item.done", - "output_index": tc_index, - "item": dump_model(tc_item), + "type": "response.completed", + "response": dump_model(payload), }, ) - p_tok, c_tok, t_tok, r_tok = calculate_usage( - messages, storage_output, detected_tool_calls, full_thoughts - ) - usage = ResponseUsage( - input_tokens=p_tok, - output_tokens=c_tok, - total_tokens=t_tok, - output_tokens_details={"reasoning_tokens": r_tok}, - ) - payload = _create_responses_standard_payload( - response_id, - created_time, - model_name, - detected_tool_calls, - image_items, - final_response_contents, - usage, - request, - full_thoughts, - message_item_id, - thought_item_id, - ) - _persist_conversation( - db, - resolved_model, - client_wrapper, - session.metadata, - messages, - storage_output, - detected_tool_calls, - ) - - yield make_event( - "response.completed", - { - **base_event, - "type": "response.completed", - "response": dump_model(payload), - }, - ) - - yield "data: [DONE]\n\n" + yield "data: [DONE]\n\n" + finally: + await discard_media_tasks() return StreamingResponse(generate_stream(), media_type="text/event-stream") @@ -2278,7 +2578,15 @@ async def create_chat_completion( if not request.messages: raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Messages required.") - structured_requirement = _build_structured_requirement(request.response_format) + _log_ignored_openai_options(request) + function_names = {tool.function.name for tool in request.tools or []} + if choice_error := _tool_choice_declaration_error(function_names, False, request.tool_choice): + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=choice_error) + + try: + structured_requirement = _build_structured_requirement(request.response_format) + except ValueError as exc: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc extra_instr = [structured_requirement.instruction] if structured_requirement else None app_messages = convert_to_app_messages(request.messages) @@ -2372,6 +2680,7 @@ async def create_chat_completion( session, base_url, structured_requirement, + request.tool_choice, ) if not isinstance(resp_or_stream, ModelOutput): @@ -2380,13 +2689,21 @@ async def create_chat_completion( status_code=status.HTTP_502_BAD_GATEWAY, detail="Unexpected streaming response." ) - thoughts, visible_output, storage_output, tool_calls = process_llm_output( - normalize_llm_text(resp_or_stream.thoughts or ""), - normalize_llm_text(resp_or_stream.text or ""), - structured_requirement, - ) + try: + thoughts, visible_output, storage_output, tool_calls = process_llm_output( + normalize_llm_text(resp_or_stream.thoughts or ""), + normalize_llm_text(resp_or_stream.text or ""), + structured_requirement, + ) + except StructuredOutputValidationError as exc: + raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail=str(exc)) from exc images = resp_or_stream.images or [] + # No `has_images` escape hatch here: Chat Completions has no image-generation tool, so an + # image Gemini volunteers on its own cannot stand in for a function call that was forced. + if choice_error := _tool_choice_failure(request.tool_choice, tool_calls): + raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail=choice_error) + media_items: list[GeneratedVideo | GeneratedMedia] = (resp_or_stream.videos or []) + ( resp_or_stream.media or [] ) @@ -2518,8 +2835,14 @@ async def create_response( tmp_dir: Path = Depends(get_temp_dir), ): base_url = str(raw_request.base_url) + _log_ignored_openai_options(request) + if input_error := _validate_responses_input(request.input): + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=input_error) base_messages = _convert_responses_to_app_messages(request.input) - structured_requirement = _build_structured_requirement(request.response_format) + try: + structured_requirement = _build_structured_requirement(_responses_response_format(request)) + except ValueError as exc: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc extra_instr = [structured_requirement.instruction] if structured_requirement else [] standard_tools, image_tools = [], [] @@ -2535,18 +2858,31 @@ async def create_response( elif t.get("type") == "image_generation": image_tools.append(ImageGeneration.model_validate(t)) + if ignored_image_options := { + name for image_tool in image_tools for name in image_tool.model_fields_set if name != "type" + }: + logger.debug( + "Ignoring image-generation option(s) unsupported by the Gemini Web upstream: " + f"{', '.join(sorted(ignored_image_options))}" + ) + + if choice_error := _tool_choice_declaration_error( + {tool.name for tool in standard_tools}, + bool(image_tools), + request.tool_choice, + ): + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=choice_error) + img_instr = build_image_generation_instruction( image_tools, - request.tool_choice if isinstance(request.tool_choice, ToolChoiceFunction) else None, + request.tool_choice if isinstance(request.tool_choice, ToolChoiceTypes) else None, ) if img_instr: extra_instr.append(img_instr) preface = _convert_instructions_to_app_messages(request.instructions) conv_messages = [*preface, *base_messages] if preface else base_messages model_tool_choice = ( - request.tool_choice - if isinstance(request.tool_choice, (str, ChatCompletionNamedToolChoice)) - else None + request.tool_choice if isinstance(request.tool_choice, (str, ToolChoiceFunction)) else None ) messages = _prepare_messages_for_model( @@ -2569,8 +2905,8 @@ async def create_response( if session: msgs = _prepare_messages_for_model( remain, - request.tools, - request.tool_choice, + standard_tools or None, + model_tool_choice, None, False, ) @@ -2641,6 +2977,7 @@ async def create_response( request, base_url, structured_requirement, + bool(image_tools), ) if not isinstance(resp_or_stream, ModelOutput): @@ -2649,17 +2986,22 @@ async def create_response( status_code=status.HTTP_502_BAD_GATEWAY, detail="Unexpected streaming response." ) - thoughts, assistant_text, storage_output, tool_calls = process_llm_output( - normalize_llm_text(resp_or_stream.thoughts or ""), - normalize_llm_text(resp_or_stream.text or ""), - structured_requirement, - ) + try: + thoughts, assistant_text, storage_output, tool_calls = process_llm_output( + normalize_llm_text(resp_or_stream.thoughts or ""), + normalize_llm_text(resp_or_stream.text or ""), + structured_requirement, + ) + except StructuredOutputValidationError as exc: + raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail=str(exc)) from exc images = resp_or_stream.images or [] - if ( - isinstance(request.tool_choice, ToolChoiceTypes) - and request.tool_choice.type == "image_generation" - ) and not images: - raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail="No images returned.") + if choice_error := _tool_choice_failure( + request.tool_choice, + tool_calls, + has_images=bool(images), + has_image_tool=bool(image_tools), + ): + raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail=choice_error) unique_media = [] seen_urls = set() @@ -2781,6 +3123,7 @@ async def create_response( contents, usage, request, + structured_requirement, thoughts, ) _persist_conversation( diff --git a/app/server/gemini.py b/app/server/gemini.py index f16096d..6d3b35d 100644 --- a/app/server/gemini.py +++ b/app/server/gemini.py @@ -1,26 +1,18 @@ -"""Gemini REST API v1beta native endpoints (ported from fork fujunchao, adapted to our tree). +"""Native Gemini REST API v1beta endpoints. -Endpoints (Google Gemini API compatible): -- GET /v1beta/models — list models -- GET /v1beta/models/{model} — get one model -- POST /v1beta/models/{model}:generateContent — non-streaming generation +- GET /v1beta/models — list models +- GET /v1beta/models/{model} — get one model +- POST /v1beta/models/{model}:generateContent — non-streaming generation - POST /v1beta/models/{model}:streamGenerateContent — streaming (SSE) -Reuses OUR chat.py helpers; no chat.py changes were made (C1 parallel-safety). -Adaptation deltas vs the fork (see .metacog/fork-evals/fujunchao.md §5): -- helpers live in app/utils/helper.py as calculate_usage / process_llm_output / - normalize_llm_text (no _-prefix, no chat.py copies) -- image store helpers are get_media_store_dir / get_media_token; media served at - /media/{fname}?token= (not /images/) -- _find_reusable_session returns a 4-tuple here (session, client, remain, conv) -- _persist_conversation takes the client wrapper and no thoughts argument here -- _get_available_models requires a pool: pass GeminiClientPool() (a singleton that - lifespan already initialized) -- GeminiClientWrapper.extract_output does not exist here; use normalize_llm_text -- internal message types are AppMessage/AppContentItem/AppToolCall(+Function), - tools are FunctionTool (flat schema) -- Gemini fileData (Files API URI) parts are logged and skipped: our pipeline only - accepts base64 file_data items, and a Google fileUri cannot be fetched locally. +Requests are translated into the same internal AppMessage/FunctionTool pipeline the +OpenAI-shaped routes use, so this module owns only the Gemini wire format: it converts +`contents` in, converts candidates back out, and renders errors in Google's envelope. + +Two Gemini features have no local equivalent and are refused rather than dropped, because +silently ignoring them would change what the model is answering: `fileData` (a Files API URI +this process cannot fetch) and `cachedContent`. Generation controls Gemini Web does not expose +are accepted and logged instead - see `_log_ignored_gemini_options`. """ from __future__ import annotations @@ -46,6 +38,7 @@ AppToolCall, AppToolCallFunction, FunctionTool, + StructuredOutputRequirement, ToolChoiceFunction, ) from app.models.gemini_models import ( @@ -56,6 +49,7 @@ GeminiFunctionCall, GeminiGenerateContentRequest, GeminiGenerateContentResponse, + GeminiGenerationConfig, GeminiInlineData, GeminiModelInfo, GeminiModelListResponse, @@ -63,7 +57,7 @@ GeminiUsageMetadata, ) -# 从 chat.py 导入已有辅助函数(不修改 chat.py) +# Shared request/response pipeline helpers; this module owns only the Gemini wire format. from app.server.chat import ( StreamingOutputFilter, _build_structured_requirement, @@ -72,8 +66,11 @@ _image_to_base64, _persist_conversation, _prepare_messages_for_model, + _requires_upload, _resolve_model_name, - _send_with_split, + _send_with_internal_fallback, + _tool_choice_failure, + _use_temporary_chat_mode, ) from app.server.middleware import ( get_media_store_dir, @@ -82,7 +79,15 @@ verify_gemini_api_key, ) from app.services import GeminiClientPool, GeminiClientWrapper, LMDBConversationStore -from app.utils.helper import calculate_usage, normalize_llm_text, process_llm_output +from app.utils.helper import ( + StructuredOutputValidationError, + calculate_usage, + guess_extension_for_mime, + normalize_llm_text, + normalize_openapi_schema, + process_llm_output, + validate_json_schema, +) router = APIRouter() @@ -173,12 +178,18 @@ def _gemini_contents_to_messages( text_fragments.append(part.text) if part.inlineData: - data_url = f"data:{part.inlineData.mimeType};base64,{part.inlineData.data}" - content_items.append(AppContentItem(type="image_url", url=data_url)) + suffix = guess_extension_for_mime(part.inlineData.mimeType) + content_items.append( + AppContentItem( + type="file", + file_data=part.inlineData.data, + filename=f"inline{suffix}", + ) + ) if part.fileData: - # Our pipeline only accepts base64 file_data items; a Google Files API - # fileUri cannot be fetched locally. Log and skip (disclosed in C1 report). + # Unreachable via the routes, which reject fileData up front; kept so a direct + # caller of this converter degrades instead of silently mis-building a prompt. logger.warning( "[Gemini API] Skipping fileData part " f"(unsupported): {reprlib.repr(part.fileData.fileUri)}" @@ -273,12 +284,29 @@ def _gemini_tools_to_internal( ) tool_choice: Literal["none", "auto", "required"] | ToolChoiceFunction | None = None if tool_config and tool_config.functionCallingConfig: - mode = tool_config.functionCallingConfig.mode.upper() + call_config = tool_config.functionCallingConfig + mode = call_config.mode.upper() + + # Google: "This should only be set when the Mode is ANY or VALIDATED." Acting on it + # under AUTO or NONE would hide tools the upstream would still have offered. + allowed_names = ( + set(call_config.allowedFunctionNames or []) + if mode in {"ANY", "VALIDATED"} + else set[str]() + ) + if allowed_names: + internal_tools = [tool for tool in internal_tools if tool.name in allowed_names] + if mode == "NONE": tool_choice = cast(Literal["none", "auto", "required"], "none") elif mode == "ANY": - tool_choice = cast(Literal["none", "auto", "required"], "required") - else: # AUTO + tool_choice = ( + ToolChoiceFunction(type="function", name=next(iter(allowed_names))) + if len(allowed_names) == 1 + else cast(Literal["none", "auto", "required"], "required") + ) + else: + # AUTO, and VALIDATED - which also permits a natural-language answer. tool_choice = "auto" return internal_tools or None, tool_choice @@ -348,8 +376,46 @@ def _to_gemini_error(status_code: int, message: str, grpc_status: str) -> Gemini ) +def _log_ignored_gemini_options( + request: GeminiGenerateContentRequest, response_schema: dict[str, Any] | None +) -> None: + """Debug-log valid Gemini options that the Gemini Web client cannot forward. + + `response_schema` is the already-translated result of `_gemini_response_schema`, passed in + rather than recomputed so the OpenAPI translation and its validation run once per request. + """ + ignored: set[str] = set() + if "safetySettings" in request.model_fields_set: + ignored.add("safetySettings") + + if gen_cfg := request.generationConfig: + supported_structured_fields: set[str] = set() + if gen_cfg.responseMimeType == "application/json": + supported_structured_fields.add("responseMimeType") + # The schema fields count as honored only if one survived translation. An empty schema + # is valid and still counts; `None` alone means translation failed or none was supplied. + if response_schema is not None: + supported_structured_fields.update(("responseSchema", "responseJsonSchema")) + ignored.update( + name for name in gen_cfg.model_fields_set if name not in supported_structured_fields + ) + + if request.toolConfig and (call_config := request.toolConfig.functionCallingConfig): + mode = call_config.mode.upper() + if mode == "VALIDATED": + ignored.add("toolConfig.functionCallingConfig.mode") + if call_config.allowedFunctionNames and mode not in {"ANY", "VALIDATED"}: + ignored.add("toolConfig.functionCallingConfig.allowedFunctionNames") + + if ignored: + logger.debug( + "[Gemini API] Ignoring option(s) unsupported by the Gemini Web upstream: " + f"{', '.join(sorted(ignored))}" + ) + + def _validate_gemini_request(request: GeminiGenerateContentRequest) -> str | None: - """Reject only structures that would otherwise be silently dropped from the prompt.""" + """Reject malformed or unrepresentable inputs, not optional generation controls.""" if not request.contents: return "contents is required and cannot be empty." @@ -362,9 +428,89 @@ def _validate_gemini_request(request: GeminiGenerateContentRequest) -> str | Non part.fileData is not None for part in request.systemInstruction.parts ): return "fileData is not supported; provide the data using inlineData instead." + + if request.cachedContent is not None: + return "cachedContent is not supported by the Gemini Web upstream." + + if request.toolConfig and request.toolConfig.functionCallingConfig: + call_config = request.toolConfig.functionCallingConfig + # Only meaningful in the modes that act on it; elsewhere it is ignored, not invalid. + if call_config.mode.upper() in {"ANY", "VALIDATED"}: + declared_names = { + declaration.name + for tool in request.tools or [] + for declaration in tool.functionDeclarations + } + allowed_names = set(call_config.allowedFunctionNames or []) + if unknown_names := allowed_names - declared_names: + return ( + f"allowedFunctionNames contains undeclared functions: {sorted(unknown_names)}" + ) + + if request.generationConfig: + gen_cfg = request.generationConfig + # Only `responseJsonSchema` is JSON Schema, so only it can be judged as such. + # `responseSchema` is the OpenAPI subset, translated at use time; a gap in that + # translation must not turn a valid Gemini request into a 400. + if ( + gen_cfg.responseMimeType == "application/json" + and gen_cfg.responseJsonSchema is not None + ): + try: + validate_json_schema(gen_cfg.responseJsonSchema) + except ValueError as exc: + return str(exc) return None +def _gemini_response_schema(gen_cfg: GeminiGenerationConfig) -> dict[str, Any] | None: + """Return the requested response schema as JSON Schema, or None if it cannot be used.""" + if gen_cfg.responseMimeType != "application/json": + return None + + if gen_cfg.responseJsonSchema is not None: + return gen_cfg.responseJsonSchema + + if gen_cfg.responseSchema is None: + return None + + schema = normalize_openapi_schema(gen_cfg.responseSchema) + try: + validate_json_schema(schema) + except ValueError as exc: + # Enforcing a schema we could not translate would reject good answers. + logger.debug(f"[Gemini API] Ignoring responseSchema that is not representable: {exc}") + return None + return schema + + +def _gemini_structured_requirement( + request: GeminiGenerateContentRequest, +) -> tuple[dict[str, Any] | None, StructuredOutputRequirement | None]: + """Translate the requested response schema once, returning it with its requirement. + + The requirement is deliberately non-strict. Google's own API guarantees conformance through + constrained decoding; Gemini Web offers no such control, so the schema can only be asked for + in the prompt. Failing the request on a near-miss would throw away an answer the caller can + still use, so a violation degrades to the raw text and is logged instead. + """ + if not request.generationConfig: + return None, None + + gen_cfg = request.generationConfig + if gen_cfg.responseMimeType != "application/json": + return None, None + + schema = _gemini_response_schema(gen_cfg) + response_format = ( + {"type": "json_object"} + if schema is None + else {"type": "json_schema", "json_schema": {"schema": schema, "strict": False}} + ) + requirement = _build_structured_requirement(response_format) + return schema, requirement + + def _strip_model_prefix(model: str) -> str: """Strip a leading 'models/' prefix if present.""" return model[len("models/") :] if model.startswith("models/") else model @@ -442,27 +588,28 @@ async def gemini_generate_content( if validation_error := _validate_gemini_request(request): err = _to_gemini_error(400, validation_error, "INVALID_ARGUMENT") return JSONResponse(status_code=400, content=err.model_dump(mode="json")) + response_schema, structured_requirement = _gemini_structured_requirement(request) + _log_ignored_gemini_options(request, response_schema) messages = _gemini_contents_to_messages(request.contents, request.systemInstruction) internal_tools, tool_choice = _gemini_tools_to_internal(request.tools, request.toolConfig) - structured_requirement = None - if request.generationConfig: - gen_cfg = request.generationConfig - schema = gen_cfg.responseSchema or gen_cfg.responseJsonSchema - if gen_cfg.responseMimeType == "application/json" and schema: - structured_requirement = _build_structured_requirement( - {"type": "json_schema", "json_schema": {"schema": schema}} - ) - extra_instr = [structured_requirement.instruction] if structured_requirement else None msgs = _prepare_messages_for_model(messages, internal_tools, tool_choice, extra_instr) pool, db = GeminiClientPool(), LMDBConversationStore() - - session, client, remain, _conv = await _find_reusable_session(db, pool, model_obj, msgs) + use_temporary = _use_temporary_chat_mode() + needs_upload = _requires_upload(msgs, use_temporary) + session, client, remain, stored_conv = await _find_reusable_session( + db, + pool, + model_obj, + msgs, + temporary=use_temporary, + require_account=needs_upload, + ) if session: if not remain: @@ -479,8 +626,8 @@ async def gemini_generate_content( ) else: try: - client = await pool.acquire() - session = client.start_chat(model=model_obj) + client = await pool.acquire(require_account=needs_upload) + session = client.start_chat(model=client.usable_model(model_obj)) m_input, files = await GeminiClientWrapper.process_conversation(msgs, tmp_dir) except Exception as e: logger.exception("[Gemini API] Failed to prepare session") @@ -493,8 +640,19 @@ async def gemini_generate_content( logger.debug( f"[Gemini API] Client: {client.id}, input len: {len(m_input)}, files: {len(files)}" ) - resp = await _send_with_split( - session, m_input, files=cast("list[Path | str | io.BytesIO]", files), stream=False + resp, session, client = await _send_with_internal_fallback( + pool=pool, + db=db, + resolved_model=model_obj, + session=session, + client=client, + current_input=m_input, + files=cast("list[Path | str | io.BytesIO]", files), + full_prepared_messages=msgs, + stored_conversation=stored_conv, + tmp_dir=tmp_dir, + stream=False, + temporary=use_temporary, ) except Exception as e: logger.exception("[Gemini API] Gemini call failed") @@ -510,9 +668,16 @@ async def gemini_generate_content( err = _to_gemini_error(502, "Malformed response.", "INTERNAL") return JSONResponse(status_code=502, content=err.model_dump(mode="json")) - thoughts, visible_output, storage_output, tool_calls = process_llm_output( - thoughts, raw_clean, structured_requirement - ) + try: + thoughts, visible_output, storage_output, tool_calls = process_llm_output( + thoughts, raw_clean, structured_requirement + ) + except StructuredOutputValidationError as exc: + err = _to_gemini_error(502, str(exc), "INTERNAL") + return JSONResponse(status_code=502, content=err.model_dump(mode="json")) + if choice_error := _tool_choice_failure(tool_choice, tool_calls): + err = _to_gemini_error(502, choice_error, "INTERNAL") + return JSONResponse(status_code=502, content=err.model_dump(mode="json")) # Images: collect Gemini images → inlineData parts + markdown URL for LMDB persistence image_parts: list[GeminiPart] = [] @@ -581,24 +746,26 @@ async def gemini_stream_generate_content( if validation_error := _validate_gemini_request(request): err = _to_gemini_error(400, validation_error, "INVALID_ARGUMENT") return JSONResponse(status_code=400, content=err.model_dump(mode="json")) + response_schema, structured_requirement = _gemini_structured_requirement(request) + _log_ignored_gemini_options(request, response_schema) messages = _gemini_contents_to_messages(request.contents, request.systemInstruction) internal_tools, tool_choice = _gemini_tools_to_internal(request.tools, request.toolConfig) - structured_requirement = None - if request.generationConfig: - gen_cfg = request.generationConfig - schema = gen_cfg.responseSchema or gen_cfg.responseJsonSchema - if gen_cfg.responseMimeType == "application/json" and schema: - structured_requirement = _build_structured_requirement( - {"type": "json_schema", "json_schema": {"schema": schema}} - ) - extra_instr = [structured_requirement.instruction] if structured_requirement else None msgs = _prepare_messages_for_model(messages, internal_tools, tool_choice, extra_instr) pool, db = GeminiClientPool(), LMDBConversationStore() - session, client, remain, _conv = await _find_reusable_session(db, pool, model_obj, msgs) + use_temporary = _use_temporary_chat_mode() + needs_upload = _requires_upload(msgs, use_temporary) + session, client, remain, stored_conv = await _find_reusable_session( + db, + pool, + model_obj, + msgs, + temporary=use_temporary, + require_account=needs_upload, + ) if session: if not remain: @@ -611,8 +778,8 @@ async def gemini_stream_generate_content( m_input, files = await GeminiClientWrapper.process_conversation(input_msgs, tmp_dir) else: try: - client = await pool.acquire() - session = client.start_chat(model=model_obj) + client = await pool.acquire(require_account=needs_upload) + session = client.start_chat(model=client.usable_model(model_obj)) m_input, files = await GeminiClientWrapper.process_conversation(msgs, tmp_dir) except Exception as e: logger.exception("[Gemini API] Failed to prepare streaming session") @@ -622,8 +789,19 @@ async def gemini_stream_generate_content( try: assert session is not None assert client is not None - generator = await _send_with_split( - session, m_input, files=cast("list[Path | str | io.BytesIO]", files), stream=True + generator, session, client = await _send_with_internal_fallback( + pool=pool, + db=db, + resolved_model=model_obj, + session=session, + client=client, + current_input=m_input, + files=cast("list[Path | str | io.BytesIO]", files), + full_prepared_messages=msgs, + stored_conversation=stored_conv, + tmp_dir=tmp_dir, + stream=True, + temporary=use_temporary, ) except Exception as e: logger.exception("[Gemini API] Gemini streaming call failed") @@ -636,9 +814,10 @@ async def gemini_stream_generate_content( messages=msgs, original_messages=messages, db=db, - model=model_obj, + resolved_model=model_obj, client_wrapper=client, session=session, + tool_choice=tool_choice, structured_requirement=structured_requirement, base_url=str(raw_request.base_url).rstrip("/"), ) @@ -650,9 +829,10 @@ def _create_gemini_streaming_response( messages: list[AppMessage], original_messages: list[AppMessage], db: LMDBConversationStore, - model, + resolved_model: str, client_wrapper: GeminiClientWrapper, session, + tool_choice: Literal["none", "auto", "required"] | ToolChoiceFunction | None, structured_requirement=None, base_url: str = "", ) -> StreamingResponse: @@ -692,7 +872,9 @@ async def generate_stream(): if text_delta := chunk.text_delta: full_text += text_delta - if visible_delta := suppressor.process(text_delta): + if not structured_requirement and ( + visible_delta := suppressor.process(text_delta) + ): chunk_resp = GeminiGenerateContentResponse( candidates=[ GeminiCandidate( @@ -719,7 +901,7 @@ async def generate_stream(): if last_chunk.thoughts: full_thoughts = last_chunk.thoughts - if remaining_text := suppressor.flush(): + if not structured_requirement and (remaining_text := suppressor.flush()): chunk_resp = GeminiGenerateContentResponse( candidates=[ GeminiCandidate( @@ -734,10 +916,39 @@ async def generate_stream(): yield f"data: {orjson.dumps(chunk_resp.model_dump(mode='json', exclude_none=True)).decode('utf-8')}\n\n" # --- post-processing: protective layer so the SSE tail survives errors --- + # The two expected failures are reported with their own message and status, matching the + # non-streaming route; only genuinely unexpected errors fall through to the catch-all, + # which cannot say anything more useful than that something broke. try: _thoughts, visible_output, storage_output, tool_calls = process_llm_output( full_thoughts, full_text, structured_requirement ) + except StructuredOutputValidationError as exc: + logger.warning(f"[Gemini API] Structured output rejected mid-stream: {exc}") + err_resp = _to_gemini_error(502, str(exc), "INTERNAL") + yield f"data: {orjson.dumps(err_resp.model_dump(mode='json')).decode('utf-8')}\n\n" + return + + if choice_error := _tool_choice_failure(tool_choice, tool_calls): + logger.warning(f"[Gemini API] Forced tool choice unmet mid-stream: {choice_error}") + err_resp = _to_gemini_error(502, choice_error, "INTERNAL") + yield f"data: {orjson.dumps(err_resp.model_dump(mode='json')).decode('utf-8')}\n\n" + return + + try: + if structured_requirement and visible_output: + structured_chunk = GeminiGenerateContentResponse( + candidates=[ + GeminiCandidate( + content=GeminiContent( + role="model", + parts=[GeminiPart(text=visible_output)], + ), + index=0, + ) + ], + ) + yield f"data: {orjson.dumps(structured_chunk.model_dump(mode='json', exclude_none=True)).decode('utf-8')}\n\n" image_store = get_media_store_dir() seen_hashes: set[str] = set() @@ -831,7 +1042,7 @@ async def generate_stream(): _persist_conversation( db, - model.model_name, + resolved_model, client_wrapper, session.metadata, messages, diff --git a/app/server/health.py b/app/server/health.py index 14587f9..44e3c8a 100644 --- a/app/server/health.py +++ b/app/server/health.py @@ -1,4 +1,4 @@ -from fastapi import APIRouter +from fastapi import APIRouter, Response, status from loguru import logger from app.models import HealthCheckResponse @@ -8,7 +8,7 @@ @router.get("/health", response_model=HealthCheckResponse) -async def health_check(): +async def health_check(response: Response): pool = GeminiClientPool() db = LMDBConversationStore() client_status = pool.status() @@ -20,8 +20,18 @@ async def health_check(): if not stat: logger.error("Failed to retrieve LMDB conversation store stats") + response.status_code = status.HTTP_503_SERVICE_UNAVAILABLE return HealthCheckResponse( ok=False, error="LMDB conversation store unavailable", clients=client_status ) - return HealthCheckResponse(ok=all(client_status.values()), storage=stat, clients=client_status) + if not any(client_status.values()): + response.status_code = status.HTTP_503_SERVICE_UNAVAILABLE + return HealthCheckResponse( + ok=False, + error="No usable Gemini client is available", + storage=stat, + clients=client_status, + ) + + return HealthCheckResponse(ok=True, storage=stat, clients=client_status) diff --git a/app/server/media.py b/app/server/media.py index 86b5b70..206b0bd 100644 --- a/app/server/media.py +++ b/app/server/media.py @@ -1,9 +1,33 @@ +import re +from pathlib import Path + from fastapi import APIRouter, HTTPException, Query from fastapi.responses import FileResponse from app.server.middleware import get_media_store_dir, verify_media_token router = APIRouter() +# The extension is whatever the upstream save produced, so it has to allow more than plain +# alphanumerics (`.tar.gz`, `.x-m4a`) while still excluding every path separator and `..`. +MEDIA_FILENAME_RE = re.compile(r"(?:img|media)_[0-9a-f]{32}\.[A-Za-z0-9][A-Za-z0-9.\-_]{0,15}\Z") + + +def _resolve_media_file(media_store: Path, filename: str) -> Path | None: + """Return an existing media file inside the store, or None if the name is not one of ours. + + The name has to match the pattern this server generates, which excludes separators and + traversal outright; the containment check then covers a store reached through a symlink. + """ + if not MEDIA_FILENAME_RE.fullmatch(filename): + return None + + root = media_store.resolve() + candidate = (root / filename).resolve() + try: + candidate.relative_to(root) + except ValueError: + return None + return candidate if candidate.is_file() else None @router.get("/media/{filename}", tags=["Media"]) @@ -11,8 +35,7 @@ async def get_media(filename: str, token: str | None = Query(default=None)): if not verify_media_token(filename, token): raise HTTPException(status_code=403, detail="Invalid token") - media_store = get_media_store_dir() - file_path = media_store / filename - if not file_path.exists(): + file_path = _resolve_media_file(get_media_store_dir(), filename) + if file_path is None: raise HTTPException(status_code=404, detail="Media not found") return FileResponse(file_path) diff --git a/app/server/middleware.py b/app/server/middleware.py index cad5423..f8dae4a 100644 --- a/app/server/middleware.py +++ b/app/server/middleware.py @@ -3,12 +3,14 @@ import tempfile import time from pathlib import Path +from typing import Any from fastapi import Depends, FastAPI, HTTPException, Request, status from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import JSONResponse from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer from loguru import logger +from starlette.types import ASGIApp, Message, Receive, Scope, Send from app.utils import g_config @@ -17,6 +19,96 @@ MEDIA_STORE_DIR.mkdir(parents=True, exist_ok=True) +class RequestBodyLimitMiddleware: + """Reject request bodies past a local ceiling, before the app buffers them. + + A declared `content-length` is refused up front; a chunked body is measured as it arrives + and cut off once it crosses the limit. The ceiling exists to bound this process's memory, + not to describe what Gemini Web will accept - the upstream decides that for itself. + """ + + def __init__(self, app: ASGIApp, max_body_bytes: int) -> None: + self.app = app + self.max_body_bytes = max(0, max_body_bytes) + + async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: + if scope["type"] != "http" or self.max_body_bytes == 0: + await self.app(scope, receive, send) + return + + content_length = next( + ( + value + for name, value in scope.get("headers", []) + if name.lower() == b"content-length" + ), + None, + ) + if content_length is not None: + try: + declared_size = int(content_length) + except ValueError: + declared_size = 0 + if declared_size > self.max_body_bytes: + await self._send_too_large(scope, receive, send) + return + + received = 0 + overflowed = False + + async def limited_receive() -> Message: + nonlocal overflowed, received + message = await receive() + if message["type"] == "http.request": + received += len(message.get("body", b"")) + if received > self.max_body_bytes: + overflowed = True + raise HTTPException( + status_code=status.HTTP_413_CONTENT_TOO_LARGE, + detail=self._detail(), + ) + return message + + async def limited_send(message: Message) -> None: + # FastAPI turns the receive error into its own generic envelope; drop that so the + # surface-appropriate body below is what the client actually gets. + if not overflowed: + await send(message) + + try: + await self.app(scope, limited_receive, limited_send) + except HTTPException: + if not overflowed: + raise + + if overflowed: + await self._send_too_large(scope, receive, send) + + def _detail(self) -> str: + return ( + f"Request body exceeds the wrapper safety ceiling of {self.max_body_bytes} bytes. " + "This is a local resource guard, not Gemini Web's upstream input limit." + ) + + async def _send_too_large(self, scope: Scope, receive: Receive, send: Send) -> None: + detail = self._detail() + if str(scope.get("path", "")).startswith("/v1beta/"): + content: dict[str, Any] = { + "error": { + "code": status.HTTP_413_CONTENT_TOO_LARGE, + "message": detail, + "status": "RESOURCE_EXHAUSTED", + } + } + else: + content = {"error": {"message": detail}} + response = JSONResponse( + status_code=status.HTTP_413_CONTENT_TOO_LARGE, + content=content, + ) + await response(scope, receive, send) + + def get_media_store_dir() -> Path: """Returns a persistent directory for storing media.""" return MEDIA_STORE_DIR @@ -92,15 +184,13 @@ def verify_gemini_api_key(request: Request): return "" # 1) x-goog-api-key header - api_key = request.headers.get("x-goog-api-key") - if api_key: + if api_key := request.headers.get("x-goog-api-key"): if api_key != g_config.server.api_key: raise HTTPException(status.HTTP_403_FORBIDDEN, detail="Wrong API key") return api_key # 2) key= query parameter - api_key = request.query_params.get("key") - if api_key: + if api_key := request.query_params.get("key"): if api_key != g_config.server.api_key: raise HTTPException(status.HTTP_403_FORBIDDEN, detail="Wrong API key") return api_key @@ -136,6 +226,13 @@ def add_exception_handler(app: FastAPI): app.add_exception_handler(Exception, global_exception_handler) +def add_request_size_limit_middleware(app: FastAPI) -> None: + app.add_middleware( + RequestBodyLimitMiddleware, + max_body_bytes=g_config.server.max_request_body_bytes, + ) + + def add_cors_middleware(app: FastAPI): if g_config.cors.enabled: cors = g_config.cors diff --git a/app/services/client.py b/app/services/client.py index 4378b55..4d9d5f0 100644 --- a/app/services/client.py +++ b/app/services/client.py @@ -140,8 +140,10 @@ async def _process_content_item( return None, await save_url_to_tempfile(item_media_url, tempdir) raise ValueError(f"{item.type} cannot be empty") elif item.type == "file": + if file_url := getattr(item, "url", None): + return None, await save_url_to_tempfile(file_url, tempdir) if not (file_data := getattr(item, "file_data", None)): - raise ValueError("File must contain 'file_data'") + raise ValueError("File must contain 'file_data' or 'url'") filename = getattr(item, "filename", "") or "" return None, await save_file_to_tempfile(file_data, filename, tempdir) elif item.type == "input_audio": diff --git a/app/services/lmdb.py b/app/services/lmdb.py index b076447..98afa5b 100644 --- a/app/services/lmdb.py +++ b/app/services/lmdb.py @@ -4,7 +4,7 @@ from contextlib import contextmanager, suppress from datetime import datetime, timedelta from pathlib import Path -from typing import Any +from typing import Any, Self, cast import lmdb import orjson @@ -62,16 +62,29 @@ def _hash_message(message: AppMessage, fuzzy: bool = False) -> str: elif isinstance(content, str): core_data["content"] = _normalize_text(content, fuzzy=fuzzy) elif isinstance(content, list): - text_parts = [] + content_items: list[dict[str, Any]] = [] for item in content: - if item.type == "text" and item.text: - if normalized_part := _normalize_text(item.text, fuzzy=fuzzy): - text_parts.append(normalized_part) - elif item.type != "text" and item.url: - text_parts.append(f"[{item.type}:{item.url}]") - - core_data["content"] = "\n".join(text_parts) if text_parts else None - + item_data: dict[str, Any] = { + "type": item.type, + "filename": item.filename, + "url": item.url, + "content_digest": item.content_digest, + } + if item.text is not None: + item_data["text"] = _normalize_text(item.text, fuzzy=fuzzy) + if item.raw_data is not None: + # Included directly: the outer dump sorts keys recursively, so this is already + # canonical, and digesting it would only serialize the same data a second time. + item_data["raw_data"] = item.raw_data + content_items.append(item_data) + + core_data["content"] = content_items or None + + # `reasoning_content` is deliberately NOT hashed. `_persist_conversation` stores every + # assistant turn it produces with `reasoning_content=None`, while both request converters + # populate it from whatever the client echoes back - and this server does emit reasoning on + # both surfaces. Hashing it would make the stored turn and the replayed turn disagree by + # construction, so the newest prefix could never match and reuse would collapse. if message.tool_calls: calls_data = [] for tc in message.tool_calls: @@ -114,8 +127,42 @@ def _hash_conversation( class LMDBConversationStore(metaclass=Singleton): """LMDB-based storage for Message lists with hash-based key-value operations.""" - HASH_LOOKUP_PREFIX = "hash:" - FUZZY_LOOKUP_PREFIX = "fuzzy:" + # Bump when _hash_message changes shape. Entries under an older version can never match + # again, and their conversations would otherwise keep index rows no eviction can find, + # so startup sweeps them instead of leaving them to accumulate. + # + # The conversation records themselves are keyed by the hash that produced them, so records + # written under an older version stay unreachable after the sweep: a repeat of the same + # conversation is replayed in full and stored again under a current key, and the superseded + # record is left to expire on the normal retention schedule. + INDEX_VERSION = "v2" + HASH_LOOKUP_PREFIX = f"hash:{INDEX_VERSION}:" + FUZZY_LOOKUP_PREFIX = f"fuzzy:{INDEX_VERSION}:" + _INDEX_NAMESPACES = ("hash:", "fuzzy:") + _INTERNAL_NAMESPACES = ("hash:", "fuzzy:", "meta:") + _INDEX_VERSION_KEY = "meta:index_version" + + @classmethod + def open_isolated( + cls, + db_path: str, + max_db_size: int | None = None, + retention_days: int | None = None, + ) -> Self: + """Open a store outside the singleton, for maintenance commands and isolated tests. + + LMDB does not support two environments on one path in a single process, so `db_path` + must not be the path the singleton already holds open. + """ + return cast( + Self, + type.__call__( + cls, + db_path=db_path, + max_db_size=max_db_size, + retention_days=retention_days, + ), + ) def __init__( self, @@ -447,6 +494,51 @@ def _delete_messages_from_database(self, txn, key): logger.debug(f"Deleted messages with key: {key[:12]}") return conv + def _is_index_key(self, key: str) -> bool: + """Whether a raw key is a lookup entry rather than a stored conversation.""" + return key.startswith(self._INDEX_NAMESPACES) + + def _is_internal_key(self, key: str) -> bool: + """Whether a raw key is bookkeeping rather than a stored conversation.""" + return key.startswith(self._INTERNAL_NAMESPACES) + + def prune_stale_indexes(self) -> int: + """Drop lookup entries written under a superseded INDEX_VERSION. + + Only the lookup entries go: the conversation records they pointed at are keyed by the + old hash and cannot be re-indexed under the new one, so they are left to expire under + the normal retention window. + + A marker records that the sweep ran for this version, so later startups skip the scan. + """ + version_key = self._INDEX_VERSION_KEY.encode("utf-8") + try: + with self._get_transaction(write=True) as txn: + if txn.get(version_key) == self.INDEX_VERSION.encode("utf-8"): + return 0 + + stale = [ + bytes(key) + for key, _ in txn.cursor() + if (decoded := bytes(key).decode("utf-8", "replace")) + and self._is_index_key(decoded) + and not decoded.startswith((self.HASH_LOOKUP_PREFIX, self.FUZZY_LOOKUP_PREFIX)) + ] + for key in stale: + txn.delete(key) + txn.put(version_key, self.INDEX_VERSION.encode("utf-8"), overwrite=True) + except Error as exc: + logger.error(f"Failed to prune stale LMDB indexes: {exc}") + return 0 + + if stale: + logger.info( + f"Pruned {len(stale)} LMDB lookup entries from a superseded index version; " + "the conversations behind them are replayed in full once and stored again " + "under a current key, and the superseded records expire under retention." + ) + return len(stale) + def keys(self, prefix: str = "", limit: int | None = None) -> list[str]: """List all keys in the store, optionally filtered by prefix.""" keys = [] @@ -458,8 +550,7 @@ def keys(self, prefix: str = "", limit: int | None = None) -> list[str]: count = 0 for key, _ in cursor: key_str = bytes(key).decode("utf-8") - # Skip internal index mappings - if key_str.startswith((self.HASH_LOOKUP_PREFIX, self.FUZZY_LOOKUP_PREFIX)): + if self._is_internal_key(key_str): continue if not prefix or key_str.startswith(prefix): @@ -481,6 +572,10 @@ def cleanup_expired(self, retention_days: int | None = None) -> int: return 0 cutoff = datetime.now() - timedelta(days=retention_value) + return self.cleanup_before(cutoff) + + def cleanup_before(self, cutoff: datetime) -> int: + """Delete conversations older than an explicit timestamp and repair both indexes.""" expired_entries: list[tuple[str, ConversationInStore]] = [] try: @@ -488,7 +583,7 @@ def cleanup_expired(self, retention_days: int | None = None) -> int: cursor = txn.cursor() for key_bytes, value_bytes in cursor: key_str = bytes(key_bytes).decode("utf-8") - if key_str.startswith((self.HASH_LOOKUP_PREFIX, self.FUZZY_LOOKUP_PREFIX)): + if self._is_internal_key(key_str): continue try: @@ -498,7 +593,9 @@ def cleanup_expired(self, retention_days: int | None = None) -> int: logger.warning(f"Failed to decode record for key {key_str}: {exc}") continue - timestamp = conv.created_at or conv.updated_at + # Last touched, not first created: a conversation still in active use has + # not expired no matter how long ago it started. + timestamp = conv.updated_at or conv.created_at if not timestamp: continue @@ -539,6 +636,19 @@ def cleanup_expired(self, retention_days: int | None = None) -> int: return removed + def clear(self) -> int: + """Delete every conversation and index entry from the store.""" + removed = len(self.keys()) + try: + with self._get_transaction(write=True) as txn: + keys = [bytes(key) for key, _ in txn.cursor()] + for key in keys: + txn.delete(key) + except Error as exc: + logger.error(f"Failed to clear LMDB: {exc}") + raise + return removed + def stats(self) -> Mapping[str, Any]: """Get database statistics.""" if not self._env: diff --git a/app/utils/config.py b/app/utils/config.py index 5de9176..04cec55 100644 --- a/app/utils/config.py +++ b/app/utils/config.py @@ -32,6 +32,23 @@ class ServerConfig(BaseModel): default=None, description="API key for authentication, if set, will enable API key validation", ) + max_request_body_bytes: int = Field( + default=256 * 1024 * 1024, + ge=0, + description=( + "Local HTTP body safety ceiling in bytes (0 disables it). This protects wrapper " + "resources and does not define Gemini Web's upstream acceptance limit" + ), + ) + schema_validation_budget_seconds: float = Field( + default=1.0, + gt=0, + description=( + "Wall-clock budget for evaluating the regex keywords of a client-supplied JSON " + "Schema against one response. Guards against catastrophic backtracking; exhausting " + "it leaves the reply unverified rather than treating it as a schema violation" + ), + ) https: HTTPSConfig = Field(default=HTTPSConfig(), description="HTTPS configuration") diff --git a/app/utils/helper.py b/app/utils/helper.py index a14a1f0..096b5be 100644 --- a/app/utils/helper.py +++ b/app/utils/helper.py @@ -8,14 +8,19 @@ import socket import struct import tempfile +import time import unicodedata from collections.abc import Sequence +from contextvars import ContextVar from pathlib import Path from typing import Any, Literal from urllib.parse import urlparse import orjson +import regex from curl_cffi import CurlFollow, CurlHttpVersion, requests +from jsonschema import SchemaError, ValidationError, validators +from jsonschema.validators import validator_for from loguru import logger from pydantic import BaseModel @@ -60,15 +65,24 @@ ) STRUCTURED_JSON_WRAP_HINT = ( "\n\nSYSTEM: STRUCTURED JSON PROTOCOL (MANDATORY)\n" - "1. Return exactly one fenced block holding one strict JSON document that validates against the JSON Schema below. No prose, no second block.\n" + "1. Return exactly one fenced block holding one strict JSON document. No prose, no second block.\n" "2. Open with ```json and close with a fence of the same length; if the JSON contains a backtick run, both fences MUST be longer.\n" - "3. Emit every required field with its declared type. NEVER truncate the document or omit the closing fence.\n\n" + "3. NEVER truncate the document or omit the closing fence.\n\n" "REQUIRED SYNTAX:\n" "```json\n" '{"field":"value"}\n' "```\n\n" "END STRUCTURED JSON PROTOCOL" ) +# Appended to the protocol above when the client supplied a schema; JSON mode sends the +# protocol alone, because valid JSON of any shape satisfies it. +SCHEMA_ADHERENCE_PROMPT = ( + "The JSON document MUST validate against the JSON Schema below. " + "Emit every required field with its declared type." +) +STRICT_SCHEMA_ADHERENCE_PROMPT = ( + "Strict schema adherence is required: the JSON must conform exactly to the schema." +) TOOL_INTERFACE_PROMPT = ( "SYSTEM INTERFACE: Call an available tool whenever the request requires one, with arguments that " "validate against its JSON Schema. Never invent an undeclared tool or parameter." @@ -126,6 +140,7 @@ CHATML_END_RE = re.compile(r"\\?<\\?\|im\\?_end\\?\|\\?>", re.IGNORECASE) COMMONMARK_UNESCAPE_RE = re.compile(r"\\([!\"#$%&'()*+,\-./:;<=>?@\[\\\]^_`{|}~])") PARAM_FENCE_RE = re.compile(r"^(?P`{3,})") +MIME_SUBTYPE_UNSAFE_RE = re.compile(r"[^A-Za-z0-9._-]") TOOL_HINT_STRIPPED = TOOL_WRAP_HINT.strip() SYSTEM_HINTS = (TOOL_WRAP_HINT, STRUCTURED_JSON_WRAP_HINT) @@ -282,6 +297,198 @@ def estimate_tokens(text: str | None) -> int: return len(text) // 3 if text else 0 +class StructuredOutputValidationError(ValueError): + """Raised when model output cannot satisfy a requested JSON Schema.""" + + +class SchemaEvaluationTimeoutError(ValueError): + """Raised when a client-provided schema exhausts its regex evaluation budget.""" + + +# One cumulative budget for every regex keyword in a response, so it is sized for total workload, +# not for a single pattern: a large conforming payload is ordinary, not pathological. +SCHEMA_REGEX_BUDGET_SECONDS: float = g_config.server.schema_validation_budget_seconds +_schema_regex_deadline: ContextVar[float | None] = ContextVar("schema_regex_deadline", default=None) +_bounded_validator_classes: dict[type[Any], type[Any]] = {} + + +def _bounded_regex_search(pattern: str, value: str) -> bool: + """Search with the remaining request-scoped regex budget.""" + deadline = _schema_regex_deadline.get() + remaining = SCHEMA_REGEX_BUDGET_SECONDS if deadline is None else deadline - time.monotonic() + if remaining <= 0: + raise SchemaEvaluationTimeoutError("JSON Schema regex evaluation exceeded its time limit") + try: + return regex.search(pattern, value, timeout=remaining) is not None + except TimeoutError as exc: + raise SchemaEvaluationTimeoutError( + "JSON Schema regex evaluation exceeded its time limit" + ) from exc + + +def _validate_bounded_pattern(validator, pattern, instance, schema): + if validator.is_type(instance, "string") and not _bounded_regex_search(pattern, instance): + yield ValidationError(f"{instance!r} does not match {pattern!r}") + + +def _validate_bounded_pattern_properties(validator, pattern_properties, instance, schema): + if not validator.is_type(instance, "object"): + return + for pattern, subschema in pattern_properties.items(): + for key, value in instance.items(): + if _bounded_regex_search(pattern, key): + yield from validator.descend( + value, + subschema, + path=key, + schema_path=pattern, + ) + + +def _validate_bounded_additional_properties(validator, additional, instance, schema): + if not validator.is_type(instance, "object"): + return + + properties = schema.get("properties", {}) + patterns = tuple(schema.get("patternProperties", {})) + extras = { + key + for key in instance + if key not in properties + and not any(_bounded_regex_search(pattern, key) for pattern in patterns) + } + if validator.is_type(additional, "object"): + for extra in extras: + yield from validator.descend(instance[extra], additional, path=extra) + elif not additional and extras: + joined = ", ".join(repr(each) for each in sorted(extras, key=str)) + yield ValidationError(f"Additional properties are not allowed ({joined} unexpected)") + + +def _bounded_validator_for(schema: dict[str, Any]): + """Return a dialect-appropriate validator with timeout-bounded regex keywords.""" + base = validator_for(schema) + bounded = _bounded_validator_classes.get(base) + if bounded is None: + bounded = validators.extend( + base, + { + "pattern": _validate_bounded_pattern, + "patternProperties": _validate_bounded_pattern_properties, + "additionalProperties": _validate_bounded_additional_properties, + }, + ) + _bounded_validator_classes[base] = bounded + return bounded(schema) + + +def validate_json_schema(schema: dict[str, Any]) -> None: + """Raise ValueError when a client-provided JSON Schema is not valid.""" + try: + validator_cls = validator_for(schema) + validator_cls.check_schema(schema) + except SchemaError as exc: + raise ValueError(f"Invalid JSON Schema: {exc.message}") from exc + + +_JSON_SCHEMA_TYPE_NAMES = frozenset( + {"string", "number", "integer", "boolean", "array", "object", "null"} +) +_NESTED_SCHEMA_KEYS = frozenset( + {"items", "additionalProperties", "not", "if", "then", "else", "contains", "propertyNames"} +) +_SCHEMA_LIST_KEYS = frozenset({"anyOf", "oneOf", "allOf", "prefixItems"}) +_SCHEMA_MAP_KEYS = frozenset({"properties", "$defs", "definitions", "patternProperties"}) +# OpenAPI-only annotations with no JSON Schema equivalent. +_OPENAPI_ONLY_KEYS = frozenset({"propertyOrdering", "example"}) + + +def normalize_openapi_schema(schema: Any) -> Any: + """Translate Gemini's OpenAPI 3.0 Schema subset into equivalent JSON Schema. + + `generationConfig.responseSchema` spells its types in uppercase (`STRING`, `OBJECT`) and + marks optional values with OpenAPI's `nullable` flag. Neither is valid JSON Schema, so the + schema has to be translated before it can be checked or used to validate a response. + `responseJsonSchema` is already JSON Schema and does not go through here. + """ + if isinstance(schema, list): + return [normalize_openapi_schema(item) for item in schema] + if not isinstance(schema, dict): + return schema + + result: dict[str, Any] = {} + for key, value in schema.items(): + if key in _OPENAPI_ONLY_KEYS: + continue + if key == "type" and isinstance(value, str) and value.lower() in _JSON_SCHEMA_TYPE_NAMES: + result[key] = value.lower() + elif key in _SCHEMA_MAP_KEYS and isinstance(value, dict): + result[key] = {name: normalize_openapi_schema(sub) for name, sub in value.items()} + elif key in _SCHEMA_LIST_KEYS and isinstance(value, list): + result[key] = [normalize_openapi_schema(sub) for sub in value] + elif key in _NESTED_SCHEMA_KEYS: + result[key] = normalize_openapi_schema(value) + else: + result[key] = value + + if result.pop("nullable", None) is True: + declared = result.get("type") + if isinstance(declared, str): + result["type"] = [declared, "null"] + elif isinstance(declared, list) and "null" not in declared: + result["type"] = [*declared, "null"] + return result + + +def decode_base64_data(value: str | bytes) -> bytes: + """Decode raw or data-URL Base64 strictly, ignoring transport whitespace. + + Both the standard and URL-safe alphabets are accepted, since clients that build a payload + with `base64.urlsafe_b64encode` send `-` and `_`. Validation stays on either way: a decode + that silently discarded stray characters would hand Gemini a corrupt file - which is also + why a non-ASCII character is an error rather than something to strip, since dropping it + could turn a corrupt payload into one that decodes cleanly to the wrong bytes. + """ + if isinstance(value, str): + try: + raw = value.encode("ascii") + except UnicodeEncodeError as exc: + raise ValueError("Base64 payload contains non-ASCII characters") from exc + else: + raw = value + if raw.startswith(b"data:"): + metadata, separator, raw = raw.partition(b",") + if not separator or b";base64" not in metadata.lower(): + raise ValueError("Data URL must contain a Base64 payload") + + payload = b"".join(raw.split()) + for altchars in (None, b"-_"): + try: + return base64.b64decode(payload, altchars=altchars, validate=True) + except ValueError: + continue + raise ValueError("Invalid Base64 payload") + + +def guess_extension_for_mime(mime_type: str | None) -> str: + """Best-effort filename extension for a MIME type, never empty. + + `mimetypes` only knows registered types, so unregistered but widely sent ones (`audio/mp3`, + `application/x-*`) fall back to the subtype. That subtype is client-controlled and ends up in + a `NamedTemporaryFile` suffix, so it is scrubbed of anything that could escape the directory. + """ + if not mime_type: + return ".bin" + + mime_type = mime_type.split(";")[0].strip() + if suffix := mimetypes.guess_extension(mime_type): + return suffix + + _, _, subtype = mime_type.partition("/") + subtype = MIME_SUBTYPE_UNSAFE_RE.sub("", subtype).lstrip(".") + return f".{subtype}" if subtype else ".bin" + + async def save_file_to_tempfile( file_in_base64: str | bytes, file_name: str = "", tempdir: Path | None = None ) -> Path: @@ -289,7 +496,7 @@ async def save_file_to_tempfile( with tempfile.NamedTemporaryFile( delete=False, suffix=Path(file_name).suffix if file_name else ".bin", dir=tempdir ) as tmp: - tmp.write(base64.b64decode(file_in_base64)) + tmp.write(decode_base64_data(file_in_base64)) return Path(tmp.name) @@ -334,10 +541,8 @@ async def save_url_to_tempfile(url: str, tempdir: Path | None = None) -> Path: if url.startswith("data:"): metadata_part = url.split(",")[0] mime_type = metadata_part.split(":")[1].split(";")[0] - data = base64.b64decode(url.split(",")[1]) - suffix = mimetypes.guess_extension(mime_type) or ( - f".{mime_type.split('/')[1]}" if "/" in mime_type else ".bin" - ) + data = decode_base64_data(url) + suffix = guess_extension_for_mime(mime_type) with tempfile.NamedTemporaryFile(delete=False, suffix=suffix, dir=tempdir) as tmp: tmp.write(data) return Path(tmp.name) @@ -726,7 +931,12 @@ def convert_to_app_messages(messages: list[ChatCompletionMessage]) -> list[AppMe def canonicalize_structured_output( visible_output: str, structured_requirement: StructuredOutputRequirement ) -> str | None: - """Parse raw or fenced structured JSON and return its canonical JSON representation.""" + """Parse raw or fenced structured JSON and return its canonical JSON representation. + + `None` means the model failed the format, never that this wrapper could not run the check: + a schema that cannot be evaluated still yields the canonical payload, so only the model's + own failures can be enforced against it. + """ candidate = strip_markdown_fence(visible_output) try: structured_payload = orjson.loads(candidate) @@ -736,6 +946,37 @@ def canonicalize_structured_output( ) return None + # An empty schema is JSON mode: parsing was the whole requirement. + if structured_requirement.schema: + try: + deadline_token = _schema_regex_deadline.set( + time.monotonic() + SCHEMA_REGEX_BUDGET_SECONDS + ) + try: + _bounded_validator_for(structured_requirement.schema).validate(structured_payload) + finally: + _schema_regex_deadline.reset(deadline_token) + except ValidationError as exc: + logger.warning( + f"Structured response failed schema validation " + f"(schema={structured_requirement.schema_name}): {exc.message}" + ) + return None + # Both branches below are this wrapper failing to check, not the model failing to comply, + # so neither reports a violation: under `strict` that would 502 a conforming reply. + except SchemaEvaluationTimeoutError as exc: + logger.warning( + f"Structured response left unverified, schema evaluation timed out " + f"(schema={structured_requirement.schema_name}): {exc}" + ) + except Exception as exc: + # `check_schema` does not resolve `$ref`s, so unresolvable references and foreign + # dialects surface only here. + logger.warning( + f"Structured response left unverified, schema is not usable " + f"({structured_requirement.schema_name!r}): {exc}" + ) + canonical_output = orjson.dumps(structured_payload).decode("utf-8") logger.debug(f"Structured response fulfilled (schema={structured_requirement.schema_name}).") return canonical_output @@ -760,17 +1001,25 @@ def process_llm_output( visible_output = visible_output.strip() storage_output = visible_output - if ( - structured_requirement - and visible_output - and ( - canonical_output := canonicalize_structured_output( - visible_output, structured_requirement + if structured_requirement and visible_output: + canonical_output = canonicalize_structured_output(visible_output, structured_requirement) + if canonical_output is not None: + visible_output = canonical_output + storage_output = canonical_output + elif tool_calls: + # The format constrains the final answer, not a turn that asks for a tool. + logger.debug( + "Skipping structured-output enforcement for a turn that returned tool call(s)." + ) + elif structured_requirement.strict: + raise StructuredOutputValidationError( + f"Model output did not satisfy JSON Schema {structured_requirement.schema_name!r}" + ) + else: + logger.warning( + f"Returning unstructured text for best-effort response format " + f"{structured_requirement.schema_name!r}." ) - ) - ): - visible_output = canonical_output - storage_output = canonical_output return thoughts, visible_output, storage_output, tool_calls @@ -863,7 +1112,7 @@ def build_tool_prompt( def build_image_generation_instruction( tools: list[ImageGeneration] | None, - tool_choice: ToolChoiceFunction | None, + tool_choice: ToolChoiceTypes | None, ) -> str | None: """Construct explicit guidance so Gemini emits images when requested.""" has_forced_choice = tool_choice is not None and tool_choice.type == "image_generation" diff --git a/config/config.yaml b/config/config.yaml index d91e165..7b45377 100644 --- a/config/config.yaml +++ b/config/config.yaml @@ -4,6 +4,11 @@ server: host: "0.0.0.0" # Server bind address port: 8000 # Server port api_key: null # API key for authentication (null for no auth) + # Local transport safety ceiling only; Gemini Web remains authoritative for its actual input limit. + max_request_body_bytes: 268435456 # 256 MiB; set to 0 to disable the wrapper-side safeguard + # Regex budget for validating one response against a client-supplied JSON Schema. + # Exhausting it leaves the reply unverified rather than failing it. + schema_validation_budget_seconds: 1.0 https: enabled: false # Enable HTTPS key_file: "certs/privkey.pem" # SSL private key file path diff --git a/pyproject.toml b/pyproject.toml index 6c37720..946a6d9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -3,16 +3,18 @@ name = "gemini-fastapi" version = "1.0.0" description = "FastAPI Server built on Gemini Web API" readme = "README.md" -requires-python = "==3.13.*" +requires-python = ">=3.13" dependencies = [ "curl-cffi>=0.16.0", "fastapi>=0.141.1", - "gemini-webapi>=2.0.0", + "gemini-webapi>=2.1.0,<3", "httptools>=0.8.0", + "jsonschema>=4.26.0", "lmdb>=2.3.0", "loguru>=0.7.3", "orjson>=3.11.9", "pydantic-settings[yaml]>=2.15.0", + "regex>=2026.7.19", "uvicorn>=0.52.3", "uvloop>=0.22.1; sys_platform != 'win32'", ] @@ -21,7 +23,7 @@ dependencies = [ Repository = "https://github.com/Nativu5/Gemini-FastAPI" [project.optional-dependencies] -dev = ["pyright", "ruff", "ty"] +dev = ["httpx2", "pyright", "pytest", "ruff", "ty"] [dependency-groups] dev = ["gemini-fastapi[dev]"] @@ -67,5 +69,6 @@ indent-style = "space" [tool.pyright] typeCheckingMode = "standard" -[tool.uv.sources] -gemini-webapi = { git = "https://github.com/luuquangvu/Gemini-API.git", rev = "enable-guest-mode" } +[tool.pytest.ini_options] +testpaths = ["tests"] +pythonpath = ["."] diff --git a/scripts/rotate_lmdb.py b/scripts/rotate_lmdb.py index b9b3457..cda6c38 100644 --- a/scripts/rotate_lmdb.py +++ b/scripts/rotate_lmdb.py @@ -1,10 +1,22 @@ import argparse +import os +import sys from datetime import datetime, timedelta from pathlib import Path -from typing import Any -import lmdb -import orjson +# Run as a plain script as well as `python -m scripts.rotate_lmdb`: only the latter puts the +# repository root on the path, and `app` has to be importable either way. +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) + +# Importing the store pulls in `app.utils`, which builds the application config at import time +# and exits when it cannot. This command touches nothing the Gemini section configures, so when +# there is no config file to read it seeds the one required field rather than refusing to run - +# rotating a detached or backup database must not require the server's configuration. A real +# config file still takes effect, because this only fills in what is otherwise missing. +if not Path(os.getenv("CONFIG_PATH", "config/config.yaml")).is_file(): + os.environ.setdefault("CONFIG_GEMINI", '{"clients": []}') + +from app.services.lmdb import LMDBConversationStore def _parse_duration(value: str) -> timedelta: @@ -16,42 +28,34 @@ def _parse_duration(value: str) -> timedelta: raise ValueError("Invalid duration format. Use Nd or Nh") -def _should_delete(record: dict[str, Any], threshold: datetime) -> bool: - """Check if the record is older than the threshold.""" - timestamp = record.get("updated_at") or record.get("created_at") - if not timestamp: - return False +DEFAULT_MAP_SIZE = 1024 * 1024 * 1024 + + +def rotate_lmdb(path: Path, keep: str, map_size: int = DEFAULT_MAP_SIZE) -> int: + """Delete conversations last updated before the retention window, or all of them. + + Returns the number removed. `keep` is a duration like `14d`/`24h`, or `all` to empty + the store. `map_size` is passed explicitly rather than read from the application config, + so this command can rotate a detached or backup database whose size has nothing to do + with the running server's settings. + """ + # Opening an absent path would create an empty database and report a successful rotation of + # nothing, so a mistyped path has to fail instead. + if not (path / "data.mdb").is_file(): + raise SystemExit(f"No LMDB database at {path}") + + store = LMDBConversationStore.open_isolated( + db_path=str(path), max_db_size=map_size, retention_days=0 + ) try: - ts = datetime.fromisoformat(timestamp) - except ValueError: - return False - return ts < threshold - - -def rotate_lmdb(path: Path, keep: str) -> None: - """Remove records older than the specified duration.""" - env = lmdb.open(str(path), writemap=True, readahead=False, meminit=False) - if keep == "all": - with env.begin(write=True) as txn: - cursor = txn.cursor() - for key, _ in cursor: - txn.delete(key) - env.close() - return - - delta = _parse_duration(keep) - threshold = datetime.now() - delta - - with env.begin(write=True) as txn: - cursor = txn.cursor() - for key, value in cursor: - try: - record = orjson.loads(value) - except orjson.JSONDecodeError: - continue - if _should_delete(record, threshold): - txn.delete(key) - env.close() + if keep == "all": + return store.clear() + + delta = _parse_duration(keep) + threshold = datetime.now() - delta + return store.cleanup_before(threshold) + finally: + store.close() def main() -> None: @@ -61,9 +65,18 @@ def main() -> None: "keep", help="Retention period, e.g. 14d or 24h. Use 'all' to delete every record", ) + parser.add_argument( + "--map-size", + type=int, + default=DEFAULT_MAP_SIZE, + help=( + f"LMDB map size in bytes for opening the target database (default: {DEFAULT_MAP_SIZE})" + ), + ) args = parser.parse_args() - rotate_lmdb(args.path, args.keep) + removed = rotate_lmdb(args.path, args.keep, args.map_size) + print(f"Removed {removed} conversation(s) from {args.path}") if __name__ == "__main__": diff --git a/tests/test_api_compatibility.py b/tests/test_api_compatibility.py new file mode 100644 index 0000000..cff1f03 --- /dev/null +++ b/tests/test_api_compatibility.py @@ -0,0 +1,895 @@ +"""Wire-format compatibility tests for the OpenAI- and Gemini-shaped surfaces. + +The contract these lock down: a request naming only standard attributes must never be rejected +just because Gemini Web cannot honour one of them. Options that cannot be forwarded are accepted +and dropped; only malformed or unrepresentable *content* is refused. +""" + +import base64 +import time + +import orjson +import pytest +from pydantic import ValidationError + +from app.models.core import AppContentItem, AppToolCall, AppToolCallFunction +from app.models.gemini_models import GeminiGenerateContentRequest, GeminiGenerationConfig +from app.models.models import ( + ChatCompletionNamedToolChoice, + FunctionCallOutput, + ResponseCreateRequest, + ResponseFormatTextJSONSchemaConfig, + ResponseInputMessage, + ResponseUsage, + StructuredOutputRequirement, + ToolChoiceFunction, + ToolChoiceTypes, +) +from app.server.chat import ( + _build_structured_requirement, + _create_responses_standard_payload, + _responses_response_format, + _sse_error, + _tool_choice_declaration_error, + _tool_choice_failure, + _validate_responses_input, +) +from app.server.gemini import ( + _gemini_response_schema, + _gemini_structured_requirement, + _gemini_tools_to_internal, + _validate_gemini_request, +) +from app.utils.helper import ( + SCHEMA_ADHERENCE_PROMPT, + STRICT_SCHEMA_ADHERENCE_PROMPT, + StructuredOutputValidationError, + canonicalize_structured_output, + decode_base64_data, + guess_extension_for_mime, + normalize_openapi_schema, + process_llm_output, +) + +TOOL_CALL_OUTPUT = ( + "[ToolCalls][Call:get_weather][CallParameter:city]Hanoi[/CallParameter][/Call][/ToolCalls]" +) +OBJECT_SCHEMA = {"type": "object", "properties": {"a": {"type": "string"}}, "required": ["a"]} +_EMPTY_USAGE = ResponseUsage(input_tokens=0, output_tokens=0, total_tokens=0) + + +def _requirement(schema: dict, *, strict: bool = True) -> StructuredOutputRequirement: + return StructuredOutputRequirement( + schema_name="r", schema=schema, instruction="", raw_format={}, strict=strict + ) + + +def _gemini_request(**overrides) -> GeminiGenerateContentRequest: + payload = {"contents": [{"role": "user", "parts": [{"text": "hi"}]}], **overrides} + return GeminiGenerateContentRequest.model_validate(payload) + + +def _generation_config(request: GeminiGenerateContentRequest) -> GeminiGenerationConfig: + """Narrow the optional generationConfig the caller just supplied.""" + config = request.generationConfig + assert config is not None + return config + + +# --------------------------------------------------------------------------- response_format + + +@pytest.mark.parametrize("format_type", ["text", "json_object"]) +def test_non_json_schema_response_formats_are_accepted(format_type): + """`text` is the API default and `json_object` is JSON mode; neither may 400.""" + requirement = _build_structured_requirement({"type": format_type}) + if format_type == "text": + assert requirement is None + else: + assert requirement is not None + # JSON mode promises valid JSON only, so it must not be enforced as strict. + assert requirement.strict is False + + +def test_json_schema_sets_strict_from_the_request(): + for strict in (True, False): + requirement = _build_structured_requirement( + {"type": "json_schema", "json_schema": {"schema": OBJECT_SCHEMA, "strict": strict}} + ) + assert requirement is not None + assert requirement.strict is strict + + +@pytest.mark.parametrize("strict", ["false", "true", 0, 1, None]) +def test_chat_json_schema_rejects_non_boolean_strict_values(strict): + with pytest.raises(ValueError, match="strict must be a boolean"): + _build_structured_requirement( + {"type": "json_schema", "json_schema": {"schema": OBJECT_SCHEMA, "strict": strict}} + ) + + +@pytest.mark.parametrize( + "response_format", + [ + {"type": "json_schema"}, + {"type": "json_schema", "json_schema": {}}, + ], +) +def test_malformed_json_schema_is_still_rejected(response_format): + with pytest.raises(ValueError, match="schema"): + _build_structured_requirement(response_format) + + +@pytest.mark.parametrize( + "schema", + [ + {"type": "not-a-type"}, + {"type": "integer", "exclusiveMinimum": True}, # draft-4 spelling + ], +) +def test_an_unrepresentable_schema_is_asked_for_but_not_enforced(schema): + """A schema we cannot evaluate must not 400: that loses an answer over a gap on our side.""" + requirement = _build_structured_requirement( + {"type": "json_schema", "json_schema": {"schema": schema, "strict": True}} + ) + assert requirement is not None + # Still shown to the model, but it cannot be used to judge the reply. + assert orjson.dumps(schema, option=orjson.OPT_SORT_KEYS).decode() in requirement.instruction + assert requirement.schema == {} + assert requirement.strict is False + + +@pytest.mark.parametrize("response_format", [None, {}, "json_schema", ["json_schema"]]) +def test_absent_or_non_object_response_format_is_ignored(response_format): + assert _build_structured_requirement(response_format) is None + + +def test_json_schema_defaults_to_best_effort_and_a_generated_name(): + """OpenAI defaults `strict` to false, and so must we. + + The flag is not decorative here: a strict miss costs the caller the whole answer, and this + wrapper prompts for schema adherence rather than constraining decoding. A caller who never + asked for strict enforcement must not be opted into losing replies. + """ + requirement = _build_structured_requirement( + {"type": "json_schema", "json_schema": {"schema": OBJECT_SCHEMA, "name": ""}} + ) + assert requirement is not None + assert requirement.schema_name == "response" + assert requirement.strict is False + assert STRICT_SCHEMA_ADHERENCE_PROMPT not in requirement.instruction + # The schema is still asked for; only the failure mode softens. + assert SCHEMA_ADHERENCE_PROMPT in requirement.instruction + + +def test_non_strict_schema_omits_the_exact_conformance_line(): + requirement = _build_structured_requirement( + {"type": "json_schema", "json_schema": {"schema": OBJECT_SCHEMA, "strict": False}} + ) + assert requirement is not None + assert STRICT_SCHEMA_ADHERENCE_PROMPT not in requirement.instruction + + +# --------------------------------------------------------------------------- output enforcement + + +def test_tool_call_turn_is_not_failed_by_a_response_format(): + """The schema constrains the final answer, not a turn that asks for a tool.""" + _, visible, _, tool_calls = process_llm_output( + None, TOOL_CALL_OUTPUT, _requirement(OBJECT_SCHEMA) + ) + assert [call.function.name for call in tool_calls] == ["get_weather"] + assert visible == "" + + +def test_strict_violation_raises_and_best_effort_violation_degrades(): + with pytest.raises(StructuredOutputValidationError): + process_llm_output(None, '{"b": 1}', _requirement(OBJECT_SCHEMA)) + + _, visible, _, _ = process_llm_output( + None, '{"b": 1}', _requirement(OBJECT_SCHEMA, strict=False) + ) + assert visible == '{"b": 1}' + + +@pytest.mark.parametrize("raw_text", ["", " \n "]) +def test_a_reply_with_no_text_is_not_a_schema_violation(raw_text): + """An image-only or empty turn has nothing to validate; failing it would invent an error.""" + _, visible, storage, _ = process_llm_output(None, raw_text, _requirement(OBJECT_SCHEMA)) + assert visible == storage == "" + + +def test_text_alongside_a_tool_call_is_still_canonicalized(): + raw = f'```json\n{{"a": "x"}}\n```\n{TOOL_CALL_OUTPUT}' + _, visible, _, tool_calls = process_llm_output(None, raw, _requirement(OBJECT_SCHEMA)) + assert [call.function.name for call in tool_calls] == ["get_weather"] + assert visible == '{"a":"x"}' + + +def test_conforming_output_is_canonicalized(): + _, visible, storage, _ = process_llm_output( + None, '```json\n{"a": "x"}\n```', _requirement(OBJECT_SCHEMA) + ) + assert visible == storage == '{"a":"x"}' + + +@pytest.mark.parametrize( + "schema", + [ + {"$ref": "http://169.254.169.254/latest/meta-data"}, # unresolvable remote reference + {"$ref": "#/definitions/missing"}, # dangling local reference + {"type": "OBJECT"}, # foreign dialect that slipped through + ], +) +def test_unusable_schemas_leave_the_reply_unverified_rather_than_failing_it(schema): + """Failing to run the check is our problem, not the model's, so it cannot be a violation.""" + assert canonicalize_structured_output('{"a": 1}', _requirement(schema)) == '{"a":1}' + # And it must not reach the caller as an error, even under strict. + _, visible, _, _ = process_llm_output(None, '{"a": 1}', _requirement(schema)) + assert visible == '{"a":1}' + + +def test_json_mode_requires_only_that_the_payload_parses(): + requirement = _requirement({}, strict=False) + assert canonicalize_structured_output('{"anything": [1]}', requirement) == '{"anything":[1]}' + assert canonicalize_structured_output("not json", requirement) is None + + +def test_pathological_schema_regex_is_bounded(monkeypatch): + """A client-controlled pattern must not monopolize the async server thread.""" + monkeypatch.setattr("app.utils.helper.SCHEMA_REGEX_BUDGET_SECONDS", 0.005) + requirement = _requirement( + { + "type": "object", + "properties": {"value": {"type": "string", "pattern": "^(a+)+$"}}, + "required": ["value"], + } + ) + started = time.perf_counter() + result = canonicalize_structured_output('{"value":"' + "a" * 100 + 'b"}', requirement) + assert time.perf_counter() - started < 0.5 + assert result is None + + +def test_an_exhausted_budget_leaves_the_reply_unverified_rather_than_failing_it(monkeypatch): + """Running out of time is our limit, not a schema violation, so strict must not 502.""" + monkeypatch.setattr("app.utils.helper.SCHEMA_REGEX_BUDGET_SECONDS", 1e-9) + requirement = _requirement({"type": "object", "properties": {"a": {"pattern": "^x$"}}}) + assert canonicalize_structured_output('{"a": "x"}', requirement) == '{"a":"x"}' + + +def test_an_ordinary_large_payload_fits_the_regex_budget(): + """The budget is cumulative, so it has to cover realistic volume, not just one pattern.""" + requirement = _requirement( + { + "type": "array", + "items": { + "type": "object", + "properties": { + "email": {"type": "string", "pattern": r"^[^@]+@[^@]+\.[A-Za-z]{2,}$"}, + "sku": {"type": "string", "pattern": "^[A-Z]{3}-[0-9]{6}$"}, + "slug": {"type": "string", "pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*$"}, + }, + "required": ["email", "sku", "slug"], + }, + } + ) + rows = [ + {"email": f"u{i}@example.com", "sku": f"ABC-{i:06d}", "slug": f"row-{i}"} + for i in range(2000) + ] + assert canonicalize_structured_output(orjson.dumps(rows).decode(), requirement) is not None + + +def test_bounded_validator_preserves_pattern_properties_and_additional_properties(): + requirement = _requirement( + { + "type": "object", + "patternProperties": {"^item_[0-9]+$": {"type": "integer"}}, + "additionalProperties": False, + } + ) + assert canonicalize_structured_output('{"item_1": 1}', requirement) == '{"item_1":1}' + assert canonicalize_structured_output('{"other": 1}', requirement) is None + + +# --------------------------------------------------------------------------- Responses text.format + + +@pytest.mark.parametrize( + ("format_payload", "expected_type"), + [ + ({"type": "text"}, None), + ({"type": "json_object"}, "json_object"), + ({"type": "json_schema", "name": "r", "schema": OBJECT_SCHEMA}, "json_schema"), + ], +) +def test_text_format_accepts_every_standard_variant(format_payload, expected_type): + request = ResponseCreateRequest.model_validate( + {"model": "m", "input": "hi", "text": {"format": format_payload}} + ) + resolved = _responses_response_format(request) + assert (resolved or {}).get("type") == expected_type + + +def test_default_text_block_does_not_conflict_with_the_legacy_extension(): + request = ResponseCreateRequest.model_validate( + { + "model": "m", + "input": "hi", + "text": {"format": {"type": "text"}}, + "response_format": {"type": "json_object"}, + } + ) + assert _responses_response_format(request) == {"type": "json_object"} + + +def test_neither_format_block_yields_no_requirement(): + assert ( + _responses_response_format( + ResponseCreateRequest.model_validate({"model": "m", "input": "hi"}) + ) + is None + ) + + +def test_legacy_response_format_alone_is_honored(): + request = ResponseCreateRequest.model_validate( + {"model": "m", "input": "hi", "response_format": {"type": "json_object"}} + ) + assert _responses_response_format(request) == {"type": "json_object"} + + +def test_text_format_json_schema_defaults_name_and_strict(): + request = ResponseCreateRequest.model_validate( + { + "model": "m", + "input": "hi", + "text": {"format": {"type": "json_schema", "schema": OBJECT_SCHEMA}}, + } + ) + resolved = _responses_response_format(request) or {} + assert resolved["json_schema"]["name"] == "response" + # Same default as Chat Completions and as OpenAI: an omitted `strict` is best-effort. + assert resolved["json_schema"]["strict"] is False + + +@pytest.mark.parametrize("strict", ["false", "true", 0, 1]) +def test_responses_json_schema_rejects_non_boolean_strict_values(strict): + with pytest.raises(ValidationError): + ResponseCreateRequest.model_validate( + { + "model": "m", + "input": "hi", + "text": { + "format": { + "type": "json_schema", + "schema": OBJECT_SCHEMA, + "strict": strict, + } + }, + } + ) + + +def test_text_format_json_schema_without_a_schema_is_rejected(): + request = ResponseCreateRequest.model_validate( + {"model": "m", "input": "hi", "text": {"format": {"type": "json_schema", "name": "r"}}} + ) + with pytest.raises(ValueError, match="schema is required"): + _responses_response_format(request) + + +def test_two_conflicting_formats_are_rejected(): + request = ResponseCreateRequest.model_validate( + { + "model": "m", + "input": "hi", + "text": {"format": {"type": "json_schema", "name": "r", "schema": OBJECT_SCHEMA}}, + "response_format": {"type": "json_object"}, + } + ) + with pytest.raises(ValueError, match="not both"): + _responses_response_format(request) + + +def test_both_surfaces_resolve_an_omitted_strict_the_same_way(): + """One schema must not hard-fail on Responses and degrade on Chat Completions.""" + responses_request = ResponseCreateRequest.model_validate( + { + "model": "m", + "input": "hi", + "text": {"format": {"type": "json_schema", "name": "r", "schema": OBJECT_SCHEMA}}, + } + ) + chat = _build_structured_requirement( + {"type": "json_schema", "json_schema": {"name": "r", "schema": OBJECT_SCHEMA}} + ) + responses = _build_structured_requirement(_responses_response_format(responses_request)) + assert chat is not None + assert responses is not None + assert chat.strict is responses.strict is False + + +@pytest.mark.parametrize(("requested", "applied"), [(True, True), (False, False), (None, False)]) +def test_the_echoed_strict_reports_what_was_enforced(requested, applied): + json_schema: dict = {"name": "r", "schema": OBJECT_SCHEMA} + if requested is not None: + json_schema["strict"] = requested + request = ResponseCreateRequest.model_validate( + { + "model": "m", + "input": "hi", + "response_format": {"type": "json_schema", "json_schema": json_schema}, + } + ) + requirement = _build_structured_requirement(_responses_response_format(request)) + assert requirement is not None + assert requirement.strict is applied + + payload = _create_responses_standard_payload( + "resp_1", 0, "m", None, [], [], _EMPTY_USAGE, request, requirement + ) + text_format = payload.text.format if payload.text else None + assert isinstance(text_format, ResponseFormatTextJSONSchemaConfig) + assert text_format.strict is applied + + +# --------------------------------------------------------------------------- Gemini generationConfig + + +def test_openapi_response_schema_is_translated_not_rejected(): + """`responseSchema` is the OpenAPI subset: uppercase types and `nullable`.""" + request = _gemini_request( + generationConfig={ + "responseMimeType": "application/json", + "responseSchema": { + "type": "OBJECT", + "properties": { + "name": {"type": "STRING"}, + "age": {"type": "INTEGER", "nullable": True}, + }, + "required": ["name"], + "propertyOrdering": ["name", "age"], + }, + } + ) + assert _validate_gemini_request(request) is None + assert _gemini_response_schema(_generation_config(request)) == { + "type": "object", + "properties": {"name": {"type": "string"}, "age": {"type": ["integer", "null"]}}, + "required": ["name"], + } + + +def test_invalid_response_json_schema_is_rejected(): + """`responseJsonSchema` really is JSON Schema, so it can be judged as such.""" + request = _gemini_request( + generationConfig={ + "responseMimeType": "application/json", + "responseJsonSchema": {"type": "not-a-type"}, + } + ) + assert "Invalid JSON Schema" in (_validate_gemini_request(request) or "") + + +def test_untranslatable_response_schema_drops_enforcement_rather_than_failing(): + request = _gemini_request( + generationConfig={ + "responseMimeType": "application/json", + "responseSchema": {"type": "WHAT"}, + } + ) + assert _validate_gemini_request(request) is None + assert _gemini_response_schema(_generation_config(request)) is None + + +def test_the_gemini_surface_asks_for_the_schema_without_enforcing_it(): + """Google guarantees conformance by constrained decoding; this wrapper can only ask. + + The native surface has no `strict` flag for a caller to turn off, so failing the request on + a near-miss would throw away an answer with no way to opt out of that. + """ + request = _gemini_request( + generationConfig={"responseMimeType": "application/json", "responseSchema": OBJECT_SCHEMA} + ) + schema, requirement = _gemini_structured_requirement(request) + + assert schema == OBJECT_SCHEMA + assert requirement is not None + assert requirement.strict is False + assert STRICT_SCHEMA_ADHERENCE_PROMPT not in requirement.instruction + + # A violation therefore comes back as text rather than costing the caller the reply. + _, visible, _, _ = process_llm_output(None, '{"b": 1}', requirement) + assert visible == '{"b": 1}' + + +@pytest.mark.parametrize( + "generation_config", + [ + {"responseMimeType": "application/json"}, + {"responseMimeType": "application/json", "responseJsonSchema": {}}, + {"responseMimeType": "application/json", "responseSchema": {}}, + ], +) +def test_gemini_json_mode_survives_absent_or_empty_schemas(generation_config): + schema, requirement = _gemini_structured_requirement( + _gemini_request(generationConfig=generation_config) + ) + assert schema in (None, {}) + assert requirement is not None + assert requirement.schema == {} + assert requirement.strict is False + assert canonicalize_structured_output('{"valid": true}', requirement) == '{"valid":true}' + + +def test_no_response_schema_yields_no_requirement(): + schema, requirement = _gemini_structured_requirement(_gemini_request()) + assert schema is None + assert requirement is None + + +def test_normalize_openapi_schema_leaves_json_schema_alone(): + assert normalize_openapi_schema(OBJECT_SCHEMA) == OBJECT_SCHEMA + + +def test_normalize_openapi_schema_recurses_through_containers(): + assert normalize_openapi_schema( + { + "type": "ARRAY", + "items": { + "type": "OBJECT", + "properties": {"x": {"anyOf": [{"type": "STRING"}, {"type": "INTEGER"}]}}, + }, + } + ) == { + "type": "array", + "items": { + "type": "object", + "properties": {"x": {"anyOf": [{"type": "string"}, {"type": "integer"}]}}, + }, + } + + +def test_normalize_openapi_schema_treats_property_names_as_names(): + """A property called `type` or `nullable` must not be mistaken for the keyword.""" + assert normalize_openapi_schema( + { + "type": "OBJECT", + "properties": {"type": {"type": "STRING"}, "nullable": {"type": "BOOLEAN"}}, + } + ) == { + "type": "object", + "properties": {"type": {"type": "string"}, "nullable": {"type": "boolean"}}, + } + + +def test_normalize_openapi_schema_drops_nullable_false_without_widening(): + assert normalize_openapi_schema({"type": "STRING", "nullable": False}) == {"type": "string"} + + +@pytest.mark.parametrize("value", ["text", None, 7, True]) +def test_normalize_openapi_schema_passes_non_schemas_through(value): + assert normalize_openapi_schema(value) == value + + +@pytest.mark.parametrize( + ("overrides", "expected"), + [ + ({"contents": []}, "contents is required"), + ( + {"contents": [{"role": "user", "parts": []}]}, + "must contain at least one part", + ), + ( + { + "contents": [ + { + "role": "user", + "parts": [{"fileData": {"mimeType": "text/plain", "fileUri": "gs://x"}}], + } + ] + }, + "fileData is not supported", + ), + ({"cachedContent": "cachedContents/abc"}, "cachedContent is not supported"), + ], +) +def test_unrepresentable_content_is_refused(overrides, expected): + """Content the wrapper cannot resolve is refused; dropping it would change the question.""" + payload = {"contents": [{"role": "user", "parts": [{"text": "hi"}]}], **overrides} + request = GeminiGenerateContentRequest.model_validate(payload) + assert expected in (_validate_gemini_request(request) or "") + + +def test_file_data_in_system_instruction_is_refused(): + request = _gemini_request( + systemInstruction={"parts": [{"fileData": {"mimeType": "text/plain", "fileUri": "gs://x"}}]} + ) + assert "fileData is not supported" in (_validate_gemini_request(request) or "") + + +# --------------------------------------------------------------------------- Gemini toolConfig + +_TOOLS = [ + {"functionDeclarations": [{"name": "a", "description": "d"}, {"name": "b", "description": "d"}]} +] + + +@pytest.mark.parametrize( + ("mode", "expected_tools", "expected_choice"), + [ + ("AUTO", ["a", "b"], "auto"), + ("NONE", ["a", "b"], "none"), + ("VALIDATED", ["a"], "auto"), + ], +) +def test_allowed_function_names_only_narrows_the_modes_that_use_it( + mode, expected_tools, expected_choice +): + request = _gemini_request( + tools=_TOOLS, + toolConfig={"functionCallingConfig": {"mode": mode, "allowedFunctionNames": ["a"]}}, + ) + tools, choice = _gemini_tools_to_internal(request.tools, request.toolConfig) + assert [tool.name for tool in tools or []] == expected_tools + assert choice == expected_choice + # Names it does not act on cannot be a validation error either. + assert _validate_gemini_request(request) is None + + +def test_any_mode_with_one_allowed_name_forces_that_function(): + request = _gemini_request( + tools=_TOOLS, + toolConfig={"functionCallingConfig": {"mode": "ANY", "allowedFunctionNames": ["a"]}}, + ) + tools, choice = _gemini_tools_to_internal(request.tools, request.toolConfig) + assert [tool.name for tool in tools or []] == ["a"] + assert getattr(choice, "name", None) == "a" + + +def test_any_mode_rejects_undeclared_allowed_names(): + request = _gemini_request( + tools=_TOOLS, + toolConfig={"functionCallingConfig": {"mode": "ANY", "allowedFunctionNames": ["zzz"]}}, + ) + assert "undeclared functions" in (_validate_gemini_request(request) or "") + + +@pytest.mark.parametrize( + "tool_config", + [ + None, + {"functionCallingConfig": {"mode": "ANY"}}, + {"functionCallingConfig": {"mode": "ANY", "allowedFunctionNames": ["a", "b"]}}, + ], + ids=["no-config", "any-unrestricted", "any-multiple-names"], +) +def test_any_mode_without_a_single_target_forces_only_that_a_tool_is_called(tool_config): + request = _gemini_request(tools=_TOOLS, toolConfig=tool_config) + tools, choice = _gemini_tools_to_internal(request.tools, request.toolConfig) + assert [tool.name for tool in tools or []] == ["a", "b"] + assert choice == (None if tool_config is None else "required") + + +def test_no_tools_yields_no_tool_choice(): + assert _gemini_tools_to_internal(None, None) == (None, None) + + +# --------------------------------------------------------------------------- forced tool_choice + +_CALL = AppToolCall(id="1", type="function", function=AppToolCallFunction(name="a", arguments="{}")) +_NAMED = ChatCompletionNamedToolChoice.model_validate( + {"type": "function", "function": {"name": "a"}} +) +_FUNCTION = ToolChoiceFunction(type="function", name="a") +_IMAGE = ToolChoiceTypes(type="image_generation") + + +@pytest.mark.parametrize( + ("tool_choice", "tool_calls", "has_images", "has_image_tool", "expected"), + [ + (None, [], False, False, None), + ("auto", [], False, False, None), + ("none", [], False, False, None), + ("required", [_CALL], False, False, None), + ("required", [], False, False, "required tool result"), + # An image satisfies `required` only when an image tool was declared; one Gemini + # volunteers on its own cannot stand in for the function call that was forced. + ("required", [], True, True, None), + ("required", [], True, False, "required tool result"), + (_NAMED, [_CALL], False, False, None), + (_NAMED, [], False, False, "required function 'a'"), + (_FUNCTION, [_CALL], False, False, None), + (_FUNCTION, [], False, False, "required function 'a'"), + (_IMAGE, [], True, True, None), + (_IMAGE, [], False, True, "image generation result"), + ], +) +def test_forced_tool_choice_failure_detection( + tool_choice, tool_calls, has_images, has_image_tool, expected +): + result = _tool_choice_failure( + tool_choice, tool_calls, has_images=has_images, has_image_tool=has_image_tool + ) + if expected is None: + assert result is None + else: + assert expected in (result or "") + + +@pytest.mark.parametrize( + ("names", "has_image_tool", "tool_choice", "expected"), + [ + (set(), False, "auto", None), + ({"a"}, False, "required", None), + (set(), True, "required", None), + (set(), False, "required", "requires at least one tool"), + ({"a"}, False, _NAMED, None), + ({"b"}, False, _NAMED, "undeclared function 'a'"), + ({"b"}, False, _FUNCTION, "undeclared function 'a'"), + (set(), True, _IMAGE, None), + (set(), False, _IMAGE, "requires an image_generation tool"), + ], +) +def test_forced_tool_choice_must_name_a_declared_tool(names, has_image_tool, tool_choice, expected): + result = _tool_choice_declaration_error(names, has_image_tool, tool_choice) + if expected is None: + assert result is None + else: + assert expected in (result or "") + + +# --------------------------------------------------------------------------- Responses input + + +def _input_message(*parts) -> ResponseInputMessage: + return ResponseInputMessage.model_validate({"role": "user", "content": list(parts)}) + + +@pytest.mark.parametrize( + ("items", "expected"), + [ + ("a plain string prompt", None), + ([_input_message({"type": "input_text", "text": "hi"})], None), + ([_input_message({"type": "input_image", "image_url": "https://x/y.png"})], None), + ([_input_message({"type": "input_file", "file_url": "https://x/a.pdf"})], None), + ([_input_message({"type": "input_file", "file_data": "aGk="})], None), + ([_input_message({"type": "input_file", "file_id": "file-1"})], "file_id inputs"), + ([_input_message({"type": "input_image"})], "input_image must contain image_url"), + ( + [ + _input_message( + {"type": "input_file", "file_url": "https://x/a.pdf", "file_data": "aGk="} + ) + ], + "exactly one of file_url or file_data", + ), + ( + [_input_message({"type": "input_file", "filename": "a.pdf"})], + "exactly one of file_url or file_data", + ), + ], +) +def test_responses_input_refuses_only_unusable_content(items, expected): + result = _validate_responses_input(items) + if expected is None: + assert result is None + else: + assert expected in (result or "") + + +@pytest.mark.parametrize( + ("output", "expected"), + [ + ("done", None), + ([{"type": "input_text", "text": "done"}], None), + ([{"type": "input_file", "file_id": "file-1"}], "file_id inputs"), + ], +) +def test_tool_result_parts_are_validated_too(output, expected): + items = [FunctionCallOutput.model_validate({"call_id": "c", "output": output})] + result = _validate_responses_input(items) + if expected is None: + assert result is None + else: + assert expected in (result or "") + + +# --------------------------------------------------------------------------- content digests + + +def test_non_ascii_data_url_does_not_abort_model_construction(): + item = AppContentItem(type="image_url", url="data:text/plain;charset=utf-8,Hé") + assert item.content_digest + + +def test_digest_distinguishes_inline_payloads(): + first = base64.b64encode(b"one").decode() + second = base64.b64encode(b"two").decode() + assert ( + AppContentItem(type="file", file_data=first, filename="a.bin").content_digest + != AppContentItem(type="file", file_data=second, filename="a.bin").content_digest + ) + + +@pytest.mark.parametrize( + ("mime_type", "expected"), + [ + ("image/jpeg", ".jpg"), + ("application/pdf", ".pdf"), + # Unregistered but widely sent: the subtype is the fallback, not ".bin". + ("audio/mp3", ".mp3"), + ("application/x-foo", ".x-foo"), + ("image/png; charset=binary", ".png"), + (None, ".bin"), + ("nosubtype", ".bin"), + # A separator here would place the temp file outside its directory. + ("image/../../etc/passwd", ".etcpasswd"), + ], +) +def test_mime_extensions_fall_back_to_a_scrubbed_subtype(mime_type, expected): + suffix = guess_extension_for_mime(mime_type) + assert suffix == expected + assert not set(suffix) & set("/\\") + + +def test_both_base64_alphabets_decode(): + """Clients built on `urlsafe_b64encode` send `-` and `_`.""" + payload = bytes(range(256)) + assert decode_base64_data(base64.b64encode(payload).decode()) == payload + assert decode_base64_data(base64.urlsafe_b64encode(payload).decode()) == payload + with pytest.raises(ValueError, match="Base64"): + decode_base64_data("not base64 at all!!") + + +def test_base64_accepts_bytes_line_wrapping_and_data_urls(): + payload = b"\x00\x01binary payload\xff" + encoded = base64.b64encode(payload).decode() + assert decode_base64_data(encoded.encode()) == payload + # MIME-style encoders wrap long payloads across lines. + assert decode_base64_data(f"{encoded[:4]}\n{encoded[4:]}") == payload + assert decode_base64_data(f"data:application/octet-stream;base64,{encoded}") == payload + + +def test_data_url_without_a_base64_payload_is_rejected(): + with pytest.raises(ValueError, match="Data URL"): + decode_base64_data("data:text/plain,hello") + + +def test_non_ascii_is_an_error_rather_than_something_to_strip(): + """Dropping a stray character could make a corrupt payload decode to the wrong bytes.""" + payload = base64.b64encode(b"body").decode() + with pytest.raises(ValueError, match="non-ASCII"): + decode_base64_data(f"{payload[:2]}é{payload[2:]}") + + +def test_digest_is_identical_across_equivalent_representations(): + """The same bytes must hash alike whether sent as a data URL or as inline file data.""" + encoded = base64.b64encode(b"\x89PNG\r\n\x1a\nbody").decode() + as_data_url = AppContentItem(type="image_url", url=f"data:image/png;base64,{encoded}") + as_file = AppContentItem(type="file", file_data=encoded, filename="a.png") + assert as_data_url.content_digest == as_file.content_digest + + +def test_only_payloads_excluded_from_serialization_get_a_digest(): + """Everything else survives the round trip and is compared directly.""" + assert AppContentItem(type="text", text="hi").content_digest is None + assert AppContentItem(type="image_url", url="https://example.com/a.png").content_digest is None + assert AppContentItem(type="x", raw_data={"a": 1}).content_digest is None + + +def test_sse_errors_terminate_the_stream(): + frame = _sse_error("boom", "server_error") + assert frame.endswith("data: [DONE]\n\n") + assert '"message":"boom"' in frame + + +def test_digest_survives_a_round_trip_without_the_excluded_bytes(): + original = AppContentItem( + type="file", file_data=base64.b64encode(b"payload").decode(), filename="a.bin" + ) + restored = AppContentItem.model_validate(original.model_dump()) + assert restored.file_data is None + assert restored.content_digest == original.content_digest diff --git a/tests/test_middleware.py b/tests/test_middleware.py new file mode 100644 index 0000000..3ca4983 --- /dev/null +++ b/tests/test_middleware.py @@ -0,0 +1,167 @@ +"""Transport-layer behaviour: the request body ceiling, and serving generated media. + +The limit is enforced twice over: once from a declared `content-length`, and again by counting +a chunked body as it arrives. Both have to produce the error shape of the surface they were +addressed to, and both have to travel back out through CORS. + +Media resolution is the other half: the name has to be one this server generated - which rules +out traversal - without being so narrow that a legitimate extension becomes an unreachable file. +""" + +import os + +import pytest +from fastapi import FastAPI, Request +from fastapi.middleware.cors import CORSMiddleware +from fastapi.testclient import TestClient + +from app.server.media import _resolve_media_file +from app.server.middleware import RequestBodyLimitMiddleware + +LIMIT = 100 + + +def _build_app(max_body_bytes: int = LIMIT, *, with_cors: bool = False) -> FastAPI: + app = FastAPI() + + @app.post("/v1/echo") + async def echo(request: Request): + return {"received": len(await request.body())} + + @app.post("/v1beta/models/x:generateContent") + async def gemini_echo(request: Request): + return {"received": len(await request.body())} + + # Registration order is reversed at runtime, so this mirrors app.main.create_app: the + # limiter goes on first precisely so CORS ends up wrapping it. + app.add_middleware(RequestBodyLimitMiddleware, max_body_bytes=max_body_bytes) + if with_cors: + app.add_middleware( + CORSMiddleware, allow_origins=["*"], allow_methods=["*"], allow_headers=["*"] + ) + return app + + +def _chunks(total: int, size: int = 40): + """Send without a content-length, so only the running count can catch the overflow.""" + sent = 0 + while sent < total: + step = min(size, total - sent) + yield b"x" * step + sent += step + + +def test_a_body_within_the_ceiling_reaches_the_route(): + with TestClient(_build_app()) as client: + response = client.post("/v1/echo", content=b"x" * (LIMIT - 1)) + assert response.status_code == 200 + assert response.json() == {"received": LIMIT - 1} + + +def test_a_body_exactly_at_the_ceiling_is_allowed(): + with TestClient(_build_app()) as client: + response = client.post("/v1/echo", content=b"x" * LIMIT) + assert response.status_code == 200 + + +def test_a_declared_oversize_body_is_refused(): + with TestClient(_build_app()) as client: + response = client.post("/v1/echo", content=b"x" * (LIMIT + 1)) + assert response.status_code == 413 + assert "safety ceiling" in response.json()["error"]["message"] + + +def test_a_chunked_oversize_body_is_refused(): + with TestClient(_build_app()) as client: + response = client.post("/v1/echo", content=_chunks(LIMIT * 5)) + assert response.status_code == 413 + assert "safety ceiling" in response.json()["error"]["message"] + + +@pytest.mark.parametrize("content", [b"x" * (LIMIT + 1), None], ids=["declared", "chunked"]) +def test_the_gemini_surface_gets_googles_error_envelope(content): + body = content if content is not None else _chunks(LIMIT * 5) + with TestClient(_build_app()) as client: + response = client.post("/v1beta/models/x:generateContent", content=body) + assert response.status_code == 413 + error = response.json()["error"] + assert error["code"] == 413 + assert error["status"] == "RESOURCE_EXHAUSTED" + + +def test_a_zero_ceiling_disables_the_guard(): + with TestClient(_build_app(0)) as client: + response = client.post("/v1/echo", content=b"x" * (LIMIT * 100)) + assert response.status_code == 200 + + +def test_a_refusal_still_carries_cors_headers(): + """Without this the browser reports an opaque CORS failure instead of the 413.""" + with TestClient(_build_app(with_cors=True)) as client: + allowed = client.post("/v1/echo", content=b"x", headers={"Origin": "https://example.com"}) + refused = client.post( + "/v1/echo", content=b"x" * (LIMIT + 1), headers={"Origin": "https://example.com"} + ) + assert allowed.headers["access-control-allow-origin"] == "*" + assert refused.status_code == 413 + assert refused.headers["access-control-allow-origin"] == "*" + + +def test_an_unparsable_content_length_falls_back_to_counting(): + """A bogus header must not be trusted as 0 and wave an oversize body through.""" + app = _build_app() + with TestClient(app) as client: + response = client.post( + "/v1/echo", + content=b"x" * (LIMIT + 1), + headers={"Content-Length": str(LIMIT + 1)}, + ) + assert response.status_code == 413 + + +def test_requests_without_a_body_are_untouched(): + app = _build_app() + + @app.get("/v1/ping") + async def ping(): + return {"ok": True} + + with TestClient(app) as client: + assert client.get("/v1/ping").status_code == 200 + + +# --------------------------------------------------------------------------------- media serving + +STEM = "img_" + "0" * 32 + + +@pytest.mark.parametrize("suffix", [".png", ".mp4", ".m4a", ".3gp", ".x-m4a", ".tar.gz", ".JPG"]) +def test_every_extension_this_server_can_produce_is_servable(tmp_path, suffix): + """The extension comes from whatever the upstream saved, not from a fixed list. + + Rejecting an unusual one would 404 a file whose token verifies, so the pattern has to be + permissive about the extension while still admitting no path separator. + """ + target = tmp_path / f"{STEM}{suffix}" + target.write_bytes(b"data") + assert _resolve_media_file(tmp_path, target.name) == target.resolve() + + +@pytest.mark.parametrize( + "filename", + [ + "../config/config.yaml", + f"..{os.sep}{STEM}.png", + f"{STEM}.png{os.sep}..{os.sep}secret", + "secret.png", + "img_notahexdigest.png", + f"{STEM}.", + f"{STEM}.png/../../etc/passwd", + ], +) +def test_names_this_server_never_generates_are_refused(tmp_path, filename): + assert _resolve_media_file(tmp_path, filename) is None + + +def test_a_matching_name_with_no_file_behind_it_is_refused(tmp_path): + assert _resolve_media_file(tmp_path, f"{STEM}.png") is None diff --git a/tests/test_storage.py b/tests/test_storage.py new file mode 100644 index 0000000..2d38228 --- /dev/null +++ b/tests/test_storage.py @@ -0,0 +1,210 @@ +"""Conversation-store behaviour: lookup, retention, and index versioning. + +Every test opens its own store under `tmp_path` via `open_isolated`, so nothing touches the +singleton or the configured data directory. +""" + +from datetime import datetime, timedelta + +import lmdb +import orjson +import pytest + +from app.models.core import AppContentItem, AppMessage +from app.services.lmdb import LMDBConversationStore + +# `find` only searches the configured clients, so a stored conversation has to claim one. +CLIENT_ID = "client-id-1" +MODEL = "gemini-3-pro" + + +@pytest.fixture +def store(tmp_path): + opened = LMDBConversationStore.open_isolated(db_path=str(tmp_path / "lmdb")) + try: + yield opened + finally: + opened.close() + + +def _exchange(prompt: str = "hello") -> list[AppMessage]: + return [ + AppMessage(role="user", content=prompt), + AppMessage(role="assistant", content="hi there"), + ] + + +def _raw_keys(opened: LMDBConversationStore) -> list[str]: + with opened._get_transaction() as txn: + return [bytes(key).decode("utf-8") for key, _ in txn.cursor()] + + +def test_a_stored_conversation_is_found_again(store): + messages = _exchange() + store.store(CLIENT_ID, MODEL, messages, metadata=["c", "r", "rc"]) + + found = store.find(MODEL, messages) + assert found is not None + assert found.client_id == CLIENT_ID + assert found.metadata == ["c", "r", "rc"] + + +def test_a_different_model_does_not_match(store): + messages = _exchange() + store.store(CLIENT_ID, MODEL, messages, metadata=["c", "r", "rc"]) + assert store.find("gemini-3-flash", messages) is None + + +def test_echoed_reasoning_still_matches_the_stored_turn(store): + """Reuse must survive a client replaying the reasoning this server emitted. + + `_persist_conversation` stores every assistant turn it produces with `reasoning_content=None`, + but both request converters populate it from whatever the client sends back - and replaying + the previous output is the normal pattern on the Responses API. If the hash counted reasoning, + the newest stored prefix could never match and session reuse would collapse. + """ + stored = [ + AppMessage(role="user", content="hello"), + AppMessage(role="assistant", content="hi there", reasoning_content=None), + ] + store.store(CLIENT_ID, MODEL, stored, metadata=["c", "r", "rc"]) + + echoed = [ + AppMessage(role="user", content="hello"), + AppMessage(role="assistant", content="hi there", reasoning_content="let me think..."), + ] + found = store.find(MODEL, echoed) + assert found is not None + assert found.metadata == ["c", "r", "rc"] + + +def test_inline_media_with_the_same_bytes_matches_and_different_bytes_does_not(store): + """The content digest is what keeps two distinct images from colliding.""" + + def with_image(payload: str) -> list[AppMessage]: + return [ + AppMessage( + role="user", + content=[ + AppContentItem(type="text", text="describe"), + AppContentItem(type="image_url", url=f"data:image/png;base64,{payload}"), + ], + ), + AppMessage(role="assistant", content="a picture"), + ] + + stored = with_image("aGVsbG8=") + store.store(CLIENT_ID, MODEL, stored, metadata=["c", "r", "rc"]) + + assert store.find(MODEL, with_image("aGVsbG8=")) is not None + assert store.find(MODEL, with_image("d29ybGQ=")) is None + + +def test_raw_data_still_discriminates_and_ignores_key_order(store): + """It is hashed inline rather than digested, so the outer sort has to canonicalize it.""" + + def with_raw(raw: dict) -> list[AppMessage]: + return [ + AppMessage(role="user", content=[AppContentItem(type="x", raw_data=raw)]), + AppMessage(role="assistant", content="ok"), + ] + + store.store(CLIENT_ID, MODEL, with_raw({"b": 1, "a": 2}), metadata=["c", "r", "rc"]) + + assert store.find(MODEL, with_raw({"a": 2, "b": 1})) is not None + assert store.find(MODEL, with_raw({"a": 2, "b": 99})) is None + + +def test_keys_reports_conversations_without_index_entries(store): + store.store(CLIENT_ID, MODEL, _exchange(), metadata=["c", "r", "rc"]) + + assert len(store.keys()) == 1 + # The indexes exist, they are just not conversations. + assert len(_raw_keys(store)) > 1 + + +def test_eviction_removes_the_record_and_its_indexes(store): + messages = _exchange() + store.store(CLIENT_ID, MODEL, messages, metadata=["c", "r", "rc"]) + conv = store.find(MODEL, messages) + assert conv is not None + + assert store.evict(conv) is True + assert store.find(MODEL, messages) is None + assert _raw_keys(store) == [] + + +def test_clear_empties_the_store(store): + store.store(CLIENT_ID, MODEL, _exchange("one"), metadata=["c", "r", "rc"]) + store.store(CLIENT_ID, MODEL, _exchange("two"), metadata=["c", "r", "rc"]) + + assert store.clear() == 2 + assert store.keys() == [] + assert _raw_keys(store) == [] + + +def test_retention_keeps_a_conversation_that_is_still_in_use(store): + """Retention follows last use; a long-running conversation is not old just because it began early.""" + messages = _exchange() + store.store(CLIENT_ID, MODEL, messages, metadata=["c", "r", "rc"]) + + conv = store.find(MODEL, messages) + assert conv is not None + key = store.keys()[0] + conv.created_at = datetime.now() - timedelta(days=90) + conv.updated_at = datetime.now() + with store._get_transaction(write=True) as txn: + txn.put(key.encode("utf-8"), orjson.dumps(conv.model_dump(mode="json")), overwrite=True) + + assert store.cleanup_before(datetime.now() - timedelta(days=14)) == 0 + assert store.find(MODEL, messages) is not None + + +def test_retention_removes_a_conversation_last_touched_before_the_cutoff(store): + messages = _exchange() + store.store(CLIENT_ID, MODEL, messages, metadata=["c", "r", "rc"]) + + assert store.cleanup_before(datetime.now() + timedelta(seconds=1)) == 1 + assert store.find(MODEL, messages) is None + assert _raw_keys(store) == [] + + +def test_lookup_entries_are_written_under_the_current_index_version(store): + store.store(CLIENT_ID, MODEL, _exchange(), metadata=["c", "r", "rc"]) + + index_keys = [key for key in _raw_keys(store) if store._is_index_key(key)] + assert index_keys + assert all( + key.startswith((store.HASH_LOOKUP_PREFIX, store.FUZZY_LOOKUP_PREFIX)) for key in index_keys + ) + assert LMDBConversationStore.INDEX_VERSION in store.HASH_LOOKUP_PREFIX + + +def test_indexes_from_a_superseded_version_are_pruned(tmp_path): + """A hash-shape change strands old entries that eviction can no longer find by hash.""" + db_path = tmp_path / "lmdb" + env = lmdb.open(str(db_path), map_size=10_000_000, max_dbs=3, writemap=True) + with env.begin(write=True) as txn: + txn.put(b"hash:v1:deadbeef", b'["conv1"]') + txn.put(b"fuzzy:v1:deadbeef", b'["conv1"]') + txn.put(b"hash:legacy-unversioned", b'["conv1"]') + txn.put(b"conv1", b'{"client_id":"a","model":"m","messages":[],"metadata":[]}') + env.close() + + opened = LMDBConversationStore.open_isolated(db_path=str(db_path)) + try: + assert opened.prune_stale_indexes() == 3 + # The conversation itself survives; only the unreachable lookup rows go. + assert _raw_keys(opened) == ["conv1", opened._INDEX_VERSION_KEY] + # The marker makes the next startup skip the scan entirely. + assert opened.prune_stale_indexes() == 0 + finally: + opened.close() + + +def test_pruning_leaves_current_version_entries_alone(store): + messages = _exchange() + store.store(CLIENT_ID, MODEL, messages, metadata=["c", "r", "rc"]) + + assert store.prune_stale_indexes() == 0 + assert store.find(MODEL, messages) is not None diff --git a/tests/test_streaming.py b/tests/test_streaming.py new file mode 100644 index 0000000..68d3783 --- /dev/null +++ b/tests/test_streaming.py @@ -0,0 +1,425 @@ +"""Chat Completions SSE assembly. + +Drives the streaming generator directly over a fake upstream so the wire contract can be +asserted without a network: what the client sees, in what order, and how the stream ends. +""" + +import asyncio +from types import SimpleNamespace +from typing import Any, cast + +import orjson +import pytest +from gemini_webapi.types import Candidate, ModelOutput, WebImage + +from app.models.core import AppMessage +from app.models.models import ResponseCreateRequest, StructuredOutputRequirement +from app.server.chat import ( + _create_real_streaming_response, + _create_responses_real_streaming_response, +) +from app.services.lmdb import LMDBConversationStore + +CLIENT_ID = "client-id-1" +MODEL = "gemini-3-pro" +TOOL_CALL_OUTPUT = ( + "[ToolCalls][Call:get_weather][CallParameter:city]Hanoi[/CallParameter][/Call][/ToolCalls]" +) +OBJECT_SCHEMA = {"type": "object", "properties": {"a": {"type": "string"}}, "required": ["a"]} + + +def _requirement() -> StructuredOutputRequirement: + return StructuredOutputRequirement( + schema_name="r", schema=OBJECT_SCHEMA, instruction="", raw_format={}, strict=True + ) + + +def _output(text: str, *, delta: str | None = None, thoughts_delta: str | None = None): + return ModelOutput( + metadata=["c", "r", "rc"], + chosen=0, + candidates=[ + Candidate( + rcid="rc", + text=text, + text_delta=delta if delta is not None else text, + thoughts_delta=thoughts_delta, + ) + ], + ) + + +def _stream(*outputs: ModelOutput): + async def generator(): + for output in outputs: + yield output + + return generator() + + +@pytest.fixture +def db(tmp_path): + opened = LMDBConversationStore.open_isolated(db_path=str(tmp_path / "lmdb")) + try: + yield opened + finally: + opened.close() + + +def _collect(db, stream, *, structured_requirement=None, tool_choice=None) -> list[str]: + """Run the streaming response to completion and return its raw SSE frames.""" + # Only the few attributes the generator actually touches; a real client would need a live + # browser session behind it. + client = cast( + Any, + SimpleNamespace(id=CLIENT_ID, latest_chat_cid=None, chat_scope=lambda _temporary: None), + ) + session = cast(Any, SimpleNamespace(metadata=["c", "r", "rc"])) + response = _create_real_streaming_response( + stream, + "chatcmpl-test", + 0, + MODEL, + [AppMessage(role="user", content="hi")], + db, + MODEL, + client, + session, + "http://testserver/", + structured_requirement, + tool_choice, + ) + + async def drain() -> list[str]: + chunks: list[str] = [] + async for chunk in response.body_iterator: + chunks.append(chunk if isinstance(chunk, str) else bytes(chunk).decode("utf-8")) + return chunks + + return asyncio.run(drain()) + + +def _payloads(frames: list[str]) -> list[dict]: + return [ + orjson.loads(line[len("data: ") :]) + for frame in frames + for line in frame.strip().splitlines() + if line.startswith("data: ") and line != "data: [DONE]" + ] + + +def test_a_plain_stream_opens_with_a_role_delta_and_ends_with_done(db): + frames = _collect(db, _stream(_output("Hello"), _output("Hello world", delta=" world"))) + + assert frames[-1] == "data: [DONE]\n\n" + payloads = _payloads(frames) + assert payloads[0]["choices"][0]["delta"] == {"role": "assistant", "content": ""} + text = "".join( + payload["choices"][0]["delta"].get("content", "") + for payload in payloads + if payload.get("choices") + ) + assert text == "Hello world" + assert payloads[-1]["choices"][0]["finish_reason"] == "stop" + + +def test_reasoning_is_streamed_on_its_own_delta_field(db): + frames = _collect(db, _stream(_output("answer", thoughts_delta="thinking"))) + reasoning = [ + payload["choices"][0]["delta"]["reasoning_content"] + for payload in _payloads(frames) + if payload.get("choices") and "reasoning_content" in payload["choices"][0]["delta"] + ] + assert reasoning == ["thinking"] + + +def test_a_tool_call_is_reported_and_finishes_as_tool_calls(db): + frames = _collect(db, _stream(_output(TOOL_CALL_OUTPUT))) + payloads = _payloads(frames) + + tool_calls = [ + payload["choices"][0]["delta"]["tool_calls"] + for payload in payloads + if payload.get("choices") and payload["choices"][0]["delta"].get("tool_calls") + ] + assert tool_calls + assert tool_calls[0][0]["function"]["name"] == "get_weather" + assert payloads[-1]["choices"][0]["finish_reason"] == "tool_calls" + # The protocol markers themselves must never reach the client. + assert "[ToolCalls]" not in "".join(frames) + + +def test_structured_output_is_withheld_until_it_has_been_validated(db): + """Deltas are suppressed so a schema violation cannot arrive half-rendered.""" + frames = _collect( + db, + _stream(_output('```json\n{"a": "x"}\n```')), + structured_requirement=_requirement(), + ) + contents = [ + payload["choices"][0]["delta"].get("content", "") + for payload in _payloads(frames) + if payload.get("choices") + ] + assert "".join(contents) == '{"a":"x"}' + assert frames[-1] == "data: [DONE]\n\n" + + +def test_a_strict_schema_violation_ends_the_stream_with_an_error_and_a_terminator(db): + frames = _collect( + db, + _stream(_output('{"wrong": true}')), + structured_requirement=_requirement(), + ) + + assert frames[-1].endswith("data: [DONE]\n\n") + error = _payloads(frames)[-1]["error"] + assert error["type"] == "invalid_model_output" + assert error["code"] == "schema_validation_failed" + + +def test_an_unmet_forced_tool_choice_ends_the_stream_with_an_error(db): + frames = _collect(db, _stream(_output("just prose")), tool_choice="required") + + assert frames[-1].endswith("data: [DONE]\n\n") + error = _payloads(frames)[-1]["error"] + assert error["param"] == "tool_choice" + assert error["code"] == "required_tool_missing" + + +def test_a_volunteered_image_does_not_satisfy_a_forced_tool_choice(db, monkeypatch): + """Chat Completions has no image tool, so an image cannot stand in for a forced call. + + The image also covers cleanup: media downloads spawned while chunks arrive have to be + cancelled on an early error return, not left writing files nothing will reference. + """ + started: list[asyncio.Task] = [] + + async def slow_download(_img): + started.append(cast(asyncio.Task, asyncio.current_task())) + await asyncio.sleep(5) + + monkeypatch.setattr("app.server.chat._process_image_item", slow_download) + + first = _output("just prose") + first.candidates[0].web_images = [WebImage(url="http://127.0.0.1:1/x.png")] + + async def stream_with_a_scheduling_gap(): + yield first + # Let the spawned task start, so cancelling it is observable. + await asyncio.sleep(0) + yield _output("just prose", delta="") + + frames = _collect(db, stream_with_a_scheduling_gap(), tool_choice="required") + + error = _payloads(frames)[-1]["error"] + assert error["code"] == "required_tool_missing" + assert frames[-1].endswith("data: [DONE]\n\n") + assert started + assert all(task.cancelled() for task in started) + + +def test_an_upstream_failure_mid_stream_is_reported_and_terminated(db): + async def failing(): + yield _output("partial") + raise RuntimeError("upstream went away") + + frames = _collect(db, failing()) + + assert frames[-1].endswith("data: [DONE]\n\n") + error = _payloads(frames)[-1]["error"] + assert error["type"] == "server_error" + assert "upstream went away" in error["message"] + + +def test_a_completed_turn_is_persisted_for_reuse(db): + """The answer is stored with the prompt, so the next turn can resume this chat.""" + _collect(db, _stream(_output("Hello"))) + + stored = db.find( + MODEL, + [AppMessage(role="user", content="hi"), AppMessage(role="assistant", content="Hello")], + ) + assert stored is not None + assert stored.client_id == CLIENT_ID + assert stored.metadata == ["c", "r", "rc"] + + +def test_a_structured_turn_is_reusable_by_replaying_what_the_client_received(db): + """What is streamed has to equal what is stored, or the next turn cannot match the prefix. + + Withholding the deltas is what makes this hold: the client is sent the validated document, + which is exactly the form persisted, rather than the raw fenced text around it. + """ + frames = _collect( + db, + _stream(_output('```json\n{"a": "x"}\n```')), + structured_requirement=_requirement(), + ) + received = "".join( + payload["choices"][0]["delta"].get("content") or "" + for payload in _payloads(frames) + if payload.get("choices") + ) + assert received == '{"a":"x"}' + + stored = db.find( + MODEL, + [AppMessage(role="user", content="hi"), AppMessage(role="assistant", content=received)], + ) + assert stored is not None + + +def test_a_failed_turn_is_not_persisted(db): + _collect( + db, + _stream(_output('{"wrong": true}')), + structured_requirement=_requirement(), + ) + assert db.keys() == [] + + +def test_responses_schema_failure_cancels_pending_media_tasks(db, monkeypatch): + started: list[asyncio.Task] = [] + + async def slow_download(_img): + started.append(cast(asyncio.Task, asyncio.current_task())) + await asyncio.sleep(5) + + monkeypatch.setattr("app.server.chat._process_image_item", slow_download) + first = _output('{"wrong": true}') + first.candidates[0].web_images = [WebImage(url="http://127.0.0.1:1/x.png")] + + async def stream_with_a_scheduling_gap(): + yield first + await asyncio.sleep(0) + + client = cast( + Any, + SimpleNamespace(id=CLIENT_ID, latest_chat_cid=None, chat_scope=lambda _temporary: None), + ) + session = cast(Any, SimpleNamespace(metadata=["c", "r", "rc"])) + response = _create_responses_real_streaming_response( + stream_with_a_scheduling_gap(), + "resp-test", + 0, + MODEL, + [AppMessage(role="user", content="hi")], + db, + MODEL, + client, + session, + ResponseCreateRequest(model=MODEL, input="hi", stream=True), + "http://testserver/", + _requirement(), + ) + + async def drain(): + frames = [] + async for chunk in response.body_iterator: + frames.append(chunk if isinstance(chunk, str) else bytes(chunk).decode("utf-8")) + return frames, [task.cancelled() for task in started] + + frames, cancelled = asyncio.run(drain()) + assert any("schema_validation_failed" in frame for frame in frames) + assert started + assert all(cancelled) + + +def test_stream_disconnect_cancels_pending_media_tasks(db, monkeypatch): + started: list[asyncio.Task] = [] + + async def slow_download(_img): + started.append(cast(asyncio.Task, asyncio.current_task())) + await asyncio.sleep(5) + + monkeypatch.setattr("app.server.chat._process_image_item", slow_download) + first = _output("Here is the image: ") + first.candidates[0].web_images = [WebImage(url="http://127.0.0.1:1/x.png")] + + async def infinite_stream(): + yield first + while True: + await asyncio.sleep(0.1) + yield _output("more") + + client = cast( + Any, + SimpleNamespace(id=CLIENT_ID, latest_chat_cid=None, chat_scope=lambda _temporary: None), + ) + session = cast(Any, SimpleNamespace(metadata=["c", "r", "rc"])) + response = _create_real_streaming_response( + infinite_stream(), + "chatcmpl-test", + 0, + MODEL, + [AppMessage(role="user", content="hi")], + db, + MODEL, + client, + session, + "http://testserver/", + ) + + async def abort_early(): + it = cast(Any, response.body_iterator) + await it.__anext__() + await it.__anext__() + await it.__anext__() + await asyncio.sleep(0) + await it.aclose() + return [task.cancelled() for task in started] + + cancelled = asyncio.run(abort_early()) + assert started + assert all(cancelled) + + +def test_responses_disconnect_cancels_pending_media_tasks(db, monkeypatch): + started: list[asyncio.Task] = [] + + async def slow_download(_img): + started.append(cast(asyncio.Task, asyncio.current_task())) + await asyncio.sleep(5) + + monkeypatch.setattr("app.server.chat._process_image_item", slow_download) + first = _output("Here is the image: ") + first.candidates[0].web_images = [WebImage(url="http://127.0.0.1:1/x.png")] + + async def infinite_stream(): + yield first + while True: + await asyncio.sleep(0.1) + yield _output("more") + + client = cast( + Any, + SimpleNamespace(id=CLIENT_ID, latest_chat_cid=None, chat_scope=lambda _temporary: None), + ) + session = cast(Any, SimpleNamespace(metadata=["c", "r", "rc"])) + response = _create_responses_real_streaming_response( + infinite_stream(), + "resp-test", + 0, + MODEL, + [AppMessage(role="user", content="hi")], + db, + MODEL, + client, + session, + ResponseCreateRequest(model=MODEL, input="hi", stream=True), + "http://testserver/", + ) + + async def abort_early(): + it = cast(Any, response.body_iterator) + # Consume events until media tasks are scheduled + for _ in range(6): + await it.__anext__() + await asyncio.sleep(0) + await it.aclose() + return [task.cancelled() for task in started] + + cancelled = asyncio.run(abort_early()) + assert started + assert all(cancelled) diff --git a/uv.lock b/uv.lock index 52340bd..f67438e 100644 --- a/uv.lock +++ b/uv.lock @@ -1,6 +1,6 @@ version = 1 revision = 3 -requires-python = "==3.13.*" +requires-python = ">=3.13" [[package]] name = "annotated-doc" @@ -32,6 +32,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/da/35/f2287558c17e29fafc8ef3daf819bb9834061cfa43bff8014f7df7f63bdc/anyio-4.14.2-py3-none-any.whl", hash = "sha256:9f505dda5ac9f0c8309b5e8bd445a8c2bf7246f3ce950121e45ea15bc41d1494", size = 125813, upload-time = "2026-07-12T20:29:05.763Z" }, ] +[[package]] +name = "attrs" +version = "26.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9a/8e/82a0fe20a541c03148528be8cac2408564a6c9a0cc7e9171802bc1d26985/attrs-26.1.0.tar.gz", hash = "sha256:d03ceb89cb322a8fd706d4fb91940737b6642aa36998fe130a9bc96c985eff32", size = 952055, upload-time = "2026-03-19T14:22:25.026Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/64/b4/17d4b0b2a2dc85a6df63d1157e028ed19f90d4cd97c36717afef2bc2f395/attrs-26.1.0-py3-none-any.whl", hash = "sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309", size = 67548, upload-time = "2026-03-19T14:22:23.645Z" }, +] + [[package]] name = "certifi" version = "2026.7.22" @@ -64,6 +73,54 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/70/ea/839b50531021a647fb5e929f72cf97bc1ff702b5472166164b5b6e76b851/cffi-2.1.1-cp313-cp313-win32.whl", hash = "sha256:334644fbac4eff73d985a17a91226df55d0f394160c4cfb880e084c8f7161cac", size = 175263, upload-time = "2026-08-03T21:20:13.559Z" }, { url = "https://files.pythonhosted.org/packages/60/a6/8b149b2c3f2e11aaa1618ef64500b45f50f22c57a977a4dff1aff1f91042/cffi-2.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:1aa5645c30469b09530c4ebca77ebf8f17618293c58f8549cb1a543a50236e7d", size = 185688, upload-time = "2026-08-03T21:20:14.69Z" }, { url = "https://files.pythonhosted.org/packages/01/9a/11f687cb39d6a3504060d5242f04f48c735afb4d3d533958a20594890cb2/cffi-2.1.1-cp313-cp313-win_arm64.whl", hash = "sha256:63bbfd5ded17c4840ac07cd8f1c21ba9d9708141f840b324f422f41b207e3973", size = 180078, upload-time = "2026-08-03T21:20:15.917Z" }, + { url = "https://files.pythonhosted.org/packages/d3/7b/d6bbf82b8b96e7391438898c42f5bd96dd02030fd5b64937d248220003e2/cffi-2.1.1-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:7dbb61fe3a7699468030f71bbe5f8a0e326a151daa91beb11a6fc1f980c55e1c", size = 194064, upload-time = "2026-08-03T21:20:17.148Z" }, + { url = "https://files.pythonhosted.org/packages/94/e6/bcc91b283be94735e268487a054004f0aa19947b6348fa367db53230abc8/cffi-2.1.1-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:f24fb43132a4c6b4cb4eb029492919b2db645be6808d738f244fd146c03c32cb", size = 196720, upload-time = "2026-08-03T21:20:18.268Z" }, + { url = "https://files.pythonhosted.org/packages/d9/99/c4b0c17cacdc9c3b8f280026286a9826d6a208c0f047591a3c3ce99b91fd/cffi-2.1.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:d28630f5854ab07ab1fd4aba756de52326c82e6be15d414b12793f1975048b54", size = 184964, upload-time = "2026-08-03T21:20:19.708Z" }, + { url = "https://files.pythonhosted.org/packages/b3/a9/9db617d05d7367c1ad0ab00b3aa6e6f9281edd689b4ee9ea0e5a84e89c97/cffi-2.1.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:661c298b4821edebead0c91edd2b00374d67ad7c5a1f7a91d4442633b79d6a72", size = 184962, upload-time = "2026-08-03T21:20:20.833Z" }, + { url = "https://files.pythonhosted.org/packages/67/b8/b42132ca113dc567d37684437b46ca1dafc885902b02a110a02d5b511857/cffi-2.1.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:58acb8ab8e295e6c5ea12f888cbb13cf21511ef2a3303a23f4325c29d17fe5c1", size = 222328, upload-time = "2026-08-03T21:20:22.118Z" }, + { url = "https://files.pythonhosted.org/packages/80/10/c5c0cbf0a657aecf59ef511409734230bf556f05a0d6c9eed7aa5c0a0166/cffi-2.1.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:456a61fa52d579ebf9df2e9552ead5129855dbaff6c1e5a9b1bc408809bdc062", size = 209985, upload-time = "2026-08-03T21:20:23.401Z" }, + { url = "https://files.pythonhosted.org/packages/d5/6c/bfa0b87b03b9238148beca990292843c9396ba069b54496596594173de7b/cffi-2.1.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:a4f00aa42f75d6e4595e8866e748cc1705adc0cddfeb2ca86d0d03993d63ba03", size = 208530, upload-time = "2026-08-03T21:20:24.628Z" }, + { url = "https://files.pythonhosted.org/packages/e9/02/4e7d553a7ac4b4238b38b3c1b80d486e9d4436f8d2acbf87a0997fe3f402/cffi-2.1.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b0431303acaea1089ad4b3e9ce4e6518193def1118d4073ca848635ee4ea2e96", size = 221525, upload-time = "2026-08-03T21:20:25.758Z" }, + { url = "https://files.pythonhosted.org/packages/82/1d/a4aaf9babd75acb4d5f223bff71533bee748dd770a382619a798960ee9ba/cffi-2.1.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:64faea20f4e2613363a1a9b9c7dd73058f3ecd00133a511e72ad7c511658f527", size = 225053, upload-time = "2026-08-03T21:20:26.985Z" }, + { url = "https://files.pythonhosted.org/packages/81/10/5dc0e7bdd18e22107054288283380fc97a06ae3f1656a106908d666a3c88/cffi-2.1.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5c58fe613dc5e5336357eff555824a314d8e43282600435c8d1cb6a7a2fedd13", size = 223213, upload-time = "2026-08-03T21:20:28.277Z" }, + { url = "https://files.pythonhosted.org/packages/0b/e9/d0061c364cde06ee43168a0d076ac1da512cbc380d44767b844ba34fe2b6/cffi-2.1.1-cp314-cp314-win32.whl", hash = "sha256:1a18a57b58cfb21fc28d72e876acf10eaed67a1ed96226f92af4df681d571c4c", size = 177682, upload-time = "2026-08-03T21:20:44.288Z" }, + { url = "https://files.pythonhosted.org/packages/a7/06/1c3e01e3ba14c39f6d10bfbac52753b7e22259e38088e5cfe1d704918690/cffi-2.1.1-cp314-cp314-win_amd64.whl", hash = "sha256:3222ba5d678f80a030e6afbcc33dc1ae5cb45facabb61cee2c7016b8432fde48", size = 187949, upload-time = "2026-08-03T21:20:45.623Z" }, + { url = "https://files.pythonhosted.org/packages/87/5b/da4e39efe18eeb89cf580ea9cfc66b6a7c3eadb808fc0cc1d3a295cb5a5d/cffi-2.1.1-cp314-cp314-win_arm64.whl", hash = "sha256:ab36d55f9ed2d067327667c2fea18dda018eb628dd6347aa01dda6cf1f5d3836", size = 182947, upload-time = "2026-08-03T21:20:46.955Z" }, + { url = "https://files.pythonhosted.org/packages/23/59/40338bf421c5accea1d45158170c87006ef1cd371b05c077e76476949728/cffi-2.1.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:7750c6449dff7864bb9bb27ddfb0267756189201a3afc911d82b3caacd70dfc3", size = 188504, upload-time = "2026-08-03T21:20:29.495Z" }, + { url = "https://files.pythonhosted.org/packages/7d/47/5ecf1023850036e674c77ec4de86182d309ae344e39e7cba984b7df5d647/cffi-2.1.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:0beceaabe56af686895136a2de78db54ecd8e4046b236b8fd6d6cb61389e9bf2", size = 188259, upload-time = "2026-08-03T21:20:31.291Z" }, + { url = "https://files.pythonhosted.org/packages/2a/9c/92934c3bea9f785b23eba304538c0b4d37a2a96d2431eb3a1bc87a11aa19/cffi-2.1.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:49cbc70e6542d4ccccb936558d1064a8012541e78f821f955cff24e357776c94", size = 223864, upload-time = "2026-08-03T21:20:32.571Z" }, + { url = "https://files.pythonhosted.org/packages/4d/45/ba4c93527bc38616a8bd36488acb69a2212d60486794f0c1f318949bbb76/cffi-2.1.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:e2d65b31f36619cda3999b78b2aa9632e76b78448e7a56fc4240824200e7c4fc", size = 211538, upload-time = "2026-08-03T21:20:33.808Z" }, + { url = "https://files.pythonhosted.org/packages/80/e9/b6ef565e452acb932fb0cb5443f44a78efbd1233e566f02b5a83855e9115/cffi-2.1.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:28907ab9bfb6aa13184cfc17c6b8e1023c5ab6fd7076d8c20a35e59fe04f8f29", size = 210688, upload-time = "2026-08-03T21:20:34.974Z" }, + { url = "https://files.pythonhosted.org/packages/9a/95/eff5f0cee78d2eabc7eebffec40d3fc1876b5f3c95582e018bb4b99601f2/cffi-2.1.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:51b31d1c98274844cfd7838ce00bfc27c7423a4dc00fc0772fc3331c2cc90676", size = 223803, upload-time = "2026-08-03T21:20:36.564Z" }, + { url = "https://files.pythonhosted.org/packages/fa/01/579d39fb8bef00a335a23d83757b44feb24cd6345a2c451b64cb67b9c362/cffi-2.1.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5e7cecbaadb83884793e05828cee59b210b24583b9c7425d0ba6a754fe22eb4e", size = 226763, upload-time = "2026-08-03T21:20:37.816Z" }, + { url = "https://files.pythonhosted.org/packages/8d/b0/0b44f47c60b01b57b6e2bbd92343f13a85a1d93bc46ccf6e47e244acd99c/cffi-2.1.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:25792eac27877609e7bb06d42ff88278a6624fff2ba9bbb523c09616b117e80f", size = 225688, upload-time = "2026-08-03T21:20:38.959Z" }, + { url = "https://files.pythonhosted.org/packages/eb/d2/3b7176cb570a1d3e27faf67b72f591af508036e0d8b2be2ef9af9e8c84bb/cffi-2.1.1-cp314-cp314t-win32.whl", hash = "sha256:8ef53b2de9bcb9197d31854256575d59dbac0cba72ac627bb291ef5eceb74be4", size = 182868, upload-time = "2026-08-03T21:20:40.388Z" }, + { url = "https://files.pythonhosted.org/packages/56/78/31f00c1bcd97c9bbf55f1bfdf5bc809a5de8887473e90bb9960dca825e80/cffi-2.1.1-cp314-cp314t-win_amd64.whl", hash = "sha256:616f097f2fe415bc92a247f02e11f634e1f9e9a83d327e3c915c15089c87869e", size = 194104, upload-time = "2026-08-03T21:20:41.725Z" }, + { url = "https://files.pythonhosted.org/packages/7b/1b/58496f2ed0a35de575250c02a43ab3cc2c04d494a88fed31c1cabc0fd176/cffi-2.1.1-cp314-cp314t-win_arm64.whl", hash = "sha256:ad2c86c495b899d862ea0f4b42891b8713a3bd45dd4105c7fd51c2a72f39f3a5", size = 186402, upload-time = "2026-08-03T21:20:43.042Z" }, + { url = "https://files.pythonhosted.org/packages/c1/8f/9ebe220eab48a093d1a5a5e339ab0dc7316eef3bb04d63c42f0251b61f50/cffi-2.1.1-cp315-cp315-ios_13_0_arm64_iphoneos.whl", hash = "sha256:dddad92b554513a31f272570678ba307fb9f618f05e3d4a5eacafff9eae03e1d", size = 194043, upload-time = "2026-08-03T21:20:48.179Z" }, + { url = "https://files.pythonhosted.org/packages/ff/69/844bad3ece306c4782c2ecb93597035b6690d48704b803914c199da1e8b3/cffi-2.1.1-cp315-cp315-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:da0e573f9f97159390c89d9f1a9e41908b66d408cc5b58d08cf3847d844c531b", size = 196737, upload-time = "2026-08-03T21:20:49.457Z" }, + { url = "https://files.pythonhosted.org/packages/1b/8a/af668013284634733f02d683458a0728739c7d6ddb5e14cb0c20832266fe/cffi-2.1.1-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:fb92203a88b3d3053034db775110081c49d28be6551923805e039924093761e4", size = 184933, upload-time = "2026-08-03T21:20:50.639Z" }, + { url = "https://files.pythonhosted.org/packages/0c/75/2f5207ff6d1a613133b23a5203cc0c2a628313b5eb3974d7956ae3c57950/cffi-2.1.1-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:2ae64be792b8966f2c69538199728b290e34726562896df1e5dc8ffd8d8188e8", size = 185002, upload-time = "2026-08-03T21:20:52.173Z" }, + { url = "https://files.pythonhosted.org/packages/e2/31/9e1313b0a6e30e91b3b3d3fff51ae99c857c07738e3afcce1f7334e1b7ab/cffi-2.1.1-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:507a24c282e0f42f8ed737cf048572cbf580468da5555764a8331735e9c736b6", size = 222271, upload-time = "2026-08-03T21:20:53.462Z" }, + { url = "https://files.pythonhosted.org/packages/50/e3/f6234a833e6e08c7007003074723c406559eecf9b48dfc97471e5a8eb7a0/cffi-2.1.1-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:246fa40ce8645a614ff682e0b70f37134e460eaf93a775e0cbe3cca585a67a80", size = 209919, upload-time = "2026-08-03T21:20:54.783Z" }, + { url = "https://files.pythonhosted.org/packages/0d/fc/5f74e293fced6edb51af3a46c4ccf6c23c9943774ecb375ddbd522c76add/cffi-2.1.1-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:471cee653ae88de62096552e6d24ccb4a5adb8c8c9f10b5054d0122c15bf2779", size = 208529, upload-time = "2026-08-03T21:20:56.066Z" }, + { url = "https://files.pythonhosted.org/packages/44/16/29e6d01b388bef055ecd6ca8244b3f4d336bd09e92d5d892187b9601084e/cffi-2.1.1-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:aeae0e330c9f6acd681f647d46cefd30c29f93e3392882e792e82080c9691399", size = 221630, upload-time = "2026-08-03T21:20:57.336Z" }, + { url = "https://files.pythonhosted.org/packages/a4/18/fa7f1f6857d5eb88a4ca99ffcbfb7c387a287ccc154c64a73e86314745d7/cffi-2.1.1-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:42a494cee34437f05546455144f2b5d9ac09b1face62bcfce597d2e521066688", size = 225134, upload-time = "2026-08-03T21:20:58.675Z" }, + { url = "https://files.pythonhosted.org/packages/e0/9f/e8e3dfa04a1b4c241f8c91faacad872b4d4efd051d49764ad4e2fd4b9fea/cffi-2.1.1-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:cc572dace3f60ef98d7b12ff411d20f5362feb31a0439eab0085bbfd349982d7", size = 223197, upload-time = "2026-08-03T21:20:59.968Z" }, + { url = "https://files.pythonhosted.org/packages/f8/7e/8debeb04f1ab9fe2a6963964cd6f1aaf7192627b83926586a6a4e089c9fa/cffi-2.1.1-cp315-cp315-win32.whl", hash = "sha256:4f42141fc14250de6dde5ee7ea4432be017252d91f19c5ad043c084cea629cac", size = 177683, upload-time = "2026-08-03T21:21:14.901Z" }, + { url = "https://files.pythonhosted.org/packages/e0/31/5158704cc474ab65c1647932e88be78dc0873f47130e253be38bcaf13d01/cffi-2.1.1-cp315-cp315-win_amd64.whl", hash = "sha256:e6e8cff14d6fb0be70a09c0bdc58096f501952d04624ebf867e0e56da2df8960", size = 187897, upload-time = "2026-08-03T21:21:16.108Z" }, + { url = "https://files.pythonhosted.org/packages/cc/4b/b3a2da8570c704ffc0f9762cdc3ec0f02c8573798e0b5cf7f11c82bbb70f/cffi-2.1.1-cp315-cp315-win_arm64.whl", hash = "sha256:27350daa11d4f10c540e6e89dada4c54feb7256ad03e9a4dc075ebad7ba360d1", size = 182935, upload-time = "2026-08-03T21:21:17.271Z" }, + { url = "https://files.pythonhosted.org/packages/d0/ef/5443574510a1207e6f6bc38ba6e1f1de36cb48fef07b2728bb896a21f430/cffi-2.1.1-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:c26608d2222fb1e94487e4a387d85f13eb55d5ed725cb25a0c589ac4ee60e7bc", size = 188464, upload-time = "2026-08-03T21:21:01.163Z" }, + { url = "https://files.pythonhosted.org/packages/7e/ae/a56fa8c4686ad50e148fcbc8d3ae0d03915ff5c30d795058988c24118cef/cffi-2.1.1-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:4be96343e422f2dfcd12ab5c9f5aebe03f82f737c6bffeca6830b3875cb44aab", size = 188262, upload-time = "2026-08-03T21:21:02.382Z" }, + { url = "https://files.pythonhosted.org/packages/53/b2/6187f46f2912276a3ae284076109cc5c8680482f11f766ccf26db4a86427/cffi-2.1.1-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:937c0052c05a31ca1daf18de3158eed4dbfcb9cc107adbea227728d647be701e", size = 223779, upload-time = "2026-08-03T21:21:03.553Z" }, + { url = "https://files.pythonhosted.org/packages/8a/f6/c3ad28bd19f77047a03084424fbd4cbe997303267c14423737324be0385d/cffi-2.1.1-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:df423d40ee8654634421812bc3b196da3f9bd7d32929da813f8394c4348a5358", size = 211520, upload-time = "2026-08-03T21:21:04.863Z" }, + { url = "https://files.pythonhosted.org/packages/a0/cd/ccac9013a5bd9fd764de118674ab9c805b5ca10c19270d90ee273f8b2240/cffi-2.1.1-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:a730a083190634c65cca36ba5f489531576ebd79bcd5c8e172130f6453127231", size = 210673, upload-time = "2026-08-03T21:21:06.223Z" }, + { url = "https://files.pythonhosted.org/packages/52/86/2976131c639aead931c5bee5aba67e4b09fbeb8018b6f282f70803f923a7/cffi-2.1.1-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:363e05fa78e15116c3c32c210ee36884fd6b9afa6d440e47112c3bd511d64cb6", size = 223835, upload-time = "2026-08-03T21:21:07.539Z" }, + { url = "https://files.pythonhosted.org/packages/ac/0c/33a7aeab2f9c76918c52e084beb39c570db3588133412929e8ec06fab90b/cffi-2.1.1-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:770de9db11e84213beec501cfcaa013b019820ca881e03344dea5844f7876d94", size = 226705, upload-time = "2026-08-03T21:21:08.774Z" }, + { url = "https://files.pythonhosted.org/packages/e3/26/2cde30fdde421130bfc18f70395731a6e6b2053c6a1978a5258ff04e72fa/cffi-2.1.1-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:7da0c5eff80f0197f3b3d1232ec5a682a9325f4ae9016a78f5f5ca35f9ced1f5", size = 225539, upload-time = "2026-08-03T21:21:09.911Z" }, + { url = "https://files.pythonhosted.org/packages/6d/cd/a361394c94b2129d604bb846f624a8e88255a3ee33129c434a00d715e64f/cffi-2.1.1-cp315-cp315t-win32.whl", hash = "sha256:06c72bb76605a4b0cd0aad6930b69d4baf7dd5d806cfc409b824191099700e66", size = 182707, upload-time = "2026-08-03T21:21:11.226Z" }, + { url = "https://files.pythonhosted.org/packages/9b/b5/ba2b299993c26577d529b6ae29841f9e15b9fcf004d65f423f4fcf94ade9/cffi-2.1.1-cp315-cp315t-win_amd64.whl", hash = "sha256:d9c275eaacd24aa73f94ffd6de08fc3f932424d8b6c376f4bed7cde376fe7bc3", size = 193772, upload-time = "2026-08-03T21:21:12.39Z" }, + { url = "https://files.pythonhosted.org/packages/aa/29/35e016098c814cd93de9cd320c66b5bfba14dc6ecedd3cb518fa7c408c69/cffi-2.1.1-cp315-cp315t-win_arm64.whl", hash = "sha256:d18e5ac0f2f03f4f518d3e23db0f0cad7faa1da8620e9c09461d443bbf6e6692", size = 186360, upload-time = "2026-08-03T21:21:13.636Z" }, ] [[package]] @@ -109,6 +166,14 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ad/03/b9df2973f1119f9d11d8fb3bf2682e5ffe5c52ef3ab89f720473c60fe97e/curl_cffi-0.16.0-cp310-abi3-win_amd64.whl", hash = "sha256:e22a8212d830108e977ff394237f637238e265f5f65037d6c1ee71ea8cc03bcb", size = 1976497, upload-time = "2026-08-01T13:44:51.839Z" }, { url = "https://files.pythonhosted.org/packages/6b/8b/092beeb5fbe3b7370666708eb5618a9e593ffc80ecb2c97c3395158d270b/curl_cffi-0.16.0-cp310-abi3-win_arm64.whl", hash = "sha256:095fc36e4988736f31521d6fe0aa1f243dba22656b4818fc2fb3b7a547e7a9ba", size = 1711122, upload-time = "2026-08-01T13:44:53.242Z" }, { url = "https://files.pythonhosted.org/packages/15/ea/81cf3858b256494b31a554cf76bbd345def3ea7e7a1a592cc515633b4e28/curl_cffi-0.16.0-cp313-abi3-android_24_arm64_v8a.whl", hash = "sha256:06b1c7e07af8ff7c4c5ce4086ea89cc582ebff9adff4a37cfffa5f5de5d5b943", size = 8603463, upload-time = "2026-08-01T13:44:55.003Z" }, + { url = "https://files.pythonhosted.org/packages/88/a5/56d25581fe34f3a6ac5470e4713907731abf7736fcb7373ef1d53db25a83/curl_cffi-0.16.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:226c038cfc85db5190c3d4ec1737a897e7651a45fc794ad384634633b1b5b92f", size = 3024017, upload-time = "2026-08-01T13:44:57.248Z" }, + { url = "https://files.pythonhosted.org/packages/41/b8/ea215edaedcc79fee1eb2557074657d3e4ed89120e9e5c8951134fcf19dc/curl_cffi-0.16.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1efd99f7df6e32cbcef7d5d1a0136141da45ff850edf5fc1845c8bed1d06fb95", size = 2780695, upload-time = "2026-08-01T13:44:58.889Z" }, + { url = "https://files.pythonhosted.org/packages/54/16/946c71c7ff94079c02438a1e53bb736651dab84e5b56991130348d821e3a/curl_cffi-0.16.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:6c540b625979b618bff1339e998058c0a36fb2ef93c336b3ef4695c3dae6decd", size = 12829729, upload-time = "2026-08-01T13:45:00.539Z" }, + { url = "https://files.pythonhosted.org/packages/29/4f/0c386128b18ee664ab3b5a44a1d08c8b841163789b77a8ccf8a95f435678/curl_cffi-0.16.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:98f98848aed5d1cb0d5393c46ab84acd73b160c21beb49d6fa612d3cff926478", size = 13481749, upload-time = "2026-08-01T13:45:02.732Z" }, + { url = "https://files.pythonhosted.org/packages/c1/11/fddf4e9bb6bec4dec12d998579a4bd563e6c107f7056aeb8a0f8aee8426d/curl_cffi-0.16.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:2c93c2f4cf5308f40b07516d57c5f499752387b12a4611420b665b1b958aaa87", size = 12583834, upload-time = "2026-08-01T13:45:05.108Z" }, + { url = "https://files.pythonhosted.org/packages/c5/f5/089e00306984ba13e593f531b44083b31ecc1b1b482e23a9976b29155b2e/curl_cffi-0.16.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:75e5898b3066b64a68fa1eb72552a2e027d61c9d2020657ee2fc3e73d4b39db0", size = 13247624, upload-time = "2026-08-01T13:45:07.38Z" }, + { url = "https://files.pythonhosted.org/packages/49/e5/16fe4881f4155afd5013e7d937fe9a204ee948db032f2adc0c34da2a7494/curl_cffi-0.16.0-cp314-cp314t-win_amd64.whl", hash = "sha256:3a1c8a7469453d09b500c47c83945179c4dd31c327906791e54292a06fec080a", size = 2029539, upload-time = "2026-08-01T13:45:09.619Z" }, + { url = "https://files.pythonhosted.org/packages/86/57/c52fc76510a9ccd5629bc981bde877403562fc643dc68c5f06cd0b4c41c8/curl_cffi-0.16.0-cp314-cp314t-win_arm64.whl", hash = "sha256:c3dd33eaf267d017bfac09b4f71af1d94045dee5518ce18af380462e29f92fad", size = 1778763, upload-time = "2026-08-01T13:45:11.051Z" }, ] [[package]] @@ -136,17 +201,21 @@ dependencies = [ { name = "fastapi" }, { name = "gemini-webapi" }, { name = "httptools" }, + { name = "jsonschema" }, { name = "lmdb" }, { name = "loguru" }, { name = "orjson" }, { name = "pydantic-settings", extra = ["yaml"] }, + { name = "regex" }, { name = "uvicorn" }, { name = "uvloop", marker = "sys_platform != 'win32'" }, ] [package.optional-dependencies] dev = [ + { name = "httpx2" }, { name = "pyright" }, + { name = "pytest" }, { name = "ruff" }, { name = "ty" }, ] @@ -160,13 +229,17 @@ dev = [ requires-dist = [ { name = "curl-cffi", specifier = ">=0.16.0" }, { name = "fastapi", specifier = ">=0.141.1" }, - { name = "gemini-webapi", git = "https://github.com/luuquangvu/Gemini-API.git?rev=enable-guest-mode" }, + { name = "gemini-webapi", specifier = ">=2.1.0,<3" }, { name = "httptools", specifier = ">=0.8.0" }, + { name = "httpx2", marker = "extra == 'dev'" }, + { name = "jsonschema", specifier = ">=4.26.0" }, { name = "lmdb", specifier = ">=2.3.0" }, { name = "loguru", specifier = ">=0.7.3" }, { name = "orjson", specifier = ">=3.11.9" }, { name = "pydantic-settings", extras = ["yaml"], specifier = ">=2.15.0" }, { name = "pyright", marker = "extra == 'dev'" }, + { name = "pytest", marker = "extra == 'dev'" }, + { name = "regex", specifier = ">=2026.7.19" }, { name = "ruff", marker = "extra == 'dev'" }, { name = "ty", marker = "extra == 'dev'" }, { name = "uvicorn", specifier = ">=0.52.3" }, @@ -179,14 +252,18 @@ dev = [{ name = "gemini-fastapi", extras = ["dev"] }] [[package]] name = "gemini-webapi" -version = "0.0.post262" -source = { git = "https://github.com/luuquangvu/Gemini-API.git?rev=enable-guest-mode#b38110e82fe0e41bbf53a03646ef87ddc88a996a" } +version = "2.1.0" +source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "curl-cffi" }, { name = "loguru" }, { name = "orjson" }, { name = "pydantic" }, ] +sdist = { url = "https://files.pythonhosted.org/packages/88/1b/895b9f018864ecbeb9bfed65c73c1f787212f175c183441c8d9f4242763f/gemini_webapi-2.1.0.tar.gz", hash = "sha256:08e1e3c659134b2b99c4c1980d4ff9c896fefbdf14b4cd3a9d8326f6cbe87eaa", size = 334969, upload-time = "2026-08-15T18:14:38.942Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/09/da/5f137f06a36dfa4232122cc51acd2984f3225b02daefa4c1a5e39cc81230/gemini_webapi-2.1.0-py3-none-any.whl", hash = "sha256:ce96cef1472d3b6aab906c7a45b6d400bd737bbb05baee8d3689ef91bd6620f7", size = 115365, upload-time = "2026-08-15T18:14:37.446Z" }, +] [[package]] name = "h11" @@ -197,6 +274,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, ] +[[package]] +name = "httpcore2" +version = "2.10.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "h11" }, + { name = "truststore" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a9/83/a896fc59940fc5a6e2aff3a4be1d92fa890112936803b331cae75a993c34/httpcore2-2.10.0.tar.gz", hash = "sha256:13c0cc3d1919d4f28457f60cd2c2abe04113a8af184ccf1142811beba936f9dc", size = 67427, upload-time = "2026-08-09T09:11:32.123Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e5/4f/d149104195a35e2853a2fc203a8e3477747e58c80e17dda686dace174383/httpcore2-2.10.0-py3-none-any.whl", hash = "sha256:7df06cfb34070cae4f7c89be69dc1095eca138e9704ceffb98d25c1912ab6f01", size = 83000, upload-time = "2026-08-09T09:11:29.555Z" }, +] + [[package]] name = "httptools" version = "0.8.0" @@ -210,6 +300,45 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/04/ab/d1cef3b5523f4d272a70f42a776c3169a2dddfe3a54de4b2ce4a36341528/httptools-0.8.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:6a43c9dd399758ccc0531acb0a3c4a6c299ee893ee9400e9c893b7bdcfae0681", size = 464460, upload-time = "2026-05-25T22:17:20.882Z" }, { url = "https://files.pythonhosted.org/packages/ce/48/5d1d072442277bb2b3434e0e60690b8e8c23840ef7de8b6ea54040a536d3/httptools-0.8.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:0770728beb05094c809b98e814edff5fef69d26ad7d21185f2f6d5884a0ba683", size = 471312, upload-time = "2026-05-25T22:17:22.085Z" }, { url = "https://files.pythonhosted.org/packages/0d/66/b96623b27e51a68199ef4efdda0613cced9233fe3062ac74e50749c5ad37/httptools-0.8.0-cp313-cp313-win_amd64.whl", hash = "sha256:7685df791fad561384bfb139e77fde27a1ffd93134e016f95a0db424ffbf77b1", size = 90117, upload-time = "2026-05-25T22:17:23.074Z" }, + { url = "https://files.pythonhosted.org/packages/1a/12/fa3fbf5f9517b273edea2dc982aa82a8c634091e67c590792b729017bc6f/httptools-0.8.0-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:de242a49b5d18e0a8776e654e9f6bf6d89f3875a5c35b425a0e7ce940feb3fd6", size = 206183, upload-time = "2026-05-25T22:17:24.004Z" }, + { url = "https://files.pythonhosted.org/packages/30/fc/5e7c4cb443370f2090a3aba0453a07384d29ff66b7435bb90e77e1037599/httptools-0.8.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:159e9ab5f701ccd42e555a12f1ad8ff69702910fc1c996cf2bb66e5fcb7a231b", size = 112079, upload-time = "2026-05-25T22:17:25.216Z" }, + { url = "https://files.pythonhosted.org/packages/ba/53/771bd891eb0f236f32145d6a1775777ec85745f3cc983a1f23d1a3b8ddfe/httptools-0.8.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:c4a9f1707e4823d54dfec6c33fa3697d302aed536ed352a7ebb5a061ddb869d0", size = 481596, upload-time = "2026-05-25T22:17:26.186Z" }, + { url = "https://files.pythonhosted.org/packages/62/42/94e15bc68ce3d423243c45d7f1b0c7561f13844f97dc52ae23182fb65628/httptools-0.8.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d76ad7b951387e3632c8716a9bb03ac5b45c5f16119aa409db0459520887944e", size = 480865, upload-time = "2026-05-25T22:17:27.542Z" }, + { url = "https://files.pythonhosted.org/packages/1c/7c/fe2980fc03723272e30f135b62360b075f513dfe7cc73aef36c7f04012bd/httptools-0.8.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a3b7387147361c3fd47a0bde763c5c91b5b4cd4dc9989b8ece84ff436c99843b", size = 463189, upload-time = "2026-05-25T22:17:28.546Z" }, + { url = "https://files.pythonhosted.org/packages/15/1b/47fc5fff68acd1bfa20b4734059c9a06cadb88119dcd5258b5b0d21d91c8/httptools-0.8.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f256d6ce930c52ca1cb2a960b7da03548c454e7d28b06059ad41bfe789036ce0", size = 466610, upload-time = "2026-05-25T22:17:29.816Z" }, + { url = "https://files.pythonhosted.org/packages/60/bd/07b13c93ffd9bec9546e0d43f8e19378dd696dbd278511406bc07371ef1f/httptools-0.8.0-cp314-cp314-win_amd64.whl", hash = "sha256:19d1ee275bb59ba2643ba9a3a1e51cc0c788caf2b8df506368e03f56fdd08527", size = 92705, upload-time = "2026-05-25T22:17:31.133Z" }, + { url = "https://files.pythonhosted.org/packages/fd/c4/121648f68ce066d7bd762d6b6d97e620847642d38d54f3d90ff11d947629/httptools-0.8.0-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:de1ed58a974e75d56560acc7e7fed01a454994429456f65209789992e41f2568", size = 215023, upload-time = "2026-05-25T22:17:32.401Z" }, + { url = "https://files.pythonhosted.org/packages/b9/b0/312a062ae741ae3e8baa8c8bf20be81b2e67337b259ab4349bebc7b6142e/httptools-0.8.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:e93c227b595c6926c1acee96891dd9da4be338cfbe82e5cd3bb9d8dd7dc4ac0b", size = 117405, upload-time = "2026-05-25T22:17:33.742Z" }, + { url = "https://files.pythonhosted.org/packages/fc/37/fccd705f795386bb05bf413012fecff2a33e5aa8c2f069096de3e9fd8702/httptools-0.8.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2a021c3a8e65cc125390d72f59b968afca3bdcaff25bd67965e0a055a14946ca", size = 558497, upload-time = "2026-05-25T22:17:34.732Z" }, + { url = "https://files.pythonhosted.org/packages/bd/39/f172e8003576de35f5ba77ff417cf0e34429d35dc014deef15afa337a72c/httptools-0.8.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:48774d39cbb70e2b1f71f88852a3087ae1d3a1eb80482bb48c13067ab080c14f", size = 571585, upload-time = "2026-05-25T22:17:35.813Z" }, + { url = "https://files.pythonhosted.org/packages/3e/b9/f5564760af99f3dbbf3f9104dc00e5da27e96cf433c6bdcf77617f70bf3f/httptools-0.8.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:88eead8ec8680a9f146c655bc88445a325bd7921cfd8194c7337e9467282427d", size = 543297, upload-time = "2026-05-25T22:17:37.08Z" }, + { url = "https://files.pythonhosted.org/packages/99/67/8d9f2c313618e161b82f3873188e7196126da1d6e29688df40eb3997c77a/httptools-0.8.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:2c032fa028f46871ec7e1fc59fc15e8023eab3e6bbe6ece786a1611719a5d081", size = 539535, upload-time = "2026-05-25T22:17:38.032Z" }, + { url = "https://files.pythonhosted.org/packages/48/63/b906c01e53f50d432c0defe43ce52764a111dc1bdd028bafbeb54dcfd008/httptools-0.8.0-cp314-cp314t-win_amd64.whl", hash = "sha256:384c17174464c8e873398b7af24f0b1f44d992c820328413951a625323155d77", size = 108209, upload-time = "2026-05-25T22:17:39.473Z" }, +] + +[[package]] +name = "httpx2" +version = "2.10.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio", marker = "sys_platform != 'emscripten'" }, + { name = "httpcore2", marker = "sys_platform != 'emscripten'" }, + { name = "httpx2-jsfetch", marker = "sys_platform == 'emscripten'" }, + { name = "idna" }, + { name = "truststore", marker = "sys_platform != 'emscripten'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/bd/3d/f9a8c07a3884f3e5b26205e8436a18b3af61c5d53192c3bea235574dbbec/httpx2-2.10.0.tar.gz", hash = "sha256:8741d7329fe2c7885fc9ceb61c8217acfb87a85f75723714b89ebf7ad7196338", size = 98749, upload-time = "2026-08-09T09:11:33.24Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b9/6d/a637d52449d98a6892d9a4dc0262587afdb6a66f201871842dce5a97b1c1/httpx2-2.10.0-py3-none-any.whl", hash = "sha256:5e3194a432701e1cc6f69a8b1b2fa199ef907013fede8d9a09a2c5b7b8141a18", size = 94355, upload-time = "2026-08-09T09:11:30.882Z" }, +] + +[[package]] +name = "httpx2-jsfetch" +version = "1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cd/c4/0e5636363151a2a1795e0a77617168b9ca438e1748ec05fc9b5687f93d64/httpx2_jsfetch-1.0.tar.gz", hash = "sha256:70a0e3eabfef7cce5ad9c629f7d01ca05e418f586646f4ddf14782e4c1454c60", size = 6872, upload-time = "2026-08-07T00:13:07.492Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9b/43/832f631d32e4f1211caa2ba368317739fe71f0b8530e4c9d15dc454bac2a/httpx2_jsfetch-1.0-py3-none-any.whl", hash = "sha256:cb916b707601e69a07721aabc8f3f6659be3a6893bc1ff5c6f9e02241df2da32", size = 6382, upload-time = "2026-08-07T00:13:06.567Z" }, ] [[package]] @@ -221,6 +350,42 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/1e/5e/d4e9f1a599fb8e573b7b87160658329fbf28d19eac2718f51fc3def3aa5a/idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2", size = 65455, upload-time = "2026-06-02T14:34:06.319Z" }, ] +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + +[[package]] +name = "jsonschema" +version = "4.26.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "jsonschema-specifications" }, + { name = "referencing" }, + { name = "rpds-py" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b3/fc/e067678238fa451312d4c62bf6e6cf5ec56375422aee02f9cb5f909b3047/jsonschema-4.26.0.tar.gz", hash = "sha256:0c26707e2efad8aa1bfc5b7ce170f3fccc2e4918ff85989ba9ffa9facb2be326", size = 366583, upload-time = "2026-01-07T13:41:07.246Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/69/90/f63fb5873511e014207a475e2bb4e8b2e570d655b00ac19a9a0ca0a385ee/jsonschema-4.26.0-py3-none-any.whl", hash = "sha256:d489f15263b8d200f8387e64b4c3a75f06629559fb73deb8fdfb525f2dab50ce", size = 90630, upload-time = "2026-01-07T13:41:05.306Z" }, +] + +[[package]] +name = "jsonschema-specifications" +version = "2025.9.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "referencing" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/19/74/a633ee74eb36c44aa6d1095e7cc5569bebf04342ee146178e2d36600708b/jsonschema_specifications-2025.9.1.tar.gz", hash = "sha256:b540987f239e745613c7a9176f3edb72b832a4ac465cf02712288397832b5e8d", size = 32855, upload-time = "2025-09-08T01:34:59.186Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/41/45/1a4ed80516f02155c51f51e8cedb3c1902296743db0bbc66608a0db2814f/jsonschema_specifications-2025.9.1-py3-none-any.whl", hash = "sha256:98802fee3a11ee76ecaca44429fda8a41bff98b00a0f2838151b113f210cc6fe", size = 18437, upload-time = "2025-09-08T01:34:57.871Z" }, +] + [[package]] name = "lmdb" version = "2.3.0" @@ -233,6 +398,12 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/79/45/1dc1ff9c998d08051728fef5f60eb31f8f9294a7bd80a786a6ae6917f2db/lmdb-2.3.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f8d815c4f1ad38d048efeee395c935b8839ed244a1d74526c83e31c05faab3ec", size = 346786, upload-time = "2026-07-12T15:46:10.535Z" }, { url = "https://files.pythonhosted.org/packages/f3/d4/1bd0b10a0d7408f2d6a4c49be9176287bdccc8c3951a96abfd8f61eb5ec1/lmdb-2.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:f45e10949d0fc7a0cc4bc9b3bfe34d824b5a71330312289e43dc2638c26a9f13", size = 115087, upload-time = "2026-07-12T15:46:11.779Z" }, { url = "https://files.pythonhosted.org/packages/80/f9/a4d82dedaf2a9090bb44c5ab300158a22f4c26a30355c7ffb40f52503bc6/lmdb-2.3.0-cp313-cp313-win_arm64.whl", hash = "sha256:de099ca35b010fd0c5eed957e7ffda0aee32639fb7d12c0dadbb850be9c62dee", size = 114437, upload-time = "2026-07-12T15:46:12.867Z" }, + { url = "https://files.pythonhosted.org/packages/94/6c/0c582a5c1333836ca990e81449494cea45f0a87946e05ca6291284a48eeb/lmdb-2.3.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:6369469befaf66ac8d599d0627e5190147eea2d81b735c6c5e64e8b629b1f10f", size = 120656, upload-time = "2026-07-12T15:46:14.075Z" }, + { url = "https://files.pythonhosted.org/packages/d4/02/4d9332c1c0914578de46b0b0e7e29b0f5a551b6221d7333add33f20aa843/lmdb-2.3.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:42c616d283be4c370d0cf89c83f0b6da77667f9edda62741f4b8bad95c9ba056", size = 120040, upload-time = "2026-07-12T15:46:15.24Z" }, + { url = "https://files.pythonhosted.org/packages/32/ff/0f6b3ac56e1ddcf2bad89b76608d6c1200b9e1c432739a9b6155965e3e3e/lmdb-2.3.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:90b96fec0ef2d6ecfd225b9b10020b819699bf0e214ebe635f224d5cced20cf7", size = 344674, upload-time = "2026-07-12T15:46:16.441Z" }, + { url = "https://files.pythonhosted.org/packages/66/0d/81df0bb4297a549d2cfc0c658f321d15a47f460f4f9c0331b5ead2fcb366/lmdb-2.3.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5cbf2f5eaf3db866345cb1139e0c6fef873a3c0758f01915ba6ed6ba12b24fe0", size = 346381, upload-time = "2026-07-12T15:46:17.682Z" }, + { url = "https://files.pythonhosted.org/packages/3d/96/9e5b9eb951751f50931a0a8d4bb5d3ccda7ad5e377c8743a0846c14ce11b/lmdb-2.3.0-cp314-cp314-win_amd64.whl", hash = "sha256:83225e119c799c911295d5a6f83bf25e52746609fd6907fc95bb8ae9d0f5a6d0", size = 116721, upload-time = "2026-07-12T15:46:19.057Z" }, + { url = "https://files.pythonhosted.org/packages/69/72/35ae2ebdf91e084857ec7f162397f2fd10feccac9b770e3f7d83c005d9be/lmdb-2.3.0-cp314-cp314-win_arm64.whl", hash = "sha256:40ebeea45a92dfd5b67bf47fc66b5b026cf19e365ac9bab3a3140b6b1b9766f6", size = 116812, upload-time = "2026-07-12T15:46:20.132Z" }, ] [[package]] @@ -278,6 +449,39 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/5f/cc/2d10025f9056d376e4127ec05a5808b218d46f035fdc08178a5411b34250/orjson-3.11.9-cp313-cp313-win32.whl", hash = "sha256:d4087e5c0209a0a8efe4de3303c234b9c44d1174161dcd851e8eea07c7560b32", size = 131613, upload-time = "2026-05-06T15:10:39.569Z" }, { url = "https://files.pythonhosted.org/packages/67/bd/2775ff28bfe883b9aa1ff348300542eb2ef1ee18d8ae0e3a49846817a865/orjson-3.11.9-cp313-cp313-win_amd64.whl", hash = "sha256:051b102c93b4f634e89f3866b07b9a9a98915ada541f4ec30f177067b2694979", size = 127086, upload-time = "2026-05-06T15:10:41.262Z" }, { url = "https://files.pythonhosted.org/packages/91/2b/d26799e580939e32a7da9a39531bc9e58e15ca32ffaa6a8cb3e9bb0d22cd/orjson-3.11.9-cp313-cp313-win_arm64.whl", hash = "sha256:cce9127885941bd28f080cecf1f1d288336b7e0d812c345b08be88b572796254", size = 126696, upload-time = "2026-05-06T15:10:42.651Z" }, + { url = "https://files.pythonhosted.org/packages/8e/eb/5da01e356015aee6ecfa1187ced87aef51364e306f5e695dd52719bf0e78/orjson-3.11.9-cp314-cp314-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:b6ef1979adc4bc243523f1a2ba91418030a8e29b0a99cbe7e0e2d6807d4dce6e", size = 228465, upload-time = "2026-05-06T15:10:44.097Z" }, + { url = "https://files.pythonhosted.org/packages/64/62/3e0e0c14c957133bcd855395c62b55ed4e3b0af23ffea11b032cb1dcbdb1/orjson-3.11.9-cp314-cp314-macosx_15_0_arm64.whl", hash = "sha256:f36b7f32c7c0db4a719f1fc5824db4a9c6f8bd1a354debb91faf26ebf3a4c71e", size = 128364, upload-time = "2026-05-06T15:10:45.839Z" }, + { url = "https://files.pythonhosted.org/packages/5a/5a/07d8aa117211a8ed7630bda80c8c0b14d04e0f8dcf99bcf49656e4a710eb/orjson-3.11.9-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:08f4d8ebb44925c794e535b2bebc507cebf32209df81de22ae285fb0d8d66de0", size = 132063, upload-time = "2026-05-06T15:10:47.267Z" }, + { url = "https://files.pythonhosted.org/packages/d6/ec/4acaf21483e18aa945be74a474c74b434f284b549f275a0a39b9f98956e9/orjson-3.11.9-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:6cc7923789694fd58f001cbcac7e47abc13af4d560ebbfcf3b41a8b1a0748124", size = 122356, upload-time = "2026-05-06T15:10:48.765Z" }, + { url = "https://files.pythonhosted.org/packages/13/d8/5f0555e7638801323b7a75850f92e7dfa891bc84fe27a1ba4449170d1200/orjson-3.11.9-cp314-cp314-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ea5c46eb2d3af39e806b986f4b09d5c2706a1f5afde3cbf7544ce6616127173c", size = 129592, upload-time = "2026-05-06T15:10:50.13Z" }, + { url = "https://files.pythonhosted.org/packages/b6/30/ed9860412a3603ceb3c5955bfd72d28b9d0e7ba6ed81add14f83d7114236/orjson-3.11.9-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f5d89a2ed90731df3be64bab0aa44f78bff39fdc9d71c291f4a8023aa46425b7", size = 140491, upload-time = "2026-05-06T15:10:51.582Z" }, + { url = "https://files.pythonhosted.org/packages/d0/17/adc514dea7ac7c505527febf884934b815d34f0c7b8693c1a8b39c5c4a57/orjson-3.11.9-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:25e4aed0312d292c09f61af25bba34e0b2c88546041472b09088c39a4d828af1", size = 127309, upload-time = "2026-05-06T15:10:53.329Z" }, + { url = "https://files.pythonhosted.org/packages/76/3e/c0b690253f0b82d86e99949af13533363acfb5432ecb5d53dd5b3bce9c34/orjson-3.11.9-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:aaea64f3f467d22e70eeed68bdccb3bc4f83f650446c4a03c59f2cba28a108db", size = 134030, upload-time = "2026-05-06T15:10:54.988Z" }, + { url = "https://files.pythonhosted.org/packages/c1/7a/bc82a0bb25e9faaf92dc4d9ef002732efc09737706af83e346788641d4a7/orjson-3.11.9-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a028425d1b440c5d92a6be1e1a020739dfe67ea87d96c6dbe828c1b30041728b", size = 141482, upload-time = "2026-05-06T15:10:56.663Z" }, + { url = "https://files.pythonhosted.org/packages/01/55/e69188b939f77d5d32a9833745ace31ea5ccae3ab613a1ec185d3cd2c4fb/orjson-3.11.9-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:5b192c6cf397e4455b11523c5cf2b18ed084c1bbd61b6c0926344d2129481972", size = 415178, upload-time = "2026-05-06T15:10:58.446Z" }, + { url = "https://files.pythonhosted.org/packages/2e/1a/b8a5a7ac527e80b9cb11d51e3f6689b709279183264b9ec5c7bc680bb8b5/orjson-3.11.9-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:ea407d4ccf5891d667d045fecae97a7a1e5e87b3b97f97ae1803c2e741130be0", size = 148089, upload-time = "2026-05-06T15:11:00.441Z" }, + { url = "https://files.pythonhosted.org/packages/97/4e/00503f64204bf859b37213a63927028f30fb6268cd8677fb0a5ad48155e1/orjson-3.11.9-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5f63aaf97afd9f6dec5b1a68e1b8da12bfccb4cb9a9a65c3e0b6c847849e7586", size = 136921, upload-time = "2026-05-06T15:11:02.176Z" }, + { url = "https://files.pythonhosted.org/packages/0d/ba/a23b82a0a8d0ed7bed4e5f5035aae751cad4ff6a1e8d2ecd14d8860f5929/orjson-3.11.9-cp314-cp314-win32.whl", hash = "sha256:e30ab17845bb9fa54ccf67fa4f9f5282652d54faa6d17452f47d0f369d038673", size = 131638, upload-time = "2026-05-06T15:11:03.696Z" }, + { url = "https://files.pythonhosted.org/packages/f3/c3/0c6798456bade745c75c452342dabacce5798196483e77e643be1f53877d/orjson-3.11.9-cp314-cp314-win_amd64.whl", hash = "sha256:32ef5f4283a3be81913947d19608eacb7c6608026851123790cd9cc8982af34b", size = 127078, upload-time = "2026-05-06T15:11:05.123Z" }, + { url = "https://files.pythonhosted.org/packages/16/21/5a3f1e8913103b703a436a5664238e5b965ec392b555fe68943ea3691e6b/orjson-3.11.9-cp314-cp314-win_arm64.whl", hash = "sha256:eebdbdeef0094e4f5aefa20dcd4eb2368ab5e7a3b4edea27f1e7b2892e009cf9", size = 126687, upload-time = "2026-05-06T15:11:06.602Z" }, +] + +[[package]] +name = "packaging" +version = "26.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7d/fa/3944b40b07da9ce895c0e6303a5ab7d53da063554f534556b134a54d6093/packaging-26.3.tar.gz", hash = "sha256:94edc256424af38762eb31306eed28beb9f0efc50a8837492c9d6fd6004aed79", size = 313412, upload-time = "2026-08-04T18:15:28.737Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/63/34/ba1c580383c9eada3711951fef0795c80b829a078d72188184bcab9dd527/packaging-26.3-py3-none-any.whl", hash = "sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c", size = 129956, upload-time = "2026-08-04T18:15:27.159Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, ] [[package]] @@ -327,6 +531,34 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/1a/d9/c248c103856f807ef70c18a4f986693a46a8ffe1602e5d361485da502d20/pydantic_core-2.41.5-cp313-cp313-win32.whl", hash = "sha256:650ae77860b45cfa6e2cdafc42618ceafab3a2d9a3811fcfbd3bbf8ac3c40d36", size = 1994679, upload-time = "2025-11-04T13:40:50.619Z" }, { url = "https://files.pythonhosted.org/packages/9e/8b/341991b158ddab181cff136acd2552c9f35bd30380422a639c0671e99a91/pydantic_core-2.41.5-cp313-cp313-win_amd64.whl", hash = "sha256:79ec52ec461e99e13791ec6508c722742ad745571f234ea6255bed38c6480f11", size = 2019766, upload-time = "2025-11-04T13:40:52.631Z" }, { url = "https://files.pythonhosted.org/packages/73/7d/f2f9db34af103bea3e09735bb40b021788a5e834c81eedb541991badf8f5/pydantic_core-2.41.5-cp313-cp313-win_arm64.whl", hash = "sha256:3f84d5c1b4ab906093bdc1ff10484838aca54ef08de4afa9de0f5f14d69639cd", size = 1981005, upload-time = "2025-11-04T13:40:54.734Z" }, + { url = "https://files.pythonhosted.org/packages/ea/28/46b7c5c9635ae96ea0fbb779e271a38129df2550f763937659ee6c5dbc65/pydantic_core-2.41.5-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:3f37a19d7ebcdd20b96485056ba9e8b304e27d9904d233d7b1015db320e51f0a", size = 2119622, upload-time = "2025-11-04T13:40:56.68Z" }, + { url = "https://files.pythonhosted.org/packages/74/1a/145646e5687e8d9a1e8d09acb278c8535ebe9e972e1f162ed338a622f193/pydantic_core-2.41.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1d1d9764366c73f996edd17abb6d9d7649a7eb690006ab6adbda117717099b14", size = 1891725, upload-time = "2025-11-04T13:40:58.807Z" }, + { url = "https://files.pythonhosted.org/packages/23/04/e89c29e267b8060b40dca97bfc64a19b2a3cf99018167ea1677d96368273/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:25e1c2af0fce638d5f1988b686f3b3ea8cd7de5f244ca147c777769e798a9cd1", size = 1915040, upload-time = "2025-11-04T13:41:00.853Z" }, + { url = "https://files.pythonhosted.org/packages/84/a3/15a82ac7bd97992a82257f777b3583d3e84bdb06ba6858f745daa2ec8a85/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:506d766a8727beef16b7adaeb8ee6217c64fc813646b424d0804d67c16eddb66", size = 2063691, upload-time = "2025-11-04T13:41:03.504Z" }, + { url = "https://files.pythonhosted.org/packages/74/9b/0046701313c6ef08c0c1cf0e028c67c770a4e1275ca73131563c5f2a310a/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4819fa52133c9aa3c387b3328f25c1facc356491e6135b459f1de698ff64d869", size = 2213897, upload-time = "2025-11-04T13:41:05.804Z" }, + { url = "https://files.pythonhosted.org/packages/8a/cd/6bac76ecd1b27e75a95ca3a9a559c643b3afcd2dd62086d4b7a32a18b169/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2b761d210c9ea91feda40d25b4efe82a1707da2ef62901466a42492c028553a2", size = 2333302, upload-time = "2025-11-04T13:41:07.809Z" }, + { url = "https://files.pythonhosted.org/packages/4c/d2/ef2074dc020dd6e109611a8be4449b98cd25e1b9b8a303c2f0fca2f2bcf7/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:22f0fb8c1c583a3b6f24df2470833b40207e907b90c928cc8d3594b76f874375", size = 2064877, upload-time = "2025-11-04T13:41:09.827Z" }, + { url = "https://files.pythonhosted.org/packages/18/66/e9db17a9a763d72f03de903883c057b2592c09509ccfe468187f2a2eef29/pydantic_core-2.41.5-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2782c870e99878c634505236d81e5443092fba820f0373997ff75f90f68cd553", size = 2180680, upload-time = "2025-11-04T13:41:12.379Z" }, + { url = "https://files.pythonhosted.org/packages/d3/9e/3ce66cebb929f3ced22be85d4c2399b8e85b622db77dad36b73c5387f8f8/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:0177272f88ab8312479336e1d777f6b124537d47f2123f89cb37e0accea97f90", size = 2138960, upload-time = "2025-11-04T13:41:14.627Z" }, + { url = "https://files.pythonhosted.org/packages/a6/62/205a998f4327d2079326b01abee48e502ea739d174f0a89295c481a2272e/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:63510af5e38f8955b8ee5687740d6ebf7c2a0886d15a6d65c32814613681bc07", size = 2339102, upload-time = "2025-11-04T13:41:16.868Z" }, + { url = "https://files.pythonhosted.org/packages/3c/0d/f05e79471e889d74d3d88f5bd20d0ed189ad94c2423d81ff8d0000aab4ff/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:e56ba91f47764cc14f1daacd723e3e82d1a89d783f0f5afe9c364b8bb491ccdb", size = 2326039, upload-time = "2025-11-04T13:41:18.934Z" }, + { url = "https://files.pythonhosted.org/packages/ec/e1/e08a6208bb100da7e0c4b288eed624a703f4d129bde2da475721a80cab32/pydantic_core-2.41.5-cp314-cp314-win32.whl", hash = "sha256:aec5cf2fd867b4ff45b9959f8b20ea3993fc93e63c7363fe6851424c8a7e7c23", size = 1995126, upload-time = "2025-11-04T13:41:21.418Z" }, + { url = "https://files.pythonhosted.org/packages/48/5d/56ba7b24e9557f99c9237e29f5c09913c81eeb2f3217e40e922353668092/pydantic_core-2.41.5-cp314-cp314-win_amd64.whl", hash = "sha256:8e7c86f27c585ef37c35e56a96363ab8de4e549a95512445b85c96d3e2f7c1bf", size = 2015489, upload-time = "2025-11-04T13:41:24.076Z" }, + { url = "https://files.pythonhosted.org/packages/4e/bb/f7a190991ec9e3e0ba22e4993d8755bbc4a32925c0b5b42775c03e8148f9/pydantic_core-2.41.5-cp314-cp314-win_arm64.whl", hash = "sha256:e672ba74fbc2dc8eea59fb6d4aed6845e6905fc2a8afe93175d94a83ba2a01a0", size = 1977288, upload-time = "2025-11-04T13:41:26.33Z" }, + { url = "https://files.pythonhosted.org/packages/92/ed/77542d0c51538e32e15afe7899d79efce4b81eee631d99850edc2f5e9349/pydantic_core-2.41.5-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:8566def80554c3faa0e65ac30ab0932b9e3a5cd7f8323764303d468e5c37595a", size = 2120255, upload-time = "2025-11-04T13:41:28.569Z" }, + { url = "https://files.pythonhosted.org/packages/bb/3d/6913dde84d5be21e284439676168b28d8bbba5600d838b9dca99de0fad71/pydantic_core-2.41.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b80aa5095cd3109962a298ce14110ae16b8c1aece8b72f9dafe81cf597ad80b3", size = 1863760, upload-time = "2025-11-04T13:41:31.055Z" }, + { url = "https://files.pythonhosted.org/packages/5a/f0/e5e6b99d4191da102f2b0eb9687aaa7f5bea5d9964071a84effc3e40f997/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3006c3dd9ba34b0c094c544c6006cc79e87d8612999f1a5d43b769b89181f23c", size = 1878092, upload-time = "2025-11-04T13:41:33.21Z" }, + { url = "https://files.pythonhosted.org/packages/71/48/36fb760642d568925953bcc8116455513d6e34c4beaa37544118c36aba6d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:72f6c8b11857a856bcfa48c86f5368439f74453563f951e473514579d44aa612", size = 2053385, upload-time = "2025-11-04T13:41:35.508Z" }, + { url = "https://files.pythonhosted.org/packages/20/25/92dc684dd8eb75a234bc1c764b4210cf2646479d54b47bf46061657292a8/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5cb1b2f9742240e4bb26b652a5aeb840aa4b417c7748b6f8387927bc6e45e40d", size = 2218832, upload-time = "2025-11-04T13:41:37.732Z" }, + { url = "https://files.pythonhosted.org/packages/e2/09/f53e0b05023d3e30357d82eb35835d0f6340ca344720a4599cd663dca599/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bd3d54f38609ff308209bd43acea66061494157703364ae40c951f83ba99a1a9", size = 2327585, upload-time = "2025-11-04T13:41:40Z" }, + { url = "https://files.pythonhosted.org/packages/aa/4e/2ae1aa85d6af35a39b236b1b1641de73f5a6ac4d5a7509f77b814885760c/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2ff4321e56e879ee8d2a879501c8e469414d948f4aba74a2d4593184eb326660", size = 2041078, upload-time = "2025-11-04T13:41:42.323Z" }, + { url = "https://files.pythonhosted.org/packages/cd/13/2e215f17f0ef326fc72afe94776edb77525142c693767fc347ed6288728d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d0d2568a8c11bf8225044aa94409e21da0cb09dcdafe9ecd10250b2baad531a9", size = 2173914, upload-time = "2025-11-04T13:41:45.221Z" }, + { url = "https://files.pythonhosted.org/packages/02/7a/f999a6dcbcd0e5660bc348a3991c8915ce6599f4f2c6ac22f01d7a10816c/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:a39455728aabd58ceabb03c90e12f71fd30fa69615760a075b9fec596456ccc3", size = 2129560, upload-time = "2025-11-04T13:41:47.474Z" }, + { url = "https://files.pythonhosted.org/packages/3a/b1/6c990ac65e3b4c079a4fb9f5b05f5b013afa0f4ed6780a3dd236d2cbdc64/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:239edca560d05757817c13dc17c50766136d21f7cd0fac50295499ae24f90fdf", size = 2329244, upload-time = "2025-11-04T13:41:49.992Z" }, + { url = "https://files.pythonhosted.org/packages/d9/02/3c562f3a51afd4d88fff8dffb1771b30cfdfd79befd9883ee094f5b6c0d8/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:2a5e06546e19f24c6a96a129142a75cee553cc018ffee48a460059b1185f4470", size = 2331955, upload-time = "2025-11-04T13:41:54.079Z" }, + { url = "https://files.pythonhosted.org/packages/5c/96/5fb7d8c3c17bc8c62fdb031c47d77a1af698f1d7a406b0f79aaa1338f9ad/pydantic_core-2.41.5-cp314-cp314t-win32.whl", hash = "sha256:b4ececa40ac28afa90871c2cc2b9ffd2ff0bf749380fbdf57d165fd23da353aa", size = 1988906, upload-time = "2025-11-04T13:41:56.606Z" }, + { url = "https://files.pythonhosted.org/packages/22/ed/182129d83032702912c2e2d8bbe33c036f342cc735737064668585dac28f/pydantic_core-2.41.5-cp314-cp314t-win_amd64.whl", hash = "sha256:80aa89cad80b32a912a65332f64a4450ed00966111b6615ca6816153d3585a8c", size = 1981607, upload-time = "2025-11-04T13:41:58.889Z" }, + { url = "https://files.pythonhosted.org/packages/9f/ed/068e41660b832bb0b1aa5b58011dea2a3fe0ba7861ff38c4d4904c1c1a99/pydantic_core-2.41.5-cp314-cp314t-win_arm64.whl", hash = "sha256:35b44f37a3199f771c3eaa53051bc8a70cd7b54f333531c59e29fd4db5d15008", size = 1974769, upload-time = "2025-11-04T13:42:01.186Z" }, ] [[package]] @@ -348,6 +580,15 @@ yaml = [ { name = "pyyaml" }, ] +[[package]] +name = "pygments" +version = "2.20.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, +] + [[package]] name = "pyright" version = "1.1.411" @@ -361,6 +602,22 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/0a/49/385be530a6a5b78d1cbcd5c2e38debc8959a2fc6bdb716f4e581002979fc/pyright-1.1.411-py3-none-any.whl", hash = "sha256:dc7c72a8e2700c55baa127554040e067041ea53ccfd50bf96308cc4291c7d5d9", size = 6181526, upload-time = "2026-06-25T02:14:04.691Z" }, ] +[[package]] +name = "pytest" +version = "9.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" }, +] + [[package]] name = "python-dotenv" version = "1.2.2" @@ -386,6 +643,190 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" }, { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" }, { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" }, + { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" }, + { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" }, + { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" }, + { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" }, + { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" }, + { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" }, + { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" }, + { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" }, + { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" }, + { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" }, + { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" }, + { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" }, + { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" }, + { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" }, + { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, +] + +[[package]] +name = "referencing" +version = "0.37.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "rpds-py" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/22/f5/df4e9027acead3ecc63e50fe1e36aca1523e1719559c499951bb4b53188f/referencing-0.37.0.tar.gz", hash = "sha256:44aefc3142c5b842538163acb373e24cce6632bd54bdb01b21ad5863489f50d8", size = 78036, upload-time = "2025-10-13T15:30:48.871Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2c/58/ca301544e1fa93ed4f80d724bf5b194f6e4b945841c5bfd555878eea9fcb/referencing-0.37.0-py3-none-any.whl", hash = "sha256:381329a9f99628c9069361716891d34ad94af76e461dcb0335825aecc7692231", size = 26766, upload-time = "2025-10-13T15:30:47.625Z" }, +] + +[[package]] +name = "regex" +version = "2026.7.19" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/20/98/04b13f1ddfb63158025291c02e03eb42fbb7acb51d091d541050eb4e35e8/regex-2026.7.19.tar.gz", hash = "sha256:7e77b324909c1617cbb4c668677e2c6ae13f44d7c1de0d4f15f2e3c10f3315b5", size = 416440, upload-time = "2026-07-19T00:19:48.923Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5d/3d/84165e4299ff76f3a40fe1f2abf939e976f693383a08d2beea6af62bd2c1/regex-2026.7.19-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:f035d9dc1d25eff9d361456572231c7d27b5ccd473ca7dc0adfce732bd006d40", size = 496552, upload-time = "2026-07-19T00:17:36.808Z" }, + { url = "https://files.pythonhosted.org/packages/02/a2/a65293e6e4cf28eb7ee1be5335a5386c40d6742e9f47fafc8fec785e16c7/regex-2026.7.19-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:c42572142ed0b9d5d261ba727157c426510da78e20828b66bbb855098b8a4e38", size = 296983, upload-time = "2026-07-19T00:17:38.816Z" }, + { url = "https://files.pythonhosted.org/packages/95/47/2d0564e93d87bc48618360ddca232a2ca612bbdf53ce8465d45ca5ce14ee/regex-2026.7.19-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:40b34dd88658e4fedd2fddbf0275ac970d00614b731357f425722a3ed1983d11", size = 291832, upload-time = "2026-07-19T00:17:40.726Z" }, + { url = "https://files.pythonhosted.org/packages/07/cd/42dfbabff3dfc9603c501c0e2e2c5adbb09d127b267bf5348de0af338c15/regex-2026.7.19-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0c41c63992bf1874cebb6e7f56fd7d3c007924659a604ae3d90e427d40d4fd13", size = 796775, upload-time = "2026-07-19T00:17:42.382Z" }, + { url = "https://files.pythonhosted.org/packages/df/5d/f6a4839f2b934e3eed5973fd07f5929ee97d4c98939fb275ea23c274ee16/regex-2026.7.19-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1d3372064506b94dd2c67c845f2db8062e9e9ba84d04e33cb96d7d33c11fe1ae", size = 865687, upload-time = "2026-07-19T00:17:44.185Z" }, + { url = "https://files.pythonhosted.org/packages/14/b0/b47d6c36049bc59806a50bd4c86ced70bbe058d787f80281b1d7a9b0e024/regex-2026.7.19-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:fce7760bf283405b2c7999cab3da4e72f7deca6396013115e3f7a955db9760da", size = 911962, upload-time = "2026-07-19T00:17:46.442Z" }, + { url = "https://files.pythonhosted.org/packages/2a/be/ff61f28f9273658cfe23acbbac5217221f6519960ed401e61dfdab12bc35/regex-2026.7.19-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c0d702548d89d572b2929879bc883bb7a4c4709efafe4512cadee56c55c9bd15", size = 801817, upload-time = "2026-07-19T00:17:48.25Z" }, + { url = "https://files.pythonhosted.org/packages/c3/bb/8b4f7f26b333f9f79e1b453613c39bb4776f51d38ae66dd0ba31d6b354ca/regex-2026.7.19-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d446c6ac40bb6e05025ccee55b84d80fe9bf8e93010ffc4bb9484f13d498835f", size = 776908, upload-time = "2026-07-19T00:17:50.183Z" }, + { url = "https://files.pythonhosted.org/packages/09/13/610110fc5921d380516d03c26b652555f08aa0d23ea78a771231873c3638/regex-2026.7.19-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4c3501bfa814ab07b5580741f9bf78dfdfe146a04057f82df9e2402d2a975939", size = 784426, upload-time = "2026-07-19T00:17:52.454Z" }, + { url = "https://files.pythonhosted.org/packages/ca/f5/1ef9e2a83a5947c57ebff0b377cb5727c3d5ec1992317a320d035cd0dbb6/regex-2026.7.19-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:c4585c3e64b4f9e583b4d2683f18f5d5d872b3d71dcf24594b74ecc23602fa96", size = 860600, upload-time = "2026-07-19T00:17:54.229Z" }, + { url = "https://files.pythonhosted.org/packages/a0/02/073af33a3ec149241d11c80acea91e722aa0adbf05addd50f251c4fe89c3/regex-2026.7.19-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:571fde9741eb0ccde23dd4e0c1d50fbae910e901fa7e629faf39b2dda740d220", size = 765950, upload-time = "2026-07-19T00:17:56.041Z" }, + { url = "https://files.pythonhosted.org/packages/81/a9/d1e9f819dc394a568ef370cd56cf25394e957a2235f8370f23b576e5a475/regex-2026.7.19-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:15b364b9b98d6d2fe1a85034c23a3180ff913f46caddc3895f6fd65186255ccc", size = 851794, upload-time = "2026-07-19T00:17:57.897Z" }, + { url = "https://files.pythonhosted.org/packages/03/3a/8ae83eda7579feacdf984e71fb9e70635fb6f832eeddca58427ec4fca926/regex-2026.7.19-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ffd8893ccc1c2fce6e0d6ca402d716fe1b29db70c7132609a05955e31b2aa8f2", size = 789845, upload-time = "2026-07-19T00:17:59.97Z" }, + { url = "https://files.pythonhosted.org/packages/4b/23/c195cbfe5a75fdec64d8f6554fd15237b837919d2c61bdc141d7c807b08b/regex-2026.7.19-cp313-cp313-win32.whl", hash = "sha256:f0fa4fa9c3632d708742baf2282f2055c11d888a790362670a403cbf48a2c404", size = 267135, upload-time = "2026-07-19T00:18:01.958Z" }, + { url = "https://files.pythonhosted.org/packages/b2/80/a11de8404b7272b70acb45c1c05987cce60b45d5693da2e176f0e390d564/regex-2026.7.19-cp313-cp313-win_amd64.whl", hash = "sha256:d51ffd3427640fa2da6ade574ceba932f210ad095f65fcc450a2b0a0d454868e", size = 277747, upload-time = "2026-07-19T00:18:04.121Z" }, + { url = "https://files.pythonhosted.org/packages/d1/29/0f5c8eff1b4f1f3d83276d365fccecf666afcc7d947420943bf394d07adb/regex-2026.7.19-cp313-cp313-win_arm64.whl", hash = "sha256:c670fe7be5b6020b76bc6e8d2196074657e1327595bca93a389e1a76ab130ad8", size = 277129, upload-time = "2026-07-19T00:18:05.821Z" }, + { url = "https://files.pythonhosted.org/packages/dc/4c/44b74742052cedda40f9ae469532a037112f7311a36669a891fba8984bb0/regex-2026.7.19-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:db47b561c9afd884baa1f96f797c9ca369872c4b65912bc691cfa99e68340af2", size = 501134, upload-time = "2026-07-19T00:18:07.567Z" }, + { url = "https://files.pythonhosted.org/packages/f0/45/bbd038b5e39ee5613a5a689290145b40058cc152c41de9cc23639d2b9734/regex-2026.7.19-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:65dcd28d3eba2ab7c2fd906485cc301392b47cc2234790d27d4e4814e02cdfda", size = 299418, upload-time = "2026-07-19T00:18:09.38Z" }, + { url = "https://files.pythonhosted.org/packages/65/38/c5bde94b4cedfd5850d64c3f08222d8e1600e84f6ee71d9b44b4b8163f74/regex-2026.7.19-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:f2e7f8e2ab6c2922be02c7ec45185aa5bd771e2e57b95455ee343a44d8130dff", size = 294486, upload-time = "2026-07-19T00:18:11.188Z" }, + { url = "https://files.pythonhosted.org/packages/d7/6a/2f5e107cb26c960b781967178899daf2787a7ab151844ed3c01d6fc95474/regex-2026.7.19-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fe31f28c94402043161876a258a9c6f757cb485905c7614ce8d6cd40e6b7bdc1", size = 811643, upload-time = "2026-07-19T00:18:12.975Z" }, + { url = "https://files.pythonhosted.org/packages/37/d4/a2f963406d7d73a62eed84ba05a258afb6cad1b21aa4517443ce40506b78/regex-2026.7.19-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f8f6fa298bb4f7f58a33334406218ba74716e68feddf5e4e54cd5d8082705abf", size = 871081, upload-time = "2026-07-19T00:18:14.733Z" }, + { url = "https://files.pythonhosted.org/packages/45/a3/44be546340bedb15f13063f5e7fe16793ea4d9ea2e805d09bd174ac27724/regex-2026.7.19-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:cc1b2440423a851fad781309dd87843868f4f66a6bcd1ddb9225cf4ec2c84732", size = 917372, upload-time = "2026-07-19T00:18:16.724Z" }, + { url = "https://files.pythonhosted.org/packages/f8/f6/e0870b0fd2a40dba0074e4b76e514b21313d37946c9248453e34ec43923e/regex-2026.7.19-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8ac59a0900474a52b7c04af8196affc22bd9842acb0950df12f7b813e983609a", size = 816089, upload-time = "2026-07-19T00:18:18.617Z" }, + { url = "https://files.pythonhosted.org/packages/ae/27/957e8e22690ad6634572b39b71f130a6105f4d0718bb16849eac00fff147/regex-2026.7.19-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4896db1f4ce0576765b8272aa922df324e0f5b9bb2c3d03044ff32a7234a9aba", size = 785206, upload-time = "2026-07-19T00:18:20.464Z" }, + { url = "https://files.pythonhosted.org/packages/76/a4/186e410941e731037c01166069ab86da9f65e8f8110c18009ccf4bd623ee/regex-2026.7.19-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:4e6883a021db30511d9fb8cfb0f222ce1f2c369f7d4d8b0448f449a93ba0bdfc", size = 800431, upload-time = "2026-07-19T00:18:22.716Z" }, + { url = "https://files.pythonhosted.org/packages/73/9f/e4e10e023d291d64a33e246610b724493bf1ce98e0e59c9b7c837e5acfb7/regex-2026.7.19-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:09523a592938aa9f587fb74467c63ff0cf88fc3df14c82ab0f0517dcf76aaa62", size = 864906, upload-time = "2026-07-19T00:18:24.772Z" }, + { url = "https://files.pythonhosted.org/packages/24/57/ccb20b6be5f1f52a053d1ba2a8f7a077edb9d918248b8490d7506c6832b3/regex-2026.7.19-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:1ebac3474b8589fce2f9b225b650afd61448f7c73a5d0255a10cc6366471aed1", size = 773559, upload-time = "2026-07-19T00:18:27.008Z" }, + { url = "https://files.pythonhosted.org/packages/a3/82/f3b263cf8fad927dc102891da8502e718b7ff9d19af7a2a07c03865d7188/regex-2026.7.19-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:4a0530bb1b8c1c985e7e2122e2b4d3aedd8a3c21c6bfddae6767c4405668b56e", size = 857739, upload-time = "2026-07-19T00:18:29.107Z" }, + { url = "https://files.pythonhosted.org/packages/47/2e/1687bd1b6c2aed5e672ccf845fc11557821fe7366d921b50889ea5ce57bf/regex-2026.7.19-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:2ef7eeb108c47ce7bcc9513e51bcb1bf57e8f483d52fce68a8642e3527141ae0", size = 804522, upload-time = "2026-07-19T00:18:31.362Z" }, + { url = "https://files.pythonhosted.org/packages/76/7c/cc4e7655181b2d9235b704f2c5e19d8eff002bbc437bae59baee0e381aca/regex-2026.7.19-cp313-cp313t-win32.whl", hash = "sha256:64b6ca7391a1395c2638dd5c7456d67bea44fc6c5e8e92c5dc8aa6a8f23292b4", size = 269141, upload-time = "2026-07-19T00:18:33.479Z" }, + { url = "https://files.pythonhosted.org/packages/bb/14/961b4c7b05a2391c32dbc85e27773076671ef8f97f36cec70fe414734c02/regex-2026.7.19-cp313-cp313t-win_amd64.whl", hash = "sha256:f04b9f56b0e0614c0126be12c2c2d9f8850c1e57af302bd0a63bed379d4af974", size = 280036, upload-time = "2026-07-19T00:18:35.419Z" }, + { url = "https://files.pythonhosted.org/packages/ce/67/795644550d788ddbb6dc458c95895f8009978ea6d6ea76b005eb3f45e8c9/regex-2026.7.19-cp313-cp313t-win_arm64.whl", hash = "sha256:fcee38cd8e5089d6d4f048ba1233b3ad76e5954f545382180889112ff5cb712d", size = 279394, upload-time = "2026-07-19T00:18:37.454Z" }, + { url = "https://files.pythonhosted.org/packages/d2/25/0c4c452f8ef3efe456745b2f33195f5904b573fb4c2ff3f0cb9ec188461e/regex-2026.7.19-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:a81758ed242b861b72e778ba34d41366441a2e10b16b472784c88da2dea7e2dd", size = 496750, upload-time = "2026-07-19T00:18:39.633Z" }, + { url = "https://files.pythonhosted.org/packages/24/9e/b70ca6c1704f6c7cd32a9e143c86cc5968d10981eca284bad670c245ea7d/regex-2026.7.19-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:4aa5435cdb3eb6f55fe98a171b05e3fbcd95fadaa4aa32acf62afd9b0cfdbcac", size = 297093, upload-time = "2026-07-19T00:18:41.583Z" }, + { url = "https://files.pythonhosted.org/packages/87/74/0b692da2520d51fbff19c88b83d97e4c702909dd02386c585998b7e2dbed/regex-2026.7.19-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:60be8693a1dadc210bbcbc0db3e26da5f7d01d1d5a3da594e99b4fa42df404f5", size = 292043, upload-time = "2026-07-19T00:18:43.347Z" }, + { url = "https://files.pythonhosted.org/packages/e3/a7/1d478e614016045a33feae57446215f9fd65b665a5ceb2f891fb3183bc52/regex-2026.7.19-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d19662dbedbe783d323196312d38f5ba53cf56296378252171985da6899887d3", size = 797214, upload-time = "2026-07-19T00:18:45.362Z" }, + { url = "https://files.pythonhosted.org/packages/aa/ae/11b9c9411d92c30e3d2db32df5a31133e4a99a8fc397a604fd08f6c4bffb/regex-2026.7.19-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d15df07081d91b76ff20d43f94592ee110330152d617b730fdbe5ef9fb680053", size = 866433, upload-time = "2026-07-19T00:18:47.315Z" }, + { url = "https://files.pythonhosted.org/packages/b1/62/2b2efc4992f91d6d204b24c647c9f9412e85379d92b7c0ab9fdae622327e/regex-2026.7.19-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:56ad4d9f77df871a99e25c37091052a02528ec0eb059de928ee33956b854b45b", size = 911360, upload-time = "2026-07-19T00:18:49.588Z" }, + { url = "https://files.pythonhosted.org/packages/14/71/986ceea9aa3da548bf1357cad89b63915ec6d21ec957c8113b29ece567df/regex-2026.7.19-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7322ec6cc9fba9d49ab888bb82d67ac5625627aa168f0165139b17018df3fb8a", size = 801275, upload-time = "2026-07-19T00:18:51.767Z" }, + { url = "https://files.pythonhosted.org/packages/15/be/ce9d9534b2cda96eab32c548261224b9b4e220a4126f098f60f42ae7b4cd/regex-2026.7.19-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9c7472192ebfad53a6be7c4a8bfb2d64b81c0e93a1fc8c57e1dd0b638297b5d1", size = 777131, upload-time = "2026-07-19T00:18:54.053Z" }, + { url = "https://files.pythonhosted.org/packages/61/2b/58b5c710f2c3929515a25f3a1ca0dad0dcd4518d4fff3cf23bc7adb8dcd2/regex-2026.7.19-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c10b82c2634df08dfb13b1f04e38fe310d086ee092f4f69c0c8da234251e556e", size = 785020, upload-time = "2026-07-19T00:18:56.579Z" }, + { url = "https://files.pythonhosted.org/packages/84/03/5fe091935b74f15fe0f97998c215cae418d1c0413f6258c7d4d2e83aa37f/regex-2026.7.19-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:17ed5692f6acc4183e98331101a5f9e4f64d72fe58b753da4d444a2c77d05b12", size = 861263, upload-time = "2026-07-19T00:18:58.64Z" }, + { url = "https://files.pythonhosted.org/packages/d8/fa/d60bf82e10841eef62a9e32aac401468f05fddfbcb2942e342b1ba3d2433/regex-2026.7.19-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:22a992de9a0d91bda927bf02b94351d737a0302905432c88a53de7c4b9ce62e2", size = 766199, upload-time = "2026-07-19T00:19:00.705Z" }, + { url = "https://files.pythonhosted.org/packages/bf/5d/11e64d151b0662b81d6bf644c74dc118d461df85bdf2577fadbbf751788a/regex-2026.7.19-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:618a0aed532be87294c4477b0481f3aa0f1520f4014a4374dd4cf789b4cd2c97", size = 851317, upload-time = "2026-07-19T00:19:03.015Z" }, + { url = "https://files.pythonhosted.org/packages/7c/34/532efb87488d90807bae6a443d357ee5e2728a478c597619c8aaa17cc0bd/regex-2026.7.19-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2ce9e679f776649746729b6c86382da519ef649c8e34cc41df0d2e5e0f6c36d4", size = 789557, upload-time = "2026-07-19T00:19:05.338Z" }, + { url = "https://files.pythonhosted.org/packages/d6/90/3a8d5ca977171ec3ae21a71207d2228b2663bde14d7f7ef0e6363ecf9290/regex-2026.7.19-cp314-cp314-win32.whl", hash = "sha256:73f272fba87b8ccfe70a137d02a54af386f6d27aa509fbffdd978f5947aae1aa", size = 272531, upload-time = "2026-07-19T00:19:07.487Z" }, + { url = "https://files.pythonhosted.org/packages/96/e1/8862885e70409de70e8c005f57fb2e7be8d9ef0317250d60f4c9660a300d/regex-2026.7.19-cp314-cp314-win_amd64.whl", hash = "sha256:d721e53758b2cca74990185eb0671dd466d7a388a1a45d0c6f4c13cef41a68ac", size = 280831, upload-time = "2026-07-19T00:19:09.46Z" }, + { url = "https://files.pythonhosted.org/packages/08/82/2693e53e29f9104d9de95d37ce4dd826bd32d5f9c0085d3aa6ac042675c4/regex-2026.7.19-cp314-cp314-win_arm64.whl", hash = "sha256:65fa6cb38ed5e9c3637e68e544f598b39c3b86b808ed0627a67b68320384b459", size = 281099, upload-time = "2026-07-19T00:19:11.398Z" }, + { url = "https://files.pythonhosted.org/packages/92/b7/9a01aa16461a18cde9d7b9c3ab21e501db2ce33725f53014342b91df2b0a/regex-2026.7.19-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:5a2721c8720e2cb3c209925dfb9200199b4b07361c9e01d321719404b21458b3", size = 501121, upload-time = "2026-07-19T00:19:13.425Z" }, + { url = "https://files.pythonhosted.org/packages/f3/5e/bbaeca815dc9191c424c94a4fdc5c87c75748a64a6271821212ebdd4e1a3/regex-2026.7.19-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:199535629f25caf89698039af3d1ad5fcae7f933e2112c73f1cdf49165c99518", size = 299415, upload-time = "2026-07-19T00:19:15.43Z" }, + { url = "https://files.pythonhosted.org/packages/cd/d6/0dd1a321afaab95eb7ff44aa0f637301786f1dc71c6b797b9ed236ed8890/regex-2026.7.19-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:9b60d7814174f059e5de4ab98271cc5ba9259cfea55273a81544dceea32dc8d9", size = 294483, upload-time = "2026-07-19T00:19:17.879Z" }, + { url = "https://files.pythonhosted.org/packages/92/5f/40bacf91d0904f812e13bbbab3864604c463eced8afdc54aeaa50492ea95/regex-2026.7.19-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dbece16025afda5e3031af0c4059207e61dcf73ef13af844964f57f387d1c435", size = 811833, upload-time = "2026-07-19T00:19:20.102Z" }, + { url = "https://files.pythonhosted.org/packages/94/7c/4902744261f775aeede8b5627314b38482da29cf49a57b66a6fb753246c5/regex-2026.7.19-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d24ecb4f5e009ea0bd275ee37ad9953b32005e2e5e60f8bbae16da0dbbf0d3a0", size = 871270, upload-time = "2026-07-19T00:19:22.365Z" }, + { url = "https://files.pythonhosted.org/packages/16/70/6980c9be6bf21c0a60ed3e0aea39cf419ecf3b08d1d9947bc56e196ef186/regex-2026.7.19-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8cae6fd77a5b72dae505084b1a2ee0360139faf72fedbab667cd7cc65aae7a6a", size = 917534, upload-time = "2026-07-19T00:19:24.529Z" }, + { url = "https://files.pythonhosted.org/packages/52/92/8b2bd872782ce8c42691e39acb38eb8efe014e5ddb78ad7d943d6f197ce9/regex-2026.7.19-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9724e6cb5e478cd7d8cabf027826178739cb18cf0e117d0e32814d479fa02276", size = 816135, upload-time = "2026-07-19T00:19:26.919Z" }, + { url = "https://files.pythonhosted.org/packages/de/2d/33a602f657bdc4041f17d79f92ab18261d255d91a06117a6e29df023e5e2/regex-2026.7.19-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:572fc57b0009c735ee56c175ea021b637a15551a312f56734277f923d6fd0f6c", size = 785492, upload-time = "2026-07-19T00:19:29.192Z" }, + { url = "https://files.pythonhosted.org/packages/9e/36/0987cf4cb271680064a70d24a475873775a151d0b7058698a006cb0cae4a/regex-2026.7.19-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:20568e182eb82d39a6bf7cff3fd58566f14c75c6f74b2c8c96537eecf9010e3a", size = 800658, upload-time = "2026-07-19T00:19:31.392Z" }, + { url = "https://files.pythonhosted.org/packages/a8/24/c14f31c135e1ba55fa4f9a58ca98d0842512bf6188230763c31c8f449e3b/regex-2026.7.19-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:1d58561843f0ff7dc78b4c28b5e2dc388f3eff94ebc8a232a3adba961fc00009", size = 865073, upload-time = "2026-07-19T00:19:33.485Z" }, + { url = "https://files.pythonhosted.org/packages/14/85/181a12211f22469f24d2de1ebddfe397d2396e2c29013b9a58134a91069a/regex-2026.7.19-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:61bb1bd45520aacd56dd80943bd34991fb5350afdd1f36f2282230fd5154a218", size = 773684, upload-time = "2026-07-19T00:19:35.599Z" }, + { url = "https://files.pythonhosted.org/packages/23/58/bd1a0c1a62251366f8d21f41b1ea3c76994962071b8b6ea42f72d505c0f0/regex-2026.7.19-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:cd3584591ea4429026cdb931b054342c2bcf189b44ff367f8d5c15bc092a2966", size = 857769, upload-time = "2026-07-19T00:19:37.738Z" }, + { url = "https://files.pythonhosted.org/packages/e4/4f/f7e2dad6756b2fe1fe75dd90a628c3b45f249d39f948dd90cd2476325417/regex-2026.7.19-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5cc26a66e212fa5d6c6170c3a40d99d888db3020c6fdab1523250d4341382e44", size = 804546, upload-time = "2026-07-19T00:19:40.229Z" }, + { url = "https://files.pythonhosted.org/packages/2b/d7/01d31d5bdb09bc026fab77f59a371fdf8f9b292e4810546c56182ca70498/regex-2026.7.19-cp314-cp314t-win32.whl", hash = "sha256:2c4e61e2e1be56f63ec3cc618aa9e0de81ef6f43d177205451840022e24f5b78", size = 274526, upload-time = "2026-07-19T00:19:42.398Z" }, + { url = "https://files.pythonhosted.org/packages/52/0e/cea4ce73bc0a8247a0748228ae6669984c7e1f8134b6fa66e59c0572e0ea/regex-2026.7.19-cp314-cp314t-win_amd64.whl", hash = "sha256:c639ea314df70a7b2811e8020448c75af8c9445f5a60f8a4ced81c306a9380c2", size = 283763, upload-time = "2026-07-19T00:19:44.644Z" }, + { url = "https://files.pythonhosted.org/packages/6f/b6/26e41975febae63b7a6e3e02f32cff6cff2e4f10d19c929082f56aebf7c6/regex-2026.7.19-cp314-cp314t-win_arm64.whl", hash = "sha256:9a15e785f244f3e07847b984ce8773fc3da10a9f3c131cc49a4c5b4d672b4547", size = 283451, upload-time = "2026-07-19T00:19:46.639Z" }, +] + +[[package]] +name = "rpds-py" +version = "2026.6.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/aa/2a/9618a122aeb2a169a28b03889a2995fe297588964333d4a7d67bdf46e147/rpds_py-2026.6.3.tar.gz", hash = "sha256:1cebd1337c242e4ec2293e541f712b2da849b29f48f0c293684b71c0632625d4", size = 64051, upload-time = "2026-06-30T07:17:53.009Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a4/9e/b818ee580026ec578138e961027a68820c40afeb1ec8f6819b54fb99e196/rpds_py-2026.6.3-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:3cfe765c1da0072636ca06628261e0ea05688e160d5c8a03e0217c3854037223", size = 343012, upload-time = "2026-06-30T07:15:36.005Z" }, + { url = "https://files.pythonhosted.org/packages/f3/6b/686d9dc4359a8f163cfbbf89ee0b4e586431de22fe8248edb63a8cf50d49/rpds_py-2026.6.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f4d78253f6996be4901669ad25319f842f740eccf4d58e3c7f3dd39e6dde1d8f", size = 338203, upload-time = "2026-06-30T07:15:37.462Z" }, + { url = "https://files.pythonhosted.org/packages/9e/9b/069aa329940f8207615e091f5eedbbd40e1e15eac68a0790fd05ccdf796c/rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:54f45a148e28767bf343d33a684693c70e451c6f4c0e9904709a723fafbdfc1f", size = 367984, upload-time = "2026-06-30T07:15:39.008Z" }, + { url = "https://files.pythonhosted.org/packages/14/db/34c203e4becff3703e4d3bc121842c00b8689197f398161203a880052f4e/rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:842e7b070435622248c7a2c44ae53fa1440e073cc3023bc919fed570884097a7", size = 374815, upload-time = "2026-06-30T07:15:40.253Z" }, + { url = "https://files.pythonhosted.org/packages/ee/7d/8071067d2cc453d916ad836e828c943f575e8a44612537759002a1e07381/rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8020133a74bd81b4572dd8e4be028a6b1ebcd70e6726edc3918008c08bee6ee6", size = 490545, upload-time = "2026-06-30T07:15:41.729Z" }, + { url = "https://files.pythonhosted.org/packages/a3/42/da06c5aa8f0484ff07f270787434204d9f4535e2f8c3b51ed402267e63c3/rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cdc7e35386f3847df728fbcb5e887e2d79c19e2fa1eba9e51b6621d23e3243af", size = 382828, upload-time = "2026-06-30T07:15:43.327Z" }, + { url = "https://files.pythonhosted.org/packages/57/d7/fe978efc2ae50abe48eb7464668ea99f53c010c60aeebb7b35ad27f23661/rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:acac386b453c2516111b50985d60ce46e7fadb5ea71ae7b25f4c946935bf27cf", size = 365678, upload-time = "2026-06-30T07:15:44.992Z" }, + { url = "https://files.pythonhosted.org/packages/69/9d/1d8922e1990b2a6eb532b6ff53d3e73d2b3bbffc84116c75826bee73dfc6/rpds_py-2026.6.3-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:425560c6fa0415f27261727bb20bd097568485e5eb0c121f1949417d1c516885", size = 377811, upload-time = "2026-06-30T07:15:46.523Z" }, + { url = "https://files.pythonhosted.org/packages/b1/3d/198dceafb4fb034a6a47347e1b0735d34e0bd4a50be4e898d408ee66cb14/rpds_py-2026.6.3-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a550fb4950a06dde3beb4721f5ad4b25bf4513784665b0a8522c792e2bd822a4", size = 395382, upload-time = "2026-06-30T07:15:47.955Z" }, + { url = "https://files.pythonhosted.org/packages/1f/f1/13968e49655d40b6b19d8b9140296bbc6f1d86b3f0f6c346cf9f1adddf4b/rpds_py-2026.6.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4f4bca01b63096f606e095734dd56e74e175f94cfbf24ff3d63281cec61f7bb7", size = 543832, upload-time = "2026-06-30T07:15:49.33Z" }, + { url = "https://files.pythonhosted.org/packages/ac/ab/289bcb1b90bd3e40a2900c561fa0e2087345ecbb094f0b870f2345142b7c/rpds_py-2026.6.3-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ccffae9a092a00deb7efd545fe5e2c33c33b88e7c054337e9a74c179347d0b7d", size = 611011, upload-time = "2026-06-30T07:15:50.847Z" }, + { url = "https://files.pythonhosted.org/packages/1e/16/5043105e679436ccfbc8e5e0dd2d663ed18a8b8113515fd06a5e5d77c83e/rpds_py-2026.6.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1cf01971c4f2c5553b772a542e4aaf191789cd331bc2cd4ff0e6e65ba49e1e97", size = 572431, upload-time = "2026-06-30T07:15:52.394Z" }, + { url = "https://files.pythonhosted.org/packages/85/ed/adab103321c0a6565d5ae1c2998349bc3ee175b82ccc5ae8fc04cc413075/rpds_py-2026.6.3-cp313-cp313-win32.whl", hash = "sha256:8c3d1e9c15b9d51ca0391e13da1a25a0a4df3c58a37c9dc368e0736cf7f69df0", size = 201710, upload-time = "2026-06-30T07:15:53.894Z" }, + { url = "https://files.pythonhosted.org/packages/7b/ed/a03b09668e74e5dabbf2e211f6468e1820c0552f7b0500082da31841bf7b/rpds_py-2026.6.3-cp313-cp313-win_amd64.whl", hash = "sha256:9250a9a0a6fd4648b3f868da8d91a4c52b5811a62df58e753d50ae4454a36f80", size = 219454, upload-time = "2026-06-30T07:15:55.25Z" }, + { url = "https://files.pythonhosted.org/packages/27/17/b8642c12930b71bc2b25831f6708ccf0f75abcd11883932ec9ce54ba3a78/rpds_py-2026.6.3-cp313-cp313-win_arm64.whl", hash = "sha256:900a67df3fd1660b035a4761c4ce73c382ea6b35f90f9863c36c6fd8bf8b09bb", size = 215063, upload-time = "2026-06-30T07:15:56.573Z" }, + { url = "https://files.pythonhosted.org/packages/b6/36/7fbe9dcdaf857fb3f63c2a2284b62492d95f5e8334e947e5fb6e7f68c9be/rpds_py-2026.6.3-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:931908d9fc855d8f74783377822be318edb6dcb19e47169dc038f9a1bf60b06e", size = 344510, upload-time = "2026-06-30T07:15:57.921Z" }, + { url = "https://files.pythonhosted.org/packages/ba/54/f785cc3d3f60839ca57a5af4927a9f347b07b2799c373fc20f7949f87c7e/rpds_py-2026.6.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:d7469697dce35be237db177d42e2a2ee26e6dcc5fc052078a6fefabd288c6edd", size = 339495, upload-time = "2026-06-30T07:15:59.238Z" }, + { url = "https://files.pythonhosted.org/packages/63/ef/d4cdaf309e6b095b43597103cf8c0b951d6cca2acce68c474f75ec12e0c7/rpds_py-2026.6.3-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bcfbcf66006befb9fd2aeaa9e01feaf881b4dc330a02ba07d2322b1c11be7b5d", size = 369454, upload-time = "2026-06-30T07:16:01.021Z" }, + { url = "https://files.pythonhosted.org/packages/96/4a/9559a68b7ee15db09d7981212e8c2e219d2a1d6d4faa0391d813c3496a36/rpds_py-2026.6.3-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:847927daf4cffbd4e90e42bc890069897101edd015f956cb8721b3473372edda", size = 374583, upload-time = "2026-06-30T07:16:02.287Z" }, + { url = "https://files.pythonhosted.org/packages/ef/75/8964aa7d2c6e8ac43eba8eb6e6b0fdda1f46d39f2fc3e6aa9f2cb17f485d/rpds_py-2026.6.3-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:aca6c1ef08a82bfe327cc156da694660f599923e2e6665b6d81c9c2d0ac9ffc8", size = 492919, upload-time = "2026-06-30T07:16:03.723Z" }, + { url = "https://files.pythonhosted.org/packages/8f/97/6908094ac804115e65aedfd90f1b5fee4eebebd3f6c4cfc5419939267565/rpds_py-2026.6.3-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ae50181a047c871561212bb97f7932a2d45fb53e947bd9b57ebad85b529cbc53", size = 383725, upload-time = "2026-06-30T07:16:05.305Z" }, + { url = "https://files.pythonhosted.org/packages/d1/9c/0d1fdc2e7aba23e290d603bc494e97bd205bae262ce33c6b32a69768ed5e/rpds_py-2026.6.3-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dc319e5a1de4b6913aac94bf6a2f9e847371e0a140a43dd4991db1a09bc2d504", size = 367255, upload-time = "2026-06-30T07:16:07.086Z" }, + { url = "https://files.pythonhosted.org/packages/c4/fe/f0209ca4a9ed074bc8acb44dfd0e81c3122e94c9689f5645b7973a866719/rpds_py-2026.6.3-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:e4316bf32babbed84e691e352faf967ce2f0f024174a8643c37c94a1080374fc", size = 379060, upload-time = "2026-06-30T07:16:08.525Z" }, + { url = "https://files.pythonhosted.org/packages/c6/8d/f1cc54c616b9d8897de8738aac148d20afca93f68187475fe194d09a71b9/rpds_py-2026.6.3-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:8c6e5a2f750cc71c3e3b11d71661f21d6f9bc6cebc6564b1466417a1ec03ec77", size = 395960, upload-time = "2026-06-30T07:16:09.989Z" }, + { url = "https://files.pythonhosted.org/packages/fb/04/aafff00f73aeca2945f734f1d483c64ab8f472d0864ab02377fd8e89c3b2/rpds_py-2026.6.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:4470ce197d4090875cf6affbf1f853338387428df97c4fb7b7106317b8214698", size = 545356, upload-time = "2026-06-30T07:16:11.816Z" }, + { url = "https://files.pythonhosted.org/packages/fd/cc/e229663b9e4ddac5a4acbe9085dd80a71af2a5d356b8b39d6bff233f24b0/rpds_py-2026.6.3-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:ea964164cc9afa72d4d9b23cc28dafae93693c0a53e0b42acbff15b22c3f9ddd", size = 612319, upload-time = "2026-06-30T07:16:13.586Z" }, + { url = "https://files.pythonhosted.org/packages/e3/7a/8a0e6d3e6cd066af108b71b43122c3fe158dd9eb86acac626593a2582eb1/rpds_py-2026.6.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:639c8929aa0afe81be836b04de888460d6bed38b9c54cfc18da8f6bfabf5af5d", size = 573508, upload-time = "2026-06-30T07:16:15.23Z" }, + { url = "https://files.pythonhosted.org/packages/87/03/2a69ab618a789cf6cf85c86bb844c62d090e700ab1a2aa676b3741b6c516/rpds_py-2026.6.3-cp314-cp314-win32.whl", hash = "sha256:882076c00c0a608b131187055ddc5ae29f2e7eaf870d6168980420d58528a5c8", size = 202504, upload-time = "2026-06-30T07:16:16.893Z" }, + { url = "https://files.pythonhosted.org/packages/85/62/a3892ba945f4e24c78f352e5de3c7620d8479f73f211406a97263d13c7d2/rpds_py-2026.6.3-cp314-cp314-win_amd64.whl", hash = "sha256:0be972be84cfcaf46c8c6edf690ca0f154ac17babf1f6a955a51579b34ad2dc5", size = 220380, upload-time = "2026-06-30T07:16:18.108Z" }, + { url = "https://files.pythonhosted.org/packages/3d/e7/c2bd44dc831931815ad11ebb5f430b5a0a4d3caa9de837107876c30c3432/rpds_py-2026.6.3-cp314-cp314-win_arm64.whl", hash = "sha256:2a9c6f195058cb45335e8cc3802745c603d716eb96bc9625950c1aac71c0c703", size = 215976, upload-time = "2026-06-30T07:16:19.654Z" }, + { url = "https://files.pythonhosted.org/packages/79/9c/fff7b74bce9a091ec9a012a03f9ff5f69364eaf9451060dfc4486da2ffdd/rpds_py-2026.6.3-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:f90938e92afda60266da758ee7d363447f7f0138c9559f9e1811629580582d90", size = 346840, upload-time = "2026-06-30T07:16:21.268Z" }, + { url = "https://files.pythonhosted.org/packages/e9/44/77bcb1168b33704908295533d27f10eb811e9e3e193e8993dc99572211d3/rpds_py-2026.6.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ec829541c45bca16e61c7ae50c20501f213605beb75d1aba91a6ee37fbbb56a4", size = 340282, upload-time = "2026-06-30T07:16:22.875Z" }, + { url = "https://files.pythonhosted.org/packages/87/3c/7a9081c7c9e645b39efe19e4ffbeccd80add246327cd9b888aecffd72317/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:afd70d95892096cdb26f15a00c45907b17817577aa8d1c76b2dcc2788391f9e9", size = 370403, upload-time = "2026-06-30T07:16:24.415Z" }, + { url = "https://files.pythonhosted.org/packages/f7/69/af47021eb7dad6ff3396cb001c08f0f3c4d06c20253f75be6421a59fe6b7/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:29dfa0533a5d4c94d4dfa1b694fcb56c9c63aad8330ffdd816fd225d0a7a162f", size = 376055, upload-time = "2026-06-30T07:16:26.111Z" }, + { url = "https://files.pythonhosted.org/packages/81/fc/a3bcf517084396a6dd258c592567a3c011ba4557f2fde23dceaf26e74f2e/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:af05d726809bff6b141be124d4c7ce998f9c9c7f30edb1f46c07aa103d540b41", size = 494419, upload-time = "2026-06-30T07:16:27.596Z" }, + { url = "https://files.pythonhosted.org/packages/c9/eb/13d529d1788135425c7bf207f8463458ca5d92e43f3f701365b83e9dffc1/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9826217f048f620d9a712672818bf231442c1b35d96b227a07eabd11b4bb6945", size = 384848, upload-time = "2026-06-30T07:16:29.183Z" }, + { url = "https://files.pythonhosted.org/packages/8e/f4/b7ac49f30013aba8f7b9566b1dd07e81de95e708c1374b7bacc5b9bc5c9c/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:536bceea4fa4acf7e1c61da2b5786304367c816c8895be71b8f537c480b0ea1f", size = 371369, upload-time = "2026-06-30T07:16:30.912Z" }, + { url = "https://files.pythonhosted.org/packages/31/86/6260bafa622f788b07ddec0e52d810305c8b9b0b8c27f58a2ab04bf62b4f/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:bc0011654b91cc4fb2ae701bec0a0ba1e552c0714247fa7af6c59e0ccfa3a4e1", size = 379673, upload-time = "2026-06-30T07:16:32.486Z" }, + { url = "https://files.pythonhosted.org/packages/19/c3/03f1ee79a047b48daeca157c89a18509cde22b6b951d642b9b0af1be660a/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:539d75de9e0d536c84ff18dfeb805398e58227001ce09231a26a08b9aed1ee0e", size = 397500, upload-time = "2026-06-30T07:16:34.471Z" }, + { url = "https://files.pythonhosted.org/packages/f0/95/8ed0cd8c377dca12aea498f119fe639fc474d1461545c39d2b5872eb1c0f/rpds_py-2026.6.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:166cf54d9f44fc6ceb53c7860258dde44a81406646de79f8ed3234fca3b6e538", size = 545978, upload-time = "2026-06-30T07:16:36.45Z" }, + { url = "https://files.pythonhosted.org/packages/d3/f2/0eb57f0eaa83f8fc152a7e03de968ab77e1f00732bebc892b190c6eebde7/rpds_py-2026.6.3-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:d34c20167764fbcf927194d532dd7e0c56772f0a5f943fa5ef9e9afbba8fb9db", size = 613350, upload-time = "2026-06-30T07:16:38.213Z" }, + { url = "https://files.pythonhosted.org/packages/5b/de/e0674bdbc3ef7634989b3f854c3f34bc1f587d36e5bfdc5c378d57034619/rpds_py-2026.6.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ea7bb13b7c9a29791f87a0387ba7d3ad3a6d783d827e4d3f27b40a0ff44495e2", size = 576486, upload-time = "2026-06-30T07:16:39.797Z" }, + { url = "https://files.pythonhosted.org/packages/f2/f6/21101359743cd136ada781e8210a85769578422ba460672eea0e29739200/rpds_py-2026.6.3-cp314-cp314t-win32.whl", hash = "sha256:6de4744d05bd1aa1be4ed7ea1189e3979196808008113bbbf899a460966b925e", size = 201068, upload-time = "2026-06-30T07:16:41.316Z" }, + { url = "https://files.pythonhosted.org/packages/a6/b2/9574d4d44f7760c2aa32d92a0a4f41698e33f5b204a0bf5c9758f52c79d5/rpds_py-2026.6.3-cp314-cp314t-win_amd64.whl", hash = "sha256:c7b9a2f8f4d8e90af72571d3d495deebdd7e3c75451f5b41719aee166e940fc2", size = 220600, upload-time = "2026-06-30T07:16:43.091Z" }, + { url = "https://files.pythonhosted.org/packages/08/ae/f23a2697e6ee6340a578b0f136be6483657bef0c6f9497b752bb5c0964bb/rpds_py-2026.6.3-cp315-cp315-macosx_10_12_x86_64.whl", hash = "sha256:e059c5dde6452b44424bd1834557556c226b57781dee1227af23518459722b13", size = 344726, upload-time = "2026-06-30T07:16:44.5Z" }, + { url = "https://files.pythonhosted.org/packages/c3/63/e7b3a1a5358dd32c930a1062d8e15b67fd6e8922e81df9e91706d66ee5c8/rpds_py-2026.6.3-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:2f7c26fbc5acd2522b95d4177fe4710ffd8e9b20529e703ffbf8db4d93903f05", size = 339587, upload-time = "2026-06-30T07:16:46.255Z" }, + { url = "https://files.pythonhosted.org/packages/ec/64/10a85681916ca55fffb91b0a211f84e34297c109243484dd6394660a8a7c/rpds_py-2026.6.3-cp315-cp315-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a3086b538543802f84c843911242db20447de00d8752dd0efc936dbcf02218ba", size = 369585, upload-time = "2026-06-30T07:16:48.101Z" }, + { url = "https://files.pythonhosted.org/packages/76/c2/baf95c7c38823e12ba34407c5f5767a89e5cf2233895e56f608167ae9493/rpds_py-2026.6.3-cp315-cp315-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8f2e5c5ee828d42cb11760761c0af6507927bec42d0ad5458f97c9203b054617", size = 375479, upload-time = "2026-06-30T07:16:49.93Z" }, + { url = "https://files.pythonhosted.org/packages/6a/94/0aad06c72d65101e11d33528d438cda99a39ce0da99466e156158f2541d3/rpds_py-2026.6.3-cp315-cp315-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ed0c1e5d10cdc7135537988c74a0188da68e2f3c30813ba3744ab1e42e0480f9", size = 492418, upload-time = "2026-06-30T07:16:51.641Z" }, + { url = "https://files.pythonhosted.org/packages/b5/17/de3f5a479a1f056535d7489819639d8cd591ea6281d700390b43b1abd745/rpds_py-2026.6.3-cp315-cp315-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8c2642a7603ec0b16ed77da4555db3b4b472341904873788327c0b0d7b95f1bb", size = 384123, upload-time = "2026-06-30T07:16:53.622Z" }, + { url = "https://files.pythonhosted.org/packages/46/7d/bf09bd1b145bb2671c03e1e6d1ab8651858d90d8c7dfeadd85a37a934fd8/rpds_py-2026.6.3-cp315-cp315-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8e4320744c1ffdd95a603def63344bfab2d33edeab301c5007e7de9f9f5b3885", size = 367351, upload-time = "2026-06-30T07:16:55.241Z" }, + { url = "https://files.pythonhosted.org/packages/a3/ea/1bb734f314b8be319149ddee80b18bd41372bdcfbdf88d28131c0cd37719/rpds_py-2026.6.3-cp315-cp315-manylinux_2_31_riscv64.whl", hash = "sha256:a9f4645593036b81bbdb36b9c8e0ea0d1c3fee968c4d59db0344c14087ef143a", size = 378827, upload-time = "2026-06-30T07:16:56.841Z" }, + { url = "https://files.pythonhosted.org/packages/4b/93/d9611e5b25e26df9a3649813ed66193ace9347a7c7fc4ab7cf70e94851c0/rpds_py-2026.6.3-cp315-cp315-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:e55d236be29255554da47abe5c577637db7c24a02b8b46f0ca9524c855801868", size = 395966, upload-time = "2026-06-30T07:16:58.557Z" }, + { url = "https://files.pythonhosted.org/packages/c3/cb/99d77e16e5534ae1d90629bbe419ba6ee170833a6a85e3aa1cc41726fbbc/rpds_py-2026.6.3-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:24e9c5386e16669b674a69c156c8eeefcb578f3b3397b713b08e6d60f3c7b187", size = 545680, upload-time = "2026-06-30T07:17:00.164Z" }, + { url = "https://files.pythonhosted.org/packages/59/15/11a29755f790cef7a2f755e8e14f4f0c33f39489e1893a632a2eee59672b/rpds_py-2026.6.3-cp315-cp315-musllinux_1_2_i686.whl", hash = "sha256:c60924535c75f1566b6eb75b5c31a48a43fef04fa2d0d201acbad8a9969c6107", size = 611853, upload-time = "2026-06-30T07:17:01.962Z" }, + { url = "https://files.pythonhosted.org/packages/68/86/0c27547e21644da938fb530f7e1a8148dd24d02db07e7a5f2567a17ce710/rpds_py-2026.6.3-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:38a2fea2787428f811719ceb9114cb78964a3138838320c29ac39526c79c16ba", size = 573715, upload-time = "2026-06-30T07:17:03.693Z" }, + { url = "https://files.pythonhosted.org/packages/29/71/4d8fcf700931815594bce892255bbd973b94efaf0fc1932b0590df18d886/rpds_py-2026.6.3-cp315-cp315-win32.whl", hash = "sha256:d483fe17f01ad64b7bf7cc38fcefff1ca9fb83f8c2b2542b68f97ffe0611b369", size = 202864, upload-time = "2026-06-30T07:17:05.746Z" }, + { url = "https://files.pythonhosted.org/packages/eb/62/b577562de0edbb55b2be85ce5fd09c33e386b9b13eee09833af4240fd5c4/rpds_py-2026.6.3-cp315-cp315-win_amd64.whl", hash = "sha256:67e3a721ffc5d8d2210d3671872298c4a84e4b8035cfe42ffd7cde35d772b146", size = 220430, upload-time = "2026-06-30T07:17:07.471Z" }, + { url = "https://files.pythonhosted.org/packages/c8/95/d6d0b2509825141eef60669a5739eec88dbc6a48053d6c92993a5704defe/rpds_py-2026.6.3-cp315-cp315-win_arm64.whl", hash = "sha256:6e84adbcf4bf841aed8116a8264b9f50b4cb3e7bd89b516122e616ac56ca269e", size = 215877, upload-time = "2026-06-30T07:17:09.008Z" }, + { url = "https://files.pythonhosted.org/packages/b7/bf/f3ea278f0afd615c1d0f19cb69043a41526e2bb600c2b536eb192218eb27/rpds_py-2026.6.3-cp315-cp315t-macosx_10_12_x86_64.whl", hash = "sha256:ae6dd8f10bd17aad820876d24caec9efdafd80a318d16c0a48edb5e136902c6b", size = 346933, upload-time = "2026-06-30T07:17:10.762Z" }, + { url = "https://files.pythonhosted.org/packages/9d/29/9907bdf1c5346763cf10b7f6852aad86652168c259def904cbe0082c5864/rpds_py-2026.6.3-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:bdbd97738551fca3917c1bd7188bec1920bb520104f28e7e1007f9ceb17b7690", size = 340274, upload-time = "2026-06-30T07:17:12.266Z" }, + { url = "https://files.pythonhosted.org/packages/6f/2c/8e03767b5778ef25cebf74a7a91a2c3806f8eced4c92cb7406bbe060756d/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8b95977e7211527ab0ba576e286d023389fbeeb32a6b7b771665d333c60e5342", size = 370763, upload-time = "2026-06-30T07:17:14.107Z" }, + { url = "https://files.pythonhosted.org/packages/2e/e1/df2a7e1ba2efd796af26194250b8d42c821b46592311595162af9ef0528d/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d15fde0e6fb0d88a60d221204873743e5d9f0b7d29165e62cd86d0413ad74ba6", size = 376467, upload-time = "2026-06-30T07:17:15.76Z" }, + { url = "https://files.pythonhosted.org/packages/6b/de/8a0814d1946af29cb068fb259aa8622f856df1d0bab58429448726b537f5/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a136d453475ac0fcbda502ef1e6504bd28d6d904700915d278deeab0d00fe140", size = 496689, upload-time = "2026-06-30T07:17:17.308Z" }, + { url = "https://files.pythonhosted.org/packages/df/f3/f19e0c852ba13694f5a79f3b719331051573cb5693feacf8a88ffffc3a71/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f826877d462181e5eb1c26a0026b8d0cab05d99844ecb6d8bf3627a2ca0c0442", size = 385340, upload-time = "2026-06-30T07:17:18.928Z" }, + { url = "https://files.pythonhosted.org/packages/e2/ae/7ec3a9d2d4351f99e37bcb06b6b6f954512646bfdbf9742e1de727865daf/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:79486287de1730dbaff3dbd124d0ca4d2ef7f9d29bf2544f1f93c09b5bcbbd12", size = 372179, upload-time = "2026-06-30T07:17:20.539Z" }, + { url = "https://files.pythonhosted.org/packages/d3/ac/9cee911dff2aaa9a5a8354f6610bf2e6a616de9197c5fff4f54f82585f1e/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_31_riscv64.whl", hash = "sha256:808345f53cb952433ca2816f1604ff3515608a81784954f38d4452acfe8e61d5", size = 379993, upload-time = "2026-06-30T07:17:22.212Z" }, + { url = "https://files.pythonhosted.org/packages/83/6b/7c2a07ba88d1e9a936612f7a5d067467ed03d971d5a06f7d309dff044a7e/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1967debc37f64f2c4dc90a7f563aec558b471966e12adcac4e1c4240496b6ebf", size = 398909, upload-time = "2026-06-30T07:17:23.66Z" }, + { url = "https://files.pythonhosted.org/packages/97/0b/776ffcb66783637b0031f6d58d6fb55913c8b5abf00aeecd46bf933fb477/rpds_py-2026.6.3-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:f0840b5b17057f7fd918b76183a4b5a0635f43e14eb2ce60dce1d4ee4707ea00", size = 546584, upload-time = "2026-06-30T07:17:25.264Z" }, + { url = "https://files.pythonhosted.org/packages/55/33/ba3bc04d7092bd553c9b2b195624992d2cc4f3de1f380b7b93cbee67bd79/rpds_py-2026.6.3-cp315-cp315t-musllinux_1_2_i686.whl", hash = "sha256:faa679d19a6696fd54259ad321251ad77a13e70e03dd834daa762a44fb6196ef", size = 614357, upload-time = "2026-06-30T07:17:26.888Z" }, + { url = "https://files.pythonhosted.org/packages/8b/71/14edf065f04630b1a8472f7653cad03f6c478bcf95ea0e6aed55451e33ea/rpds_py-2026.6.3-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:23a439f31ccbeff1574e24889128821d1f7917470e830cf6544dced1c662262a", size = 576533, upload-time = "2026-06-30T07:17:28.546Z" }, + { url = "https://files.pythonhosted.org/packages/ba/76/65002b08596c389105720a8c0d22298b8dc25a4baf89b2ce431343c8b1de/rpds_py-2026.6.3-cp315-cp315t-win32.whl", hash = "sha256:913ca42ccad3f8cc6e292b587ae8ae49c8c823e5dce51a736252fc7c7cdfa577", size = 201204, upload-time = "2026-06-30T07:17:30.193Z" }, + { url = "https://files.pythonhosted.org/packages/8c/97/d855d6b3c322d1f27e26f5241c42016b56cf01377ea8ed348285f54652f0/rpds_py-2026.6.3-cp315-cp315t-win_amd64.whl", hash = "sha256:ae3d4fe8c0b9213624fdce7279d70e3b148b682ca20719ebd193a23ebfa47324", size = 220719, upload-time = "2026-06-30T07:17:31.788Z" }, ] [[package]] @@ -425,6 +866,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c8/cb/6a6a47d5b464bd08695d254f3da6e7986cc70c9fa5d778eda57538edfe56/starlette-1.6.0-py3-none-any.whl", hash = "sha256:a86dd39d14bb45f85a3d18525215a9ef0cfd1f192ac793220e72598c90335f0c", size = 75969, upload-time = "2026-08-08T18:27:56.196Z" }, ] +[[package]] +name = "truststore" +version = "0.10.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/53/a3/1585216310e344e8102c22482f6060c7a6ea0322b63e026372e6dcefcfd6/truststore-0.10.4.tar.gz", hash = "sha256:9d91bd436463ad5e4ee4aba766628dd6cd7010cf3e2461756b3303710eebc301", size = 26169, upload-time = "2025-08-12T18:49:02.73Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/19/97/56608b2249fe206a67cd573bc93cd9896e1efb9e98bce9c163bcdc704b88/truststore-0.10.4-py3-none-any.whl", hash = "sha256:adaeaecf1cbb5f4de3b1959b42d41f6fab57b2b1666adb59e89cb0b53361d981", size = 18660, upload-time = "2025-08-12T18:49:01.46Z" }, +] + [[package]] name = "ty" version = "0.0.72" @@ -496,6 +946,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/15/c0/0be24758891ef825f2065cd5db8741aaddabe3e248ee6acc5e8a80f04005/uvloop-0.22.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0530a5fbad9c9e4ee3f2b33b148c6a64d47bbad8000ea63704fa8260f4cf728e", size = 4366890, upload-time = "2025-10-16T22:16:40.547Z" }, { url = "https://files.pythonhosted.org/packages/d2/53/8369e5219a5855869bcee5f4d317f6da0e2c669aecf0ef7d371e3d084449/uvloop-0.22.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bc5ef13bbc10b5335792360623cc378d52d7e62c2de64660616478c32cd0598e", size = 4119472, upload-time = "2025-10-16T22:16:41.694Z" }, { url = "https://files.pythonhosted.org/packages/f8/ba/d69adbe699b768f6b29a5eec7b47dd610bd17a69de51b251126a801369ea/uvloop-0.22.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1f38ec5e3f18c8a10ded09742f7fb8de0108796eb673f30ce7762ce1b8550cad", size = 4239051, upload-time = "2025-10-16T22:16:43.224Z" }, + { url = "https://files.pythonhosted.org/packages/90/cd/b62bdeaa429758aee8de8b00ac0dd26593a9de93d302bff3d21439e9791d/uvloop-0.22.1-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:3879b88423ec7e97cd4eba2a443aa26ed4e59b45e6b76aabf13fe2f27023a142", size = 1362067, upload-time = "2025-10-16T22:16:44.503Z" }, + { url = "https://files.pythonhosted.org/packages/0d/f8/a132124dfda0777e489ca86732e85e69afcd1ff7686647000050ba670689/uvloop-0.22.1-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:4baa86acedf1d62115c1dc6ad1e17134476688f08c6efd8a2ab076e815665c74", size = 752423, upload-time = "2025-10-16T22:16:45.968Z" }, + { url = "https://files.pythonhosted.org/packages/a3/94/94af78c156f88da4b3a733773ad5ba0b164393e357cc4bd0ab2e2677a7d6/uvloop-0.22.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:297c27d8003520596236bdb2335e6b3f649480bd09e00d1e3a99144b691d2a35", size = 4272437, upload-time = "2025-10-16T22:16:47.451Z" }, + { url = "https://files.pythonhosted.org/packages/b5/35/60249e9fd07b32c665192cec7af29e06c7cd96fa1d08b84f012a56a0b38e/uvloop-0.22.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c1955d5a1dd43198244d47664a5858082a3239766a839b2102a269aaff7a4e25", size = 4292101, upload-time = "2025-10-16T22:16:49.318Z" }, + { url = "https://files.pythonhosted.org/packages/02/62/67d382dfcb25d0a98ce73c11ed1a6fba5037a1a1d533dcbb7cab033a2636/uvloop-0.22.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:b31dc2fccbd42adc73bc4e7cdbae4fc5086cf378979e53ca5d0301838c5682c6", size = 4114158, upload-time = "2025-10-16T22:16:50.517Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7a/f1171b4a882a5d13c8b7576f348acfe6074d72eaf52cccef752f748d4a9f/uvloop-0.22.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:93f617675b2d03af4e72a5333ef89450dfaa5321303ede6e67ba9c9d26878079", size = 4177360, upload-time = "2025-10-16T22:16:52.646Z" }, + { url = "https://files.pythonhosted.org/packages/79/7b/b01414f31546caf0919da80ad57cbfe24c56b151d12af68cee1b04922ca8/uvloop-0.22.1-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:37554f70528f60cad66945b885eb01f1bb514f132d92b6eeed1c90fd54ed6289", size = 1454790, upload-time = "2025-10-16T22:16:54.355Z" }, + { url = "https://files.pythonhosted.org/packages/d4/31/0bb232318dd838cad3fa8fb0c68c8b40e1145b32025581975e18b11fab40/uvloop-0.22.1-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:b76324e2dc033a0b2f435f33eb88ff9913c156ef78e153fb210e03c13da746b3", size = 796783, upload-time = "2025-10-16T22:16:55.906Z" }, + { url = "https://files.pythonhosted.org/packages/42/38/c9b09f3271a7a723a5de69f8e237ab8e7803183131bc57c890db0b6bb872/uvloop-0.22.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:badb4d8e58ee08dad957002027830d5c3b06aea446a6a3744483c2b3b745345c", size = 4647548, upload-time = "2025-10-16T22:16:57.008Z" }, + { url = "https://files.pythonhosted.org/packages/c1/37/945b4ca0ac27e3dc4952642d4c900edd030b3da6c9634875af6e13ae80e5/uvloop-0.22.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b91328c72635f6f9e0282e4a57da7470c7350ab1c9f48546c0f2866205349d21", size = 4467065, upload-time = "2025-10-16T22:16:58.206Z" }, + { url = "https://files.pythonhosted.org/packages/97/cc/48d232f33d60e2e2e0b42f4e73455b146b76ebe216487e862700457fbf3c/uvloop-0.22.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:daf620c2995d193449393d6c62131b3fbd40a63bf7b307a1527856ace637fe88", size = 4328384, upload-time = "2025-10-16T22:16:59.36Z" }, + { url = "https://files.pythonhosted.org/packages/e4/16/c1fd27e9549f3c4baf1dc9c20c456cd2f822dbf8de9f463824b0c0357e06/uvloop-0.22.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6cde23eeda1a25c75b2e07d39970f3374105d5eafbaab2a4482be82f272d5a5e", size = 4296730, upload-time = "2025-10-16T22:17:00.744Z" }, ] [[package]] From 4464d635329e66e4c3022251009eaba301d28063 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C6=B0u=20Quang=20V=C5=A9?= Date: Sun, 16 Aug 2026 23:01:33 +0700 Subject: [PATCH 287/291] Optimize Dockerfile and CI workflows --- .github/workflows/ci.yaml | 2 +- Dockerfile | 25 ++++++++++++++++++------- 2 files changed, 19 insertions(+), 8 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 71b7305..32dfee7 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -33,7 +33,7 @@ jobs: uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0 - name: Install dependencies - run: uv sync --all-groups + run: uv sync --locked --all-groups - name: Run Ruff run: uv run ruff check diff --git a/Dockerfile b/Dockerfile index 2f67703..d66b58b 100644 --- a/Dockerfile +++ b/Dockerfile @@ -7,8 +7,11 @@ ENV UV_COMPILE_BYTECODE=1 \ UV_PYTHON_DOWNLOADS=0 COPY pyproject.toml uv.lock ./ -RUN --mount=type=cache,target=/root/.cache/uv \ - uv sync --locked --no-install-project --no-dev + +RUN --mount=type=cache,target=/root/.cache/uv < Date: Mon, 17 Aug 2026 09:02:30 +0700 Subject: [PATCH 288/291] Add `guest_mode` health policy --- README.md | 17 +++++++++++--- README.zh.md | 14 ++++++++++-- app/server/health.py | 16 +++++++++++-- app/utils/config.py | 16 +++++++++++++ config/config.yaml | 2 ++ tests/test_health.py | 53 ++++++++++++++++++++++++++++++++++++++++++++ 6 files changed, 111 insertions(+), 7 deletions(-) create mode 100644 tests/test_health.py diff --git a/README.md b/README.md index e975ebf..e1cf33d 100644 --- a/README.md +++ b/README.md @@ -137,9 +137,9 @@ narrows the tool list only in the `ANY` and `VALIDATED` modes that act on it. ### Utility Endpoints -- **`GET /health`**: Readiness endpoint. Returns HTTP 503 when conversation storage is unavailable - or no Gemini client is usable; individual degraded clients are reported without taking a pool - with another usable client out of service. +- **`GET /health`**: Readiness endpoint. Conversation storage failures always return HTTP 503. + Client failures follow the configured `gemini.guest_mode` health policy; the default + `adaptive` policy returns 503 only when every Gemini client is unhealthy. - **`GET /media/{filename}`**: Internal endpoint to serve generated media. Requires a valid token (automatically included in image URLs returned by the API). ## Docker Deployment @@ -300,6 +300,7 @@ You can control whether requests use normal Google chats or Google's temporary c ```yaml gemini: chat_mode: "normal" # "normal" or "temporary" + guest_mode: "adaptive" # "strict", "adaptive", or "permissive" max_chars_per_request: 1000000 ``` @@ -344,6 +345,15 @@ failing: actually serve. `/health` reports a guest client as unhealthy - refresh its cookies to restore full capability. +The `guest_mode` setting controls how those unhealthy clients affect the readiness response: + +- `strict`: return HTTP 503 when any client is unhealthy. +- `adaptive` (default): return HTTP 503 only when all clients are unhealthy; otherwise log a + warning and remain ready. +- `permissive`: log a warning but do not change readiness, even when all clients are unhealthy. + +All three modes log unhealthy clients. Conversation storage failures still return HTTP 503 +regardless of `guest_mode`. Otherwise this applies **only** in temporary mode. A normal chat opened by an authenticated client is kept by Google until you delete it, so its metadata stays reusable indefinitely and @@ -365,6 +375,7 @@ Environment variable equivalent: ```bash export CONFIG_GEMINI__CHAT_MODE="temporary" +export CONFIG_GEMINI__GUEST_MODE="adaptive" ``` ### Models diff --git a/README.zh.md b/README.zh.md index e88a9fd..7403564 100644 --- a/README.zh.md +++ b/README.zh.md @@ -130,8 +130,8 @@ Gemini 网页端未暴露的生成控制项,例如 `temperature`、`top_p`、 ### 实用工具接口 -- **`GET /health`**: 就绪状态接口。当对话存储不可用或没有任何可用 Gemini 客户端时返回 - HTTP 503;如果客户端池中仅有个别客户端降级、但仍有其他客户端可用,则不会将整个服务判为不可用。 +- **`GET /health`**: 就绪状态接口。对话存储不可用时始终返回 HTTP 503。客户端故障则遵循 + `gemini.guest_mode` 健康策略;默认的 `adaptive` 策略仅在所有 Gemini 客户端均不健康时返回 503。 - **`GET /media/{filename}`**: 用于分发生成的媒体内容的内部接口。需要有效的 Token(API 返回的图片 URL 中已自动包含该 Token)。 ## Docker 部署 @@ -286,6 +286,7 @@ gemini: ```yaml gemini: chat_mode: "normal" # "normal"(普通)或 "temporary"(临时) + guest_mode: "adaptive" # "strict"(严格)、"adaptive"(自适应)或 "permissive"(宽松) max_chars_per_request: 1000000 ``` @@ -322,6 +323,14 @@ gemini: 一条警告。`/v1/models` 只会公布客户端确实能够提供服务的模型。 `/health` 会将访客客户端报告为不健康——请刷新其 Cookie 以恢复完整能力。 +`guest_mode` 设置决定这些不健康客户端如何影响就绪状态响应: + +- `strict`(严格):任一客户端不健康时返回 HTTP 503。 +- `adaptive`(自适应,默认):仅当所有客户端均不健康时返回 HTTP 503;若仍有健康客户端, + 则只记录警告并保持就绪。 +- `permissive`(宽松):只记录警告,不改变就绪状态,即使所有客户端均不健康也是如此。 + +三种模式都会记录不健康客户端。无论 `guest_mode` 为何,对话存储不可用时仍会返回 HTTP 503。 除此之外,以上规则**仅**在临时模式下生效:由已认证客户端开启的普通会话在用户手动删除之前 会一直由 Google 保留,因此其元数据可以长期重用,并且不受重启影响。 @@ -340,6 +349,7 @@ gemini: ```bash export CONFIG_GEMINI__CHAT_MODE="temporary" +export CONFIG_GEMINI__GUEST_MODE="adaptive" ``` ### 模型 diff --git a/app/server/health.py b/app/server/health.py index 44e3c8a..41dd4f1 100644 --- a/app/server/health.py +++ b/app/server/health.py @@ -3,6 +3,8 @@ from app.models import HealthCheckResponse from app.services import GeminiClientPool, LMDBConversationStore +from app.utils import g_config +from app.utils.config import GuestMode router = APIRouter() @@ -25,11 +27,21 @@ async def health_check(response: Response): ok=False, error="LMDB conversation store unavailable", clients=client_status ) - if not any(client_status.values()): + guest_mode = g_config.gemini.guest_mode + if guest_mode == GuestMode.STRICT: + any_client_unhealthy = not all(client_status.values()) + clients_unavailable = any_client_unhealthy + client_error = "One or more Gemini clients are unhealthy" + else: + all_clients_unhealthy = not any(client_status.values()) + clients_unavailable = guest_mode == GuestMode.ADAPTIVE and all_clients_unhealthy + client_error = "No usable Gemini client is available" + + if clients_unavailable: response.status_code = status.HTTP_503_SERVICE_UNAVAILABLE return HealthCheckResponse( ok=False, - error="No usable Gemini client is available", + error=client_error, storage=stat, clients=client_status, ) diff --git a/app/utils/config.py b/app/utils/config.py index 04cec55..4294cd2 100644 --- a/app/utils/config.py +++ b/app/utils/config.py @@ -94,6 +94,14 @@ class ChatMode(StrEnum): TEMPORARY = "temporary" +class GuestMode(StrEnum): + """Health-check policy for Gemini clients running as guests.""" + + STRICT = "strict" + ADAPTIVE = "adaptive" + PERMISSIVE = "permissive" + + class GeminiConfig(BaseModel): """Gemini API configuration, including session behavior and generation options.""" @@ -133,6 +141,14 @@ class GeminiConfig(BaseModel): "reply can then come back without the earlier context instead of erroring" ), ) + guest_mode: GuestMode = Field( + default=GuestMode.ADAPTIVE, + description=( + "Guest client health policy: 'strict' fails health checks when any client is " + "unhealthy; 'adaptive' fails only when all clients are unhealthy; 'permissive' " + "only logs a warning" + ), + ) allow_private_url_fetch: bool = Field( default=False, description="Allow server-side fetching of private/loopback image URLs (SSRF risk; default blocks them)", diff --git a/config/config.yaml b/config/config.yaml index 7b45377..a135ca4 100644 --- a/config/config.yaml +++ b/config/config.yaml @@ -43,6 +43,8 @@ gemini: # WARNING: Google may close a temporary window at any time mid-conversation. The reply can then come back without the earlier # context instead of erroring, so the loss may be silent. Prefer "normal" for long or context-sensitive conversations. chat_mode: "normal" + # Guest client health policy: "strict" fails if any client is unhealthy; "adaptive" fails only if all are unhealthy; "permissive" only logs a warning. + guest_mode: "adaptive" storage: path: "data/lmdb" # Database storage path diff --git a/tests/test_health.py b/tests/test_health.py new file mode 100644 index 0000000..7626c45 --- /dev/null +++ b/tests/test_health.py @@ -0,0 +1,53 @@ +import asyncio + +import pytest +from fastapi import Response + +from app.server import health +from app.utils.config import GeminiConfig, GuestMode + + +class _Pool: + def __init__(self, client_status: dict[str, bool]): + self._client_status = client_status + + def status(self) -> dict[str, bool]: + return self._client_status + + +class _Store: + def stats(self) -> dict[str, int]: + return {"entries": 1} + + +@pytest.mark.parametrize( + ("guest_mode", "client_status", "expected_status", "expected_ok"), + [ + (GuestMode.STRICT, {"healthy": True, "guest": False}, 503, False), + (GuestMode.ADAPTIVE, {"healthy": True, "guest": False}, 200, True), + (GuestMode.ADAPTIVE, {"guest-a": False, "guest-b": False}, 503, False), + (GuestMode.PERMISSIVE, {"guest-a": False, "guest-b": False}, 200, True), + ], +) +def test_guest_mode_controls_client_health_status( + monkeypatch, + guest_mode: GuestMode, + client_status: dict[str, bool], + expected_status: int, + expected_ok: bool, +): + monkeypatch.setattr(health, "GeminiClientPool", lambda: _Pool(client_status)) + monkeypatch.setattr(health, "LMDBConversationStore", _Store) + monkeypatch.setattr(health.g_config.gemini, "guest_mode", guest_mode) + response = Response() + + result = asyncio.run(health.health_check(response)) + + assert response.status_code == expected_status + assert result.ok is expected_ok + + +def test_guest_mode_defaults_to_adaptive(): + config = GeminiConfig(clients=[], auto_refresh=True, verbose=True) + + assert config.guest_mode == GuestMode.ADAPTIVE From 24ab2f07b9746e853e1820cfbae89d85b000c050 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C6=B0u=20Quang=20V=C5=A9?= Date: Mon, 17 Aug 2026 11:00:19 +0700 Subject: [PATCH 289/291] Optimize Dockerfile and track gemini-webapi workflow --- .github/workflows/track.yml | 53 ++++++++++++++++++++----------------- Dockerfile | 1 + uv.lock | 6 ++--- 3 files changed, 32 insertions(+), 28 deletions(-) diff --git a/.github/workflows/track.yml b/.github/workflows/track.yml index eee1ccc..41f835e 100644 --- a/.github/workflows/track.yml +++ b/.github/workflows/track.yml @@ -6,12 +6,13 @@ on: workflow_dispatch: concurrency: - group: ${{ github.workflow }}-${{ github.ref }} + group: ${{ github.workflow }}-gemini-webapi cancel-in-progress: true jobs: update-dep: runs-on: ubuntu-latest + timeout-minutes: 10 permissions: contents: write pull-requests: write @@ -21,43 +22,40 @@ jobs: uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false + ref: ${{ github.event.repository.default_branch }} - name: Install uv uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0 - name: Update gemini-webapi id: update + shell: bash run: | - # Install dependencies first to enable uv pip show - uv sync + set -euo pipefail - # Get current version of gemini-webapi before upgrade - OLD_VERSION=$(uv pip show gemini-webapi 2>/dev/null | grep ^Version: | awk '{print $2}') - if [ -z "$OLD_VERSION" ]; then - echo "Error: Could not extract current gemini-webapi version" >&2 + CURRENT_RESOLUTION=$(uv tree --locked --package gemini-webapi --depth 0 | head -n 1) + if [ -z "$CURRENT_RESOLUTION" ]; then + echo "Error: Could not read the current gemini-webapi resolution" >&2 exit 1 fi - echo "Current gemini-webapi version: $OLD_VERSION" + echo "Current resolution: $CURRENT_RESOLUTION" - # Update gemini-webapi to the latest version uv lock --upgrade-package gemini-webapi - # Get new version of gemini-webapi after upgrade - NEW_VERSION=$(uv pip show gemini-webapi | grep ^Version: | awk '{print $2}') - if [ -z "$NEW_VERSION" ]; then - echo "Error: Could not extract new gemini-webapi version" >&2 + UPDATED_RESOLUTION=$(uv tree --locked --package gemini-webapi --depth 0 | head -n 1) + if [ -z "$UPDATED_RESOLUTION" ]; then + echo "Error: Could not read the updated gemini-webapi resolution" >&2 exit 1 fi - echo "New gemini-webapi version: $NEW_VERSION" + echo "Updated resolution: $UPDATED_RESOLUTION" - # Only proceed if gemini-webapi version has changed - if [ "$OLD_VERSION" != "$NEW_VERSION" ]; then - echo "gemini-webapi has been updated from $OLD_VERSION to $NEW_VERSION" - echo "updated=true" >> $GITHUB_OUTPUT - echo "version=$NEW_VERSION" >> $GITHUB_OUTPUT + if git diff --quiet -- uv.lock; then + echo "No gemini-webapi version or Git revision updates are available" + echo "updated=false" >> "$GITHUB_OUTPUT" else - echo "No updates available for gemini-webapi (version $OLD_VERSION unchanged)" - echo "updated=false" >> $GITHUB_OUTPUT + echo "The gemini-webapi locked resolution has changed" + echo "updated=true" >> "$GITHUB_OUTPUT" + echo "resolution=$UPDATED_RESOLUTION" >> "$GITHUB_OUTPUT" fi - name: Create Pull Request @@ -65,13 +63,18 @@ jobs: uses: peter-evans/create-pull-request@5f6978faf089d4d20b00c7766989d076bb2fc7f1 # v8.1.1 with: token: ${{ secrets.GITHUB_TOKEN }} - commit-message: ":arrow_up: update gemini-webapi to ${{ steps.update.outputs.version }}" - title: ":arrow_up: update gemini-webapi to ${{ steps.update.outputs.version }}" + commit-message: ":arrow_up: update gemini-webapi dependency" + title: ":arrow_up: update gemini-webapi dependency" body: | - Update `gemini-webapi` to version `${{ steps.update.outputs.version }}`. + Refresh the locked `gemini-webapi` dependency. + + Resolved package: `${{ steps.update.outputs.resolution }}`. + + This tracks both compatible package releases and new commits on a configured Git branch. Auto-generated by GitHub Actions using `uv`. branch: update-gemini-webapi - base: main + base: ${{ github.event.repository.default_branch }} delete-branch: true labels: dependency, automated + add-paths: uv.lock diff --git a/Dockerfile b/Dockerfile index d66b58b..1f225fe 100644 --- a/Dockerfile +++ b/Dockerfile @@ -18,6 +18,7 @@ FROM python:3.13-slim-trixie AS runtime LABEL org.opencontainers.image.title="Gemini-FastAPI" \ org.opencontainers.image.description="Web-based Gemini models wrapped into an OpenAI-compatible API." +# tally ignore=hadolint/DL3002 USER root WORKDIR /app diff --git a/uv.lock b/uv.lock index f67438e..891d94c 100644 --- a/uv.lock +++ b/uv.lock @@ -620,11 +620,11 @@ wheels = [ [[package]] name = "python-dotenv" -version = "1.2.2" +version = "1.2.3" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/82/ed/0301aeeac3e5353ef3d94b6ec08bbcabd04a72018415dcb29e588514bba8/python_dotenv-1.2.2.tar.gz", hash = "sha256:2c371a91fbd7ba082c2c1dc1f8bf89ca22564a087c2c287cd9b662adde799cf3", size = 50135, upload-time = "2026-03-01T16:00:26.196Z" } +sdist = { url = "https://files.pythonhosted.org/packages/6a/53/ed9d74092561d4b01a2ef1349d52cdbc135e526c245f366b089cfca6de49/python_dotenv-1.2.3.tar.gz", hash = "sha256:a20a594dabeaa385725aa239d5244871c143ecb356add8a20fcf23773a6c3a35", size = 58945, upload-time = "2026-08-16T16:54:54.067Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101, upload-time = "2026-03-01T16:00:25.09Z" }, + { url = "https://files.pythonhosted.org/packages/0d/17/c5c6b53ddc18f297992099b3d9ec16c855c0ccc83263a21fe4d1c625ec6c/python_dotenv-1.2.3-py3-none-any.whl", hash = "sha256:904552145e8bfed22162c09dab1c2b9b54fefa7b23ba780f4f26ca0316b0f0d9", size = 22780, upload-time = "2026-08-16T16:54:52.473Z" }, ] [[package]] From 7eb7a74659202cdaba3bb955a64382bf8f726370 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C6=B0u=20Quang=20V=C5=A9?= Date: Mon, 17 Aug 2026 15:35:24 +0700 Subject: [PATCH 290/291] Update Troubleshooting Cookie Section --- README.md | 5 +++++ README.zh.md | 5 +++++ 2 files changed, 10 insertions(+) diff --git a/README.md b/README.md index e1cf33d..4286f14 100644 --- a/README.md +++ b/README.md @@ -253,6 +253,11 @@ when you update the cookie list. > [!WARNING] > Keep these credentials secure and never commit them to version control. These cookies provide access to your Google account. + + +> [!WARNING] +> **Session Stability**: If cookies expire frequently, use Firefox to extract cookies. Recent versions of Chromium-based browsers use "Device Bound Session Credentials", which improves security but causes cookies to remain valid for only a few hours and prevents them from being renewed. + To use Gemini-FastAPI, you need to extract your Gemini session cookies: 1. Open [Gemini](https://gemini.google.com/) in a private/incognito browser window and sign in diff --git a/README.zh.md b/README.zh.md index 7403564..5611644 100644 --- a/README.zh.md +++ b/README.zh.md @@ -244,6 +244,11 @@ Gemini API 的兼容性限制,也不代表 Gemini 网页端的容量。通过 > [!WARNING] > 请妥善保管这些凭据,切勿提交到版本控制。这些 Cookie 可访问你的 Google 账号。 + + +> [!WARNING] +> **会话稳定性**:如果 Cookie 频繁过期,请使用 Firefox 提取 Cookie。较新的 Chromium 内核浏览器版本使用“设备绑定会话凭据”(Device Bound Session Credentials),虽然提高了安全性,但会使 Cookie 仅在几小时内有效且无法续期。 + 使用 Gemini-FastAPI 需提取 Gemini 会话 Cookie: 1. 在无痕/隐私窗口打开 [Gemini](https://gemini.google.com/) 并登录 From 7d1744ed96bcb276fa3dfafda9523e003a7c79e7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C6=B0u=20Quang=20V=C5=A9?= Date: Wed, 19 Aug 2026 17:12:14 +0700 Subject: [PATCH 291/291] Update docstrings and dependencies --- Dockerfile | 2 +- app/models/gemini_models.py | 95 ++++++++++++--------------------- app/server/gemini.py | 10 ---- pyproject.toml | 2 +- tests/test_api_compatibility.py | 22 -------- tests/test_middleware.py | 2 - uv.lock | 74 ++++++++++++------------- 7 files changed, 74 insertions(+), 133 deletions(-) diff --git a/Dockerfile b/Dockerfile index 1f225fe..7d58c69 100644 --- a/Dockerfile +++ b/Dockerfile @@ -10,7 +10,7 @@ COPY pyproject.toml uv.lock ./ RUN --mount=type=cache,target=/root/.cache/uv < GeminiModelInfo: ) -# --------------------------------------------------------------------------- -# 路由端点 -# --------------------------------------------------------------------------- - - @router.get("/v1beta/models") async def gemini_list_models(api_key: str = Depends(verify_gemini_api_key)): """List available models (Gemini API format).""" diff --git a/pyproject.toml b/pyproject.toml index 946a6d9..09304c9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -15,7 +15,7 @@ dependencies = [ "orjson>=3.11.9", "pydantic-settings[yaml]>=2.15.0", "regex>=2026.7.19", - "uvicorn>=0.52.3", + "uvicorn>=0.52.4", "uvloop>=0.22.1; sys_platform != 'win32'", ] diff --git a/tests/test_api_compatibility.py b/tests/test_api_compatibility.py index cff1f03..4bde82c 100644 --- a/tests/test_api_compatibility.py +++ b/tests/test_api_compatibility.py @@ -76,9 +76,6 @@ def _generation_config(request: GeminiGenerateContentRequest) -> GeminiGeneratio return config -# --------------------------------------------------------------------------- response_format - - @pytest.mark.parametrize("format_type", ["text", "json_object"]) def test_non_json_schema_response_formats_are_accepted(format_type): """`text` is the API default and `json_object` is JSON mode; neither may 400.""" @@ -170,9 +167,6 @@ def test_non_strict_schema_omits_the_exact_conformance_line(): assert STRICT_SCHEMA_ADHERENCE_PROMPT not in requirement.instruction -# --------------------------------------------------------------------------- output enforcement - - def test_tool_call_turn_is_not_failed_by_a_response_format(): """The schema constrains the final answer, not a turn that asks for a tool.""" _, visible, _, tool_calls = process_llm_output( @@ -293,9 +287,6 @@ def test_bounded_validator_preserves_pattern_properties_and_additional_propertie assert canonicalize_structured_output('{"other": 1}', requirement) is None -# --------------------------------------------------------------------------- Responses text.format - - @pytest.mark.parametrize( ("format_payload", "expected_type"), [ @@ -435,9 +426,6 @@ def test_the_echoed_strict_reports_what_was_enforced(requested, applied): assert text_format.strict is applied -# --------------------------------------------------------------------------- Gemini generationConfig - - def test_openapi_response_schema_is_translated_not_rejected(): """`responseSchema` is the OpenAPI subset: uppercase types and `nullable`.""" request = _gemini_request( @@ -610,8 +598,6 @@ def test_file_data_in_system_instruction_is_refused(): assert "fileData is not supported" in (_validate_gemini_request(request) or "") -# --------------------------------------------------------------------------- Gemini toolConfig - _TOOLS = [ {"functionDeclarations": [{"name": "a", "description": "d"}, {"name": "b", "description": "d"}]} ] @@ -677,8 +663,6 @@ def test_no_tools_yields_no_tool_choice(): assert _gemini_tools_to_internal(None, None) == (None, None) -# --------------------------------------------------------------------------- forced tool_choice - _CALL = AppToolCall(id="1", type="function", function=AppToolCallFunction(name="a", arguments="{}")) _NAMED = ChatCompletionNamedToolChoice.model_validate( {"type": "function", "function": {"name": "a"}} @@ -741,9 +725,6 @@ def test_forced_tool_choice_must_name_a_declared_tool(names, has_image_tool, too assert expected in (result or "") -# --------------------------------------------------------------------------- Responses input - - def _input_message(*parts) -> ResponseInputMessage: return ResponseInputMessage.model_validate({"role": "user", "content": list(parts)}) @@ -797,9 +778,6 @@ def test_tool_result_parts_are_validated_too(output, expected): assert expected in (result or "") -# --------------------------------------------------------------------------- content digests - - def test_non_ascii_data_url_does_not_abort_model_construction(): item = AppContentItem(type="image_url", url="data:text/plain;charset=utf-8,Hé") assert item.content_digest diff --git a/tests/test_middleware.py b/tests/test_middleware.py index 3ca4983..a5488e3 100644 --- a/tests/test_middleware.py +++ b/tests/test_middleware.py @@ -130,8 +130,6 @@ async def ping(): assert client.get("/v1/ping").status_code == 200 -# --------------------------------------------------------------------------------- media serving - STEM = "img_" + "0" * 32 diff --git a/uv.lock b/uv.lock index 891d94c..67068d8 100644 --- a/uv.lock +++ b/uv.lock @@ -242,7 +242,7 @@ requires-dist = [ { name = "regex", specifier = ">=2026.7.19" }, { name = "ruff", marker = "extra == 'dev'" }, { name = "ty", marker = "extra == 'dev'" }, - { name = "uvicorn", specifier = ">=0.52.3" }, + { name = "uvicorn", specifier = ">=0.52.4" }, { name = "uvloop", marker = "sys_platform != 'win32'", specifier = ">=0.22.1" }, ] provides-extras = ["dev"] @@ -276,15 +276,15 @@ wheels = [ [[package]] name = "httpcore2" -version = "2.10.0" +version = "2.12.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "h11" }, { name = "truststore" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/a9/83/a896fc59940fc5a6e2aff3a4be1d92fa890112936803b331cae75a993c34/httpcore2-2.10.0.tar.gz", hash = "sha256:13c0cc3d1919d4f28457f60cd2c2abe04113a8af184ccf1142811beba936f9dc", size = 67427, upload-time = "2026-08-09T09:11:32.123Z" } +sdist = { url = "https://files.pythonhosted.org/packages/be/ad/f4f0e57345f1870f3e8cb624e058d7eca6e5a27d33bcc3311d9b618734cd/httpcore2-2.12.0.tar.gz", hash = "sha256:9293522bba0aa7c4c8e9e3f040c16575bd8868e155a77fa30c7a9085a5eae648", size = 67548, upload-time = "2026-08-18T13:22:08.211Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e5/4f/d149104195a35e2853a2fc203a8e3477747e58c80e17dda686dace174383/httpcore2-2.10.0-py3-none-any.whl", hash = "sha256:7df06cfb34070cae4f7c89be69dc1095eca138e9704ceffb98d25c1912ab6f01", size = 83000, upload-time = "2026-08-09T09:11:29.555Z" }, + { url = "https://files.pythonhosted.org/packages/d2/74/d370e55600d9bcfa0d9794b0166126d49291a3d2b20c268fc98c453a4948/httpcore2-2.12.0-py3-none-any.whl", hash = "sha256:7e04258ce01013d7d615e5b910a3b27fac937d7a95038227e79652b4ba3b4ceb", size = 83074, upload-time = "2026-08-18T13:22:05.854Z" }, ] [[package]] @@ -318,7 +318,7 @@ wheels = [ [[package]] name = "httpx2" -version = "2.10.0" +version = "2.12.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio", marker = "sys_platform != 'emscripten'" }, @@ -327,9 +327,9 @@ dependencies = [ { name = "idna" }, { name = "truststore", marker = "sys_platform != 'emscripten'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/bd/3d/f9a8c07a3884f3e5b26205e8436a18b3af61c5d53192c3bea235574dbbec/httpx2-2.10.0.tar.gz", hash = "sha256:8741d7329fe2c7885fc9ceb61c8217acfb87a85f75723714b89ebf7ad7196338", size = 98749, upload-time = "2026-08-09T09:11:33.24Z" } +sdist = { url = "https://files.pythonhosted.org/packages/7f/f8/579a8b51e42e38ee32647df9f08aa25643ae788e275cc625b199829c4671/httpx2-2.12.0.tar.gz", hash = "sha256:7631fe9887a8a2275f4a2540e053aa670fcc50742864a9ae7c66e609fdcf12cf", size = 100040, upload-time = "2026-08-18T13:22:09.086Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/b9/6d/a637d52449d98a6892d9a4dc0262587afdb6a66f201871842dce5a97b1c1/httpx2-2.10.0-py3-none-any.whl", hash = "sha256:5e3194a432701e1cc6f69a8b1b2fa199ef907013fede8d9a09a2c5b7b8141a18", size = 94355, upload-time = "2026-08-09T09:11:30.882Z" }, + { url = "https://files.pythonhosted.org/packages/c8/95/411ba65569158e862368917aaf56597f3e5fa3b91b0502919638465a08f3/httpx2-2.12.0-py3-none-any.whl", hash = "sha256:cc8b6eecb8661c146b8f89a60e97456ee086e91a784ed31ac450c3a9e613dd36", size = 95427, upload-time = "2026-08-18T13:22:06.834Z" }, ] [[package]] @@ -343,11 +343,11 @@ wheels = [ [[package]] name = "idna" -version = "3.18" +version = "3.19" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/cd/63/9496c57188a2ee585e0f1db071d75089a11e98aa86eb99d9d7618fc1edce/idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848", size = 196711, upload-time = "2026-06-02T14:34:07.794Z" } +sdist = { url = "https://files.pythonhosted.org/packages/5f/f7/abb373e5757eaec4b922b92f97ec8d6d7e057cf06778247604fbc4e7c3f3/idna-3.19.tar.gz", hash = "sha256:5e0811a4383b21dc5838069f801c4fb62113b7447663d2530d2bd6e77b49bf15", size = 215237, upload-time = "2026-08-18T05:14:24.27Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/1e/5e/d4e9f1a599fb8e573b7b87160658329fbf28d19eac2718f51fc3def3aa5a/idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2", size = 65455, upload-time = "2026-06-02T14:34:06.319Z" }, + { url = "https://files.pythonhosted.org/packages/57/b0/0e52c878c53f245edd3a11020f20979b3f490f245af532c7cae3027754b5/idna-3.19-py3-none-any.whl", hash = "sha256:815e7be7a7806d54abb586dc943addc79e8b2ee16915059658cbeff4b1b43bf4", size = 68550, upload-time = "2026-08-18T05:14:22.343Z" }, ] [[package]] @@ -582,11 +582,11 @@ yaml = [ [[package]] name = "pygments" -version = "2.20.0" +version = "2.21.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" } +sdist = { url = "https://files.pythonhosted.org/packages/49/2e/ced460408999b33da6b31b0021b0f37d329e202d4169aeb164493778f25b/pygments-2.21.0.tar.gz", hash = "sha256:610ca751c9bc2492b38eb9a38a7fbc93edbbb2d7182edaf34e66ae493dee5c8c", size = 5005329, upload-time = "2026-08-17T08:02:48.824Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, + { url = "https://files.pythonhosted.org/packages/71/46/17f022dd3e953bf20a04a028a21ec746d942f8d2af30fa0f124fa0e6a684/pygments-2.21.0-py3-none-any.whl", hash = "sha256:2363c69b61c4a97c838da3b130dcd6468f4848992b21a82f2a63ec34377137d9", size = 1250147, upload-time = "2026-08-17T08:02:44.912Z" }, ] [[package]] @@ -877,27 +877,27 @@ wheels = [ [[package]] name = "ty" -version = "0.0.72" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/d5/df/656e684bafb13c1d146e7d5b5f3e7978ca177232acc84998ff36427e9462/ty-0.0.72.tar.gz", hash = "sha256:ec2b8066b618df18cab4cb8e992f8da45d360332acb23fa34df7fa29cd1b9d3a", size = 6654939, upload-time = "2026-08-14T21:35:42.612Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/e2/3b/f51461239a4e66565d4b362f97a3b55fe7fdba2e944068341f87c62f6743/ty-0.0.72-py3-none-linux_armv6l.whl", hash = "sha256:fda86db153ffd85ee52000cf175d6a3f1c0223772cf7c5b6f726200bf92c7b44", size = 12621989, upload-time = "2026-08-14T21:35:01.676Z" }, - { url = "https://files.pythonhosted.org/packages/ca/fb/79ddf683affc679ca856f3510b5640ec3a88a842ba5f654f5d4bc78f1786/ty-0.0.72-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:ceb944c612529b9023acfdc9cf4c0dcbb722549f9d17d46baecd1141baf01d7f", size = 12233910, upload-time = "2026-08-14T21:35:04.334Z" }, - { url = "https://files.pythonhosted.org/packages/5d/45/10562a0d84802158db8fa4ec46de54aa9fdcecdeeaabbfe3639ae7042b66/ty-0.0.72-py3-none-macosx_11_0_arm64.whl", hash = "sha256:108d76218333d6c092e5f1cebf8e9b06f25738613a0236a28e2dd47c936ee52c", size = 12084108, upload-time = "2026-08-14T21:35:06.686Z" }, - { url = "https://files.pythonhosted.org/packages/a1/dc/1fe1aef8d697e3509face271a5331700c7aa1d1e44a4b622707bdfa41d4b/ty-0.0.72-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7f3943f186f741a2499a31053872169250c9264a9a49684920e48d8fcf4ef4f5", size = 12132640, upload-time = "2026-08-14T21:35:09.305Z" }, - { url = "https://files.pythonhosted.org/packages/14/46/41ceb265e96969487311a2014bd0e53abb4fbc1395efb2ebe411fcb4db62/ty-0.0.72-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:cf283c07dc3cc52ca48a3ad8ab100fb5aec3aebbd03ef6a12d5f910b8e596fc5", size = 12402489, upload-time = "2026-08-14T21:35:11.555Z" }, - { url = "https://files.pythonhosted.org/packages/2b/45/30bf43cb4fd505c5c2dd30fda27dde5f05208686cd21217adec77c954204/ty-0.0.72-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:95f3b6462c38f9f115d10cee21f47fedf715fcf2040daf36eef210359300bc7c", size = 13130835, upload-time = "2026-08-14T21:35:13.746Z" }, - { url = "https://files.pythonhosted.org/packages/31/2f/03bba754d2613f640df168335c41f83f41db150bb515839c60d80e3a7880/ty-0.0.72-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:30caf658feb8ffb250d9e9e47107657a78f5f3425c227df1664d8df2ebe38880", size = 13590392, upload-time = "2026-08-14T21:35:16.839Z" }, - { url = "https://files.pythonhosted.org/packages/04/c7/03c67f00e63005ec41585653dc3096064570b1e6273742baae2798cd242f/ty-0.0.72-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:27bdc012ddfbeec8948e4a6036c0dc39ac7cf2c8ec7c7d48dc7d2fd56d57b399", size = 13309629, upload-time = "2026-08-14T21:35:19.169Z" }, - { url = "https://files.pythonhosted.org/packages/c1/df/102d3b264eb7f2a58dd11952f229bb5150bb5668d176a6154976a6675981/ty-0.0.72-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:802c5970a77d7739e6f499921fbb6984fb7ad8a31d95e1ff42fd46f3642e4f3b", size = 12734028, upload-time = "2026-08-14T21:35:22.099Z" }, - { url = "https://files.pythonhosted.org/packages/61/85/d0737c8c54d0ba67366ddfb9f31d88edf0b02299e65923e6945ae60ebcb5/ty-0.0.72-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:47dce65114fdc615c68ca0edb393b433df0956447e4267df0e264137a789598d", size = 13174832, upload-time = "2026-08-14T21:35:24.71Z" }, - { url = "https://files.pythonhosted.org/packages/1e/31/497f5a96c36d9b586ab6afe0574986835c6fd5b835a89773d2bec4711b49/ty-0.0.72-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:325144fa07e2675d0faa337fcc864213c272a499eb0cfe5bde2fdc62282d27bc", size = 12215005, upload-time = "2026-08-14T21:35:26.892Z" }, - { url = "https://files.pythonhosted.org/packages/df/7d/46e65b17b4966c7cd0140f134380d33d8e84fe6efccd761533ce793dc502/ty-0.0.72-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:a5c9f15d0f58e43707d8848274be1821a0ef408eccb8aa7dda28a4a9eddf7640", size = 12421298, upload-time = "2026-08-14T21:35:29.301Z" }, - { url = "https://files.pythonhosted.org/packages/08/2a/12ada4ec17700b3cb1d4fd3bc3e5b1852df9e6885288429318cade87b3c1/ty-0.0.72-py3-none-musllinux_1_2_i686.whl", hash = "sha256:8ee508d64b381871529cc22c412b41071bf5e908b7aa5d66a38f3f6b2573a806", size = 12669242, upload-time = "2026-08-14T21:35:31.444Z" }, - { url = "https://files.pythonhosted.org/packages/1c/1a/4692536880790fb550ed6d44a6096778dc71bb112f2c6d615cebb01a57e5/ty-0.0.72-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:3699e2ec7921d44da79d6b089f7bf239b2cc53c4e45a5a38430adc34ee9e9a55", size = 12988199, upload-time = "2026-08-14T21:35:33.749Z" }, - { url = "https://files.pythonhosted.org/packages/9a/0d/f5e5a50322e9c45865e7b7a428ba6cd6527387cf0f2472492ac3cf746243/ty-0.0.72-py3-none-win32.whl", hash = "sha256:f25f72a67bd36cd247707c4784e52fad0b6b4f42a1b7dd14804110fa95c486ed", size = 11939708, upload-time = "2026-08-14T21:35:36.006Z" }, - { url = "https://files.pythonhosted.org/packages/3f/4e/8af3534b2e4214e6184a5a59c34101e94a68d578f081f97b995866bab1bf/ty-0.0.72-py3-none-win_amd64.whl", hash = "sha256:cdeee869341717e1736cea2e2d7856738c6957c320f584ed2f68c8f90100d2f5", size = 12643876, upload-time = "2026-08-14T21:35:38.141Z" }, - { url = "https://files.pythonhosted.org/packages/ff/ea/a2606e654c7276bd08586391a2525b0af3f3bf60228a8c57b2d248f273f9/ty-0.0.72-py3-none-win_arm64.whl", hash = "sha256:1bd3ac3ed4424a6d6990a85dc388556aea012bd752de21349a84b685951de0d8", size = 12394857, upload-time = "2026-08-14T21:35:40.277Z" }, +version = "0.0.73" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e5/90/c4e1bb4cead3b644c3e258a27f9b05c7dc5eb0ec96a4f5282194edae9e0d/ty-0.0.73.tar.gz", hash = "sha256:823d4ce0d237bfc7eb6bcee70842f2c0706113813a16951077840743712f4b74", size = 6712739, upload-time = "2026-08-19T03:12:43.381Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e4/0f/f5e1801e55cc631f2db193276675b30561b963a2403da832bffb5d100267/ty-0.0.73-py3-none-linux_armv6l.whl", hash = "sha256:90a946082bf9bc446b5e72973d9f4ff1222a240b2ca4c9e6eed61eb913e30810", size = 12715452, upload-time = "2026-08-19T03:12:06.673Z" }, + { url = "https://files.pythonhosted.org/packages/54/32/515dd05074c213b433524ab97eb003b0132ae7e358e0d75633ba7a314ed8/ty-0.0.73-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:b7d6b5c6a6db7ea95fbbc16af514ef44a27a29a2fe1dc798900790364d170209", size = 12301870, upload-time = "2026-08-19T03:12:08.924Z" }, + { url = "https://files.pythonhosted.org/packages/50/4d/085b4889f0d4bbe4af8b96242d4a1cb209fff95967cfa239ea141983719b/ty-0.0.73-py3-none-macosx_11_0_arm64.whl", hash = "sha256:dd6f657f463e01372d8688f235be164750c8db722c97da27fa4903aa8d40b203", size = 12111741, upload-time = "2026-08-19T03:12:11.067Z" }, + { url = "https://files.pythonhosted.org/packages/95/f6/d6ec277cadfecf03ad4c18551b67c4c6eb7807a0560d801db14be99d7a89/ty-0.0.73-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fc2de468e33fd44c9ff1c43473a7316f4289480f5cba8995a67b6d22aee39ca9", size = 12196124, upload-time = "2026-08-19T03:12:13.14Z" }, + { url = "https://files.pythonhosted.org/packages/75/b7/ce78d8707563af9cae9bbd25328bfbc4931035085bd20089adf0c418f70e/ty-0.0.73-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2942fa0ef795a66034cdc8d75a72f453442f3b58ff2f69b4da05b7b954765b55", size = 12488557, upload-time = "2026-08-19T03:12:15.252Z" }, + { url = "https://files.pythonhosted.org/packages/d8/e8/329b9851b23502758c5c98e8cc875ea2a1b4c9674b4ca3a86da56a5063d3/ty-0.0.73-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1e0f1ef14f642e18ac4e7a616a2796dcf7a5d82e28cd17f9796494acc7c4aabb", size = 13215606, upload-time = "2026-08-19T03:12:17.225Z" }, + { url = "https://files.pythonhosted.org/packages/36/38/67fedfd2cb77516ef0066b1642f487dba0eb3006493cf3475b15f5b8b228/ty-0.0.73-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:16981e15fdceedb37d0aff76c5ac25914595dfee2675af95335550064251ad22", size = 13665497, upload-time = "2026-08-19T03:12:19.286Z" }, + { url = "https://files.pythonhosted.org/packages/8e/b3/154f4dd48ec5eebc186ab4b822c6e62f982fc5ddfd262d6e3903c2acba44/ty-0.0.73-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:644b2bec8a2e2e4957a942ae81d6cff5571c489bb5a8675e4d3886de537a694d", size = 13351231, upload-time = "2026-08-19T03:12:21.353Z" }, + { url = "https://files.pythonhosted.org/packages/35/5f/d462496903fbe453fb76363f8478be929c8e6ff21e6928c57dcd7e5fa21f/ty-0.0.73-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:338d565be3186f50ff8e9d10483685549c2d23f0754485d5ede3b54f4319188a", size = 12782586, upload-time = "2026-08-19T03:12:23.667Z" }, + { url = "https://files.pythonhosted.org/packages/87/52/ec6d24b74abe3ec324204c1c71e6d0c6c76a17ffc15fd51d603b0a302abe/ty-0.0.73-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:11c7b6d839309d2c102cb3a4c03d817176bbfab5b2fccc95a75ec5c9597421c9", size = 13247134, upload-time = "2026-08-19T03:12:25.956Z" }, + { url = "https://files.pythonhosted.org/packages/26/20/cc74650fec56a54786c6d7c89e09576fcad3092be34cf21715d39a406a9b/ty-0.0.73-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:488572db7ff97fb50ea36a76250f2d617c9727d143da6c7bf0623276eb0fc507", size = 12309344, upload-time = "2026-08-19T03:12:28.122Z" }, + { url = "https://files.pythonhosted.org/packages/89/bd/4b0a9087f4315d7fbadf77a3ce44c816cc9ffabed1ced06cc5be81fbc414/ty-0.0.73-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:1b958ebceefbbf594e59eb8d3d55bbd033ce634026fcba3e4bc3179e78e45bb7", size = 12502319, upload-time = "2026-08-19T03:12:30.128Z" }, + { url = "https://files.pythonhosted.org/packages/11/80/0a925074911fe111912ea29d9eed309bcc183f43d2fb3eef07db056a0beb/ty-0.0.73-py3-none-musllinux_1_2_i686.whl", hash = "sha256:91a32993b3c34e42c3f323ad6c0399cb596bd1c27e9b7f20db7cd64c1067b68e", size = 12753688, upload-time = "2026-08-19T03:12:32.433Z" }, + { url = "https://files.pythonhosted.org/packages/24/6b/aeccaf89efbc2e112bd415340a22e2669ec998aa397242503e747b712ca4/ty-0.0.73-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:bab8a19fbf51f479bddb2a12c5fabfe52f918a5590362321ed5d89b44eb62c15", size = 13069050, upload-time = "2026-08-19T03:12:35.398Z" }, + { url = "https://files.pythonhosted.org/packages/d7/3e/eae485fd86c1585943fd4e1746b0757b2da01e2c43136ebe8c686fe1c7f1/ty-0.0.73-py3-none-win32.whl", hash = "sha256:03347a612f0fa020b19bfd8dbd521db6ecc75d377a3e4d4f6e6c2e62871da4cc", size = 12053187, upload-time = "2026-08-19T03:12:37.565Z" }, + { url = "https://files.pythonhosted.org/packages/a7/01/9b8b983786e3ce34924e372e8b76b92b508273ab65c589fc7e88cc03ee17/ty-0.0.73-py3-none-win_amd64.whl", hash = "sha256:cedd05122ded0b5dcc55431a370e974b747f99c41c290a3d2ab8c1867f197519", size = 12693838, upload-time = "2026-08-19T03:12:39.483Z" }, + { url = "https://files.pythonhosted.org/packages/ea/88/25333bbfea6a5dc064371d2002d3d4807db90b84d5448f9106b2712b0fbc/ty-0.0.73-py3-none-win_arm64.whl", hash = "sha256:e47068f8369dea5d641a26a2ad0a947a320b02ff87099b07e95de0323245a4dc", size = 12443573, upload-time = "2026-08-19T03:12:41.449Z" }, ] [[package]] @@ -923,15 +923,15 @@ wheels = [ [[package]] name = "uvicorn" -version = "0.52.3" +version = "0.52.4" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "click" }, { name = "h11" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/2e/28/64ca011edf31c715b4fad359c587ea52391aaffa125065695590241ff617/uvicorn-0.52.3.tar.gz", hash = "sha256:18857b9e6579300be55c91c0a1cfd37d9a2cf0cabea33b88275f199eb73b8b58", size = 100621, upload-time = "2026-08-13T16:50:02.899Z" } +sdist = { url = "https://files.pythonhosted.org/packages/f2/0f/3f86e61397dd33bf2ccf28188c40db6a740658aeebbbf6e7dbc101a1f487/uvicorn-0.52.4.tar.gz", hash = "sha256:73acfee47a0b133c5de13d219492d62d8a31e935f4fe6e41a232451a15379f86", size = 100627, upload-time = "2026-08-19T06:27:41.821Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/dc/2b/ebd108734a8204c6b4b93c681c9a38c5273b3ccd5d129fee4ffc1d97772c/uvicorn-0.52.3-py3-none-any.whl", hash = "sha256:116af2710dbf47c80f463cd20ee4884b6662f4c9f227d797ddc7279d2fcc2c7c", size = 79859, upload-time = "2026-08-13T16:50:01.323Z" }, + { url = "https://files.pythonhosted.org/packages/f1/79/4a20b54ab0491485ccd8c077db2d39187c7f12b3e15485d38a7be37c81b4/uvicorn-0.52.4-py3-none-any.whl", hash = "sha256:f86e41a149d7d05a9969337e3946a9c171c06a5d42680896daaba624aeac8da1", size = 79871, upload-time = "2026-08-19T06:27:40.36Z" }, ] [[package]]