diff --git a/.github/workflows/docker.yaml b/.github/workflows/docker.yaml
index 21aa3fd..da8135d 100644
--- a/.github/workflows/docker.yaml
+++ b/.github/workflows/docker.yaml
@@ -6,11 +6,15 @@ on:
- main
tags:
- "v*"
- paths-ignore:
- - "**/*.md"
- - ".github/*"
- - "LICENSE"
- - ".gitignore"
+ paths:
+ - "app/**"
+ - "config/**"
+ - "pyproject.toml"
+ - "uv.lock"
+ - "Dockerfile"
+ - "run.py"
+ - ".github/workflows/docker.yaml"
+ workflow_dispatch:
env:
REGISTRY: ghcr.io
@@ -25,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 }}
@@ -42,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: |
@@ -54,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
new file mode 100644
index 0000000..dc799c9
--- /dev/null
+++ b/.github/workflows/lint.yaml
@@ -0,0 +1,41 @@
+name: Lint and Type Check
+
+on:
+ push:
+ paths:
+ - "**.py"
+ - "pyproject.toml"
+ - "uv.lock"
+ - ".github/workflows/lint.yaml"
+ pull_request:
+ paths:
+ - "**.py"
+ - "pyproject.toml"
+ - "uv.lock"
+ - ".github/workflows/lint.yaml"
+
+permissions:
+ contents: read
+
+jobs:
+ lint:
+ runs-on: ubuntu-latest
+
+ steps:
+ - name: Checkout repository
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
+
+ - name: Install uv
+ uses: astral-sh/setup-uv@cec208311dfd045dd5311c1add060b2062131d57 # v8.0.0
+
+ - name: Install dependencies
+ run: uv sync --all-groups
+
+ - 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/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..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:
@@ -11,13 +11,13 @@ jobs:
permissions:
contents: write
pull-requests: write
+
steps:
- - uses: actions/checkout@v6
+ - name: Checkout repository
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- name: Install uv
- uses: astral-sh/setup-uv@v7
- with:
- version: "latest"
+ uses: astral-sh/setup-uv@cec208311dfd045dd5311c1add060b2062131d57 # v8.0.0
- name: Update gemini-webapi
id: update
@@ -33,8 +33,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}')
@@ -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 }}"
diff --git a/Dockerfile b/Dockerfile
index 62ce9d1..068aa8c 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 \
+ tini curl ca-certificates \
&& 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"]
diff --git a/README.md b/README.md
index 6b6f485..9bc463e 100644
--- a/README.md
+++ b/README.md
@@ -1,22 +1,22 @@
# Gemini-FastAPI
[](https://www.python.org/downloads/)
-[](https://fastapi.tiangolo.com/)
+[](https://fastapi.tiangolo.com/)
[](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
@@ -180,6 +180,7 @@ 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 conversation storage size limit
export CONFIG_STORAGE__MAX_SIZE=268435456 # 256 MB
```
@@ -204,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 d012d32..fb85a48 100644
--- a/README.zh.md
+++ b/README.zh.md
@@ -1,22 +1,22 @@
# Gemini-FastAPI
[](https://www.python.org/downloads/)
-[](https://fastapi.tiangolo.com/)
+[](https://fastapi.tiangolo.com/)
[](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
### 安装
@@ -56,7 +56,7 @@ gemini:
- 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)
+ proxy: null # 可选代理 URL (null/空值则保持直连)
```
> [!NOTE]
@@ -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 部署
@@ -180,6 +180,7 @@ export CONFIG_GEMINI__CLIENTS__0__SECURE_1PSIDTS="your-secure-1psidts"
# 覆盖 Client 0 的代理设置
export CONFIG_GEMINI__CLIENTS__0__PROXY="socks5://127.0.0.1:1080"
+
# 覆盖对话存储大小限制
export CONFIG_STORAGE__MAX_SIZE=268435456 # 256 MB
```
@@ -203,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/main.py b/app/main.py
index 20d15b0..47e3956 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.")
@@ -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:
@@ -99,6 +104,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/models/__init__.py b/app/models/__init__.py
index 7f95131..6fa671f 100644
--- a/app/models/__init__.py
+++ b/app/models/__init__.py
@@ -1,69 +1,4 @@
-from .models import (
- ChatCompletionRequest,
- ChatCompletionResponse,
- Choice,
- ContentItem,
- ConversationInStore,
- FunctionCall,
- HealthCheckResponse,
- Message,
- ModelData,
- ModelListResponse,
- ResponseCreateRequest,
- ResponseCreateResponse,
- ResponseImageGenerationCall,
- ResponseImageTool,
- ResponseInputContent,
- ResponseInputItem,
- ResponseOutputContent,
- ResponseOutputMessage,
- ResponseReasoning,
- ResponseReasoningContentPart,
- ResponseSummaryPart,
- ResponseTextConfig,
- ResponseTextFormat,
- ResponseToolCall,
- ResponseToolChoice,
- ResponseUsage,
- Tool,
- ToolCall,
- ToolChoiceFunction,
- ToolChoiceFunctionDetail,
- ToolFunctionDefinition,
- Usage,
-)
+# ruff: noqa: F403
-__all__ = [
- "ChatCompletionRequest",
- "ChatCompletionResponse",
- "Choice",
- "ContentItem",
- "ConversationInStore",
- "FunctionCall",
- "HealthCheckResponse",
- "Message",
- "ModelData",
- "ModelListResponse",
- "ResponseCreateRequest",
- "ResponseCreateResponse",
- "ResponseImageGenerationCall",
- "ResponseImageTool",
- "ResponseInputContent",
- "ResponseInputItem",
- "ResponseOutputContent",
- "ResponseOutputMessage",
- "ResponseReasoning",
- "ResponseReasoningContentPart",
- "ResponseSummaryPart",
- "ResponseTextConfig",
- "ResponseTextFormat",
- "ResponseToolCall",
- "ResponseToolChoice",
- "ResponseUsage",
- "Tool",
- "ToolCall",
- "ToolChoiceFunction",
- "ToolChoiceFunctionDetail",
- "ToolFunctionDefinition",
- "Usage",
-]
+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 fccfba1..71462b2 100644
--- a/app/models/models.py
+++ b/app/models/models.py
@@ -1,97 +1,103 @@
from __future__ import annotations
-from datetime import datetime
from typing import Any, Literal
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
-
-
-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
+ChatCompletionContentItem = ChatCompletionRequestContentItem | ChatCompletionAssistantContentItem
-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."""
- type: Literal["function"]
- function: ToolFunctionDefinition
+class ChatCompletionFunctionTool(BaseModel):
+ """A function tool for the Chat Completions API."""
+ type: Literal["function"]
+ 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 +106,157 @@ 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"] | None = Field(default="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"] | 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)
-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"] | 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)
+ 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."""
+
+ type: Literal["function_call"] | None = Field(default="function_call")
+ id: str | None = Field(default=None)
+ 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 ResponseToolChoice(BaseModel):
- """Tool choice enforcing a specific tool in Responses API."""
+class FunctionCallOutput(BaseModel):
+ """A tool-result item providing function output back to the model."""
- type: Literal["function", "image_generation"]
- function: ToolChoiceFunctionDetail | None = Field(default=None)
+ type: Literal["function_call_output"] | None = Field(default="function_call_output")
+ id: str | None = Field(default=None)
+ 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 ResponseImageTool(BaseModel):
- """Image generation tool specification for Responses API."""
+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)."""
+
+ type: Literal["function"]
+ name: str
- 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)
+
+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,54 +265,76 @@ 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="")
+ 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)
+class ResponseOutputRefusal(BaseModel):
+ """Refusal content part inside a Responses API output message."""
+
+ type: Literal["refusal"] | None = Field(default="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"]
+ 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[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
+ type: Literal["summary_text"] | None = Field(default="summary_text")
+ text: str | None = Field(default=None)
-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
+ type: Literal["reasoning_text"] | None = Field(default="reasoning_text")
+ text: str | None = Field(default=None)
-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")
+ 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[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 ResponseImageGenerationCall(BaseModel):
- """Image generation call record emitted in Responses API."""
+class ResponseToolCall(BaseModel):
+ """A function-call output item emitted by the model."""
- id: str
- type: Literal["image_generation_call"] = Field(default="image_generation_call")
+ 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 | 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)
@@ -287,31 +342,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 +410,49 @@ 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)
+
+
+ChatCompletionMessage.model_rebuild()
+ChatCompletionMessageToolCall.model_rebuild()
ChatCompletionRequest.model_rebuild()
diff --git a/app/server/chat.py b/app/server/chat.py
index 8d8be91..8b3094d 100644
--- a/app/server/chat.py
+++ b/app/server/chat.py
@@ -1,3 +1,4 @@
+import asyncio
import base64
import hashlib
import io
@@ -7,7 +8,7 @@
from dataclasses import dataclass
from datetime import UTC, datetime
from pathlib import Path
-from typing import Any
+from typing import Any, Literal, cast
import orjson
from fastapi import APIRouter, Depends, HTTPException, Request, status
@@ -16,39 +17,47 @@
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 (
+ AppContentItem,
+ AppMessage,
+ AppToolCall,
+ AppToolCallFunction,
+ ChatCompletionChoice,
+ ChatCompletionFunctionTool,
+ ChatCompletionMessage,
+ ChatCompletionMessageToolCall,
+ ChatCompletionNamedToolChoice,
ChatCompletionRequest,
ChatCompletionResponse,
- Choice,
- ContentItem,
- ConversationInStore,
- Message,
+ CompletionUsage,
+ 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,
- get_image_token,
+ get_media_store_dir,
+ get_media_token,
get_temp_dir,
verify_api_key,
)
@@ -63,13 +72,12 @@
estimate_tokens,
extract_image_dimensions,
extract_tool_calls,
- remove_tool_call_blocks,
+ normalize_llm_text,
strip_system_hints,
text_from_message,
)
MAX_CHARS_PER_REQUEST = int(g_config.gemini.max_chars_per_request * 0.9)
-METADATA_TTL_MINUTES = 15
router = APIRouter()
@@ -123,10 +131,65 @@ 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))
+ if not saved_paths:
+ logger.warning("No files saved from media object.")
+ return {}
+ except Exception as e:
+ logger.error(f"Failed to save media: {e}")
+ 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
+
+ if spath in path_map:
+ results[mtype] = path_map[spath]
+ 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")
+
+ 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)
+ path_map[spath] = (random_name, fhash)
+ except Exception as e:
+ logger.warning(f"Error processing {mtype} at {spath}: {e}")
+
+ return results
+
+
def _calculate_usage(
- messages: list[Message],
+ 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."""
@@ -134,10 +197,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:
@@ -161,48 +221,51 @@ def _create_responses_standard_payload(
response_id: str,
created_time: int,
model_name: str,
- detected_tool_calls: list[Any] | None,
- image_call_items: list[ResponseImageGenerationCall],
+ detected_tool_calls: list[AppToolCall] | None,
+ image_call_items: list[ImageGenerationCall],
response_contents: list[ResponseOutputContent],
usage: ResponseUsage,
request: ResponseCreateRequest,
- normalized_input: Any,
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] = []
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)],
)
)
- 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(
[
- ResponseToolCall(
- id=call.id if hasattr(call, "id") else call["id"],
- type="tool_call",
+ ResponseFunctionToolCall(
+ id=call.id,
+ call_id=call.id,
+ name=call.function.name,
+ arguments=call.function.arguments,
status="completed",
- function=call.function if hasattr(call, "function") else call["function"],
)
for call in detected_tool_calls
]
@@ -212,7 +275,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 +286,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,
)
@@ -236,21 +298,27 @@ def _create_chat_completion_standard_payload(
created_time: int,
model_name: str,
visible_output: str | None,
- tool_calls_payload: list[dict] | None,
- finish_reason: str,
+ 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 = [ToolCall.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 = Message(
+ message = ChatCompletionMessage(
role="assistant",
content=visible_output or None,
- tool_calls=tool_calls,
+ tool_calls=tc_converted,
reasoning_content=reasoning_content or None,
)
@@ -260,13 +328,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),
)
@@ -274,7 +342,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)
@@ -287,9 +355,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:
@@ -308,36 +374,105 @@ 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,
+ type="function",
+ function=AppToolCallFunction(
+ name=tc.function.name,
+ arguments=tc.function.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[Message],
+ messages: list[AppMessage],
storage_output: str | None,
- tool_calls: list[Any] | None,
- thoughts: str | None = None,
+ tool_calls: list[AppToolCall] | None,
) -> str | None:
"""Unified logic to save conversation history to LMDB."""
try:
- current_assistant_message = Message(
+ 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
@@ -397,8 +532,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 +570,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 +582,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 +608,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[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:
@@ -482,24 +623,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(AppContentItem(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[AppMessage],
+ 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[AppMessage]:
"""Return a copy of messages enriched with tool instructions when needed."""
prepared = [msg.model_copy(deep=True) for msg in source_messages]
@@ -541,7 +686,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, 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)
@@ -549,141 +694,210 @@ def _prepare_messages_for_model(
return prepared
-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] = []
+def _convert_responses_to_app_messages(
+ items: Any,
+) -> list[AppMessage]:
+ """Convert Responses API input items into internal AppMessage objects."""
+ messages: list[AppMessage] = []
if isinstance(items, str):
- messages.append(Message(role="user", content=items))
+ messages.append(AppMessage(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",
- )
- )
- converted.append(
- ContentItem(
- type="image_url",
- image_url={
- "url": image_url,
- "detail": part.detail if part.detail else "auto",
- },
+ 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)
+
+ content = item.content
+ if isinstance(content, str):
+ messages.append(AppMessage(role=role, content=content))
+ else:
+ 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(AppContentItem(type="text", text=text_value))
+ 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(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:
+ converted.append(
+ 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(
+ AppMessage(
+ role=role,
+ content=converted or None,
+ reasoning_content=reasoning_val,
+ )
+ )
+
+ elif isinstance(item, ResponseFunctionToolCall):
+ messages.append(
+ AppMessage(
+ role="assistant",
+ tool_calls=[
+ AppToolCall(
+ id=item.call_id,
+ type="function",
+ function=AppToolCallFunction(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(
+ AppMessage(
+ 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(
+ AppMessage(
+ role="assistant",
+ reasoning_content=reasoning_val,
+ )
+ )
+ elif isinstance(item, ImageGenerationCall):
+ messages.append(
+ AppMessage(
+ 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"):
+ 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(
+ AppMessage(
+ role=role,
+ content=str(getattr(item, "content", "")),
+ )
+ )
+ compacted_messages: list[AppMessage] = []
+ for msg in messages:
+ if not compacted_messages:
+ compacted_messages.append(msg)
+ continue
-def _instructions_to_messages(
- instructions: str | list[ResponseInputItem] | None,
-) -> list[Message]:
- """Normalize instructions payload into Message objects."""
+ 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 _convert_instructions_to_app_messages(
+ instructions: str | list[ResponseInputMessage] | None,
+) -> list[AppMessage]:
+ """Normalize instructions payload into AppMessage objects."""
if not instructions:
return []
if isinstance(instructions, str):
- return [Message(role="system", content=instructions)]
+ return [AppMessage(role="system", content=instructions)]
- instruction_messages: list[Message] = []
- 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(Message(role=role, content=content))
+ instruction_messages.append(AppMessage(role=role, content=content))
else:
- converted: list[ContentItem] = []
- reasoning_parts: list[str] = []
+ converted: list[AppContentItem] = []
for part in content:
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 ""
+ 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 = part.image_url
+ image_url = getattr(part, "image_url", None)
if image_url:
+ 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:
converted.append(
- ContentItem(
- type="image_url",
- image_url={
- "url": image_url,
- "detail": part.detail if part.detail else "auto",
- },
+ AppContentItem(
+ type="file",
+ url=file_url,
+ file_data=file_data,
+ filename=getattr(part, "filename", None),
)
)
- 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,
- reasoning_content="\n".join(reasoning_parts) if reasoning_parts else None,
- )
- )
+ instruction_messages.append(AppMessage(role=role, content=converted or None))
return instruction_messages
@@ -702,38 +916,42 @@ 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 = []
+ 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,
- created=now,
- 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
-
+ 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_name,
+ id=model.model_name,
created=now,
- owned_by="gemini-web",
+ owned_by="custom",
)
)
+ seen_model_ids.add(model.model_name)
+
+ if strategy == "append":
+ for client in pool.clients:
+ if not client.running():
+ continue
+
+ client_models = client.list_models()
+ if client_models:
+ 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=model_id,
+ created=now,
+ owned_by="google",
+ )
+ )
+ seen_model_ids.add(model_id)
return models_data
@@ -742,8 +960,8 @@ async def _find_reusable_session(
db: LMDBConversationStore,
pool: GeminiClientPool,
model: Model,
- messages: list[Message],
-) -> tuple[ChatSession | None, GeminiClientWrapper | None, list[Message]]:
+ 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
@@ -754,24 +972,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}"
@@ -786,7 +993,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[Any] | None = None,
stream: bool = False,
) -> AsyncGenerator[ModelOutput] | ModelOutput:
"""Send text to Gemini, splitting or converting to attachment if too long."""
@@ -796,7 +1003,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(
@@ -805,8 +1012,8 @@ 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.append(file_obj)
+ final_files: list[Any] = list(files) if files else []
+ 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"
@@ -818,7 +1025,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
@@ -839,6 +1046,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:
@@ -856,15 +1065,26 @@ 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)
- 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()
@@ -874,7 +1094,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")
@@ -886,7 +1106,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:]
@@ -908,15 +1134,38 @@ 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 ---
def _create_real_streaming_response(
- generator: AsyncGenerator[ModelOutput],
+ resp_or_stream: AsyncGenerator[ModelOutput] | ModelOutput,
completion_id: str,
created_time: int,
model_name: str,
- messages: list[Message],
+ messages: list[AppMessage],
db: LMDBConversationStore,
model: Model,
client_wrapper: GeminiClientWrapper,
@@ -930,162 +1179,215 @@ 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
+
+ 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", "content": ""}, "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}
+ )
+
+ 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 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.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.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):
+ 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):
+ 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():
- 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(
- full_thoughts, full_text, structured_requirement
+ _, _, storage_output, detected_tool_calls = _process_llm_output(
+ normalize_llm_text(full_thoughts or ""),
+ normalize_llm_text(full_text or ""),
+ structured_requirement,
)
- 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)
-
- image_markdown = ""
- seen_hashes = set()
- for image in images:
- try:
- image_store = get_image_store_dir()
- _, _, _, fname, fhash = await _image_to_base64(image, image_store)
- if fhash in seen_hashes:
- (image_store / fname).unlink(missing_ok=True)
+ 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)
+
+ if not done:
+ yield ": ping\n\n"
continue
- seen_hashes.add(fhash)
- img_url = f"})"
- image_markdown += f"\n\n{img_url}"
- except Exception as exc:
- logger.warning(f"Failed to process image in OpenAI stream: {exc}")
+ for task in done:
+ res = task.result()
+ if not res:
+ continue
- 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"
+ 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""
+ 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
- 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"
+ 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"[]({video_url})"
+ if current_thumb
+ else f"[{title}]({video_url})"
+ )
+ if audio_url:
+ md_parts.append(
+ f"[]({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},
+ }
+
+ yield make_chunk(
+ {
+ "delta": {
+ "tool_calls": [tc_dict],
+ },
+ "finish_reason": None,
+ }
+ )
p_tok, c_tok, t_tok, r_tok = _calculate_usage(
- messages, assistant_text, tool_calls, full_thoughts
+ messages, storage_output, detected_tool_calls, full_thoughts
)
- usage = Usage(
+ usage = CompletionUsage(
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",
- "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,
@@ -1093,27 +1395,31 @@ 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],
+ resp_or_stream: AsyncGenerator[ModelOutput] | ModelOutput,
response_id: str,
created_time: int,
model_name: str,
- messages: list[Message],
+ messages: list[AppMessage],
db: LMDBConversationStore,
model: Model,
client_wrapper: GeminiClientWrapper,
session: ChatSession,
request: ResponseCreateRequest,
- image_store: Path,
base_url: str,
structured_requirement: StructuredOutputRequirement | None = None,
) -> StreamingResponse:
@@ -1157,7 +1463,6 @@ def make_event(etype: str, data: dict) -> str:
},
},
)
-
yield make_event(
"response.in_progress",
{
@@ -1175,29 +1480,47 @@ 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:
+ 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)
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,
- "item": ResponseReasoning(
+ "output_index": thought_index,
+ "item": ResponseReasoningItem(
id=thought_item_id,
type="reasoning",
status="in_progress",
@@ -1212,9 +1535,9 @@ def make_event(etype: str, data: dict) -> str:
**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": ResponseSummaryPart(text="").model_dump(mode="json"),
+ "part": SummaryTextContent(text="").model_dump(mode="json"),
},
)
thought_open = True
@@ -1226,7 +1549,7 @@ def make_event(etype: str, data: dict) -> str:
**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,
},
@@ -1240,7 +1563,7 @@ def make_event(etype: str, data: dict) -> str:
**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,
},
@@ -1251,9 +1574,9 @@ def make_event(etype: str, data: dict) -> str:
**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": ResponseSummaryPart(text=full_thoughts).model_dump(
+ "part": SummaryTextContent(text=full_thoughts).model_dump(
mode="json"
),
},
@@ -1263,25 +1586,26 @@ def make_event(etype: str, data: dict) -> str:
{
**base_event,
"type": "response.output_item.done",
- "output_index": current_index,
- "item": ResponseReasoning(
+ "output_index": thought_index,
+ "item": ResponseReasoningItem(
id=thought_item_id,
type="reasoning",
status="completed",
- summary=[ResponseSummaryPart(text=full_thoughts)],
+ summary=[SummaryTextContent(text=full_thoughts)],
).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",
@@ -1298,11 +1622,11 @@ def make_event(etype: str, data: dict) -> str:
**base_event,
"type": "response.content_part.added",
"item_id": message_item_id,
- "output_index": current_index,
+ "output_index": message_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
@@ -1315,27 +1639,139 @@ def make_event(etype: str, data: dict) -> str:
**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": [],
},
)
- except Exception:
- logger.exception("Responses streaming error")
+ 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": "Streaming error."}},
+ {
+ **base_event,
+ "type": "error",
+ "error": {"message": f"Streaming error occurred: {e}"},
+ },
)
return
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):
+ 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,
+ },
+ )
+
+ 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 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": [],
+ },
+ )
remaining = suppressor.flush()
if remaining and message_open:
@@ -1345,7 +1781,7 @@ def make_event(etype: str, data: dict) -> str:
**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": [],
@@ -1359,7 +1795,7 @@ def make_event(etype: str, data: dict) -> str:
**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,
},
@@ -1370,9 +1806,9 @@ def make_event(etype: str, data: dict) -> str:
**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": ResponseSummaryPart(text=full_thoughts).model_dump(mode="json"),
+ "part": SummaryTextContent(text=full_thoughts).model_dump(mode="json"),
},
)
yield make_event(
@@ -1380,126 +1816,299 @@ def make_event(etype: str, data: dict) -> str:
{
**base_event,
"type": "response.output_item.done",
- "output_index": current_index,
- "item": ResponseReasoning(
+ "output_index": thought_index,
+ "item": ResponseReasoningItem(
id=thought_item_id,
type="reasoning",
status="completed",
- summary=[ResponseSummaryPart(text=full_thoughts)],
+ summary=[SummaryTextContent(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
+ _, 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 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"),
- },
+ 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..."
)
- current_index += 1
+ while media_tasks:
+ done, pending = await asyncio.wait(
+ media_tasks, timeout=5.0, return_when=asyncio.FIRST_COMPLETED
+ )
+ media_tasks = list(pending)
- image_items: list[ResponseImageGenerationCall] = []
- final_response_contents: list[ResponseOutputContent] = []
- seen_hashes = set()
+ if not done:
+ yield ": ping\n\n"
+ continue
- 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)
+ 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:
- 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]
fmt = parts[1] if len(parts) > 1 else "png"
- img_item = ResponseImageGenerationCall(
+ 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"})"
- )
- final_response_contents.append(
- ResponseOutputContent(type="output_text", text=image_url)
+ img_link = (
+ f"})"
)
+ 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": current_index,
+ "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": current_index,
+ "output_index": img_index,
"item": img_item.model_dump(mode="json"),
},
)
- current_index += 1
+
+ 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)
- storage_output += f"\n\n{image_url}"
- except Exception:
- logger.warning("Image processing failed in stream")
+
+ 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"[]({video_url})"
+ if current_thumb
+ else f"[{title}]({video_url})"
+ )
+ if audio_url:
+ md_parts.append(
+ f"[]({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": 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": 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": message_index,
+ "item": ResponseOutputMessage(
+ id=message_item_id,
+ type="message",
+ status="completed",
+ role="assistant",
+ content=final_response_contents,
+ ).model_dump(mode="json"),
+ },
+ )
for call in detected_tool_calls:
- tc_item = ResponseToolCall(id=call.id, status="completed", function=call.function)
+ 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": current_index,
+ "output_index": tc_index,
"item": tc_item.model_dump(mode="json"),
},
)
@@ -1508,19 +2117,13 @@ def make_event(etype: str, data: dict) -> str:
{
**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, 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
+ messages, storage_output, detected_tool_calls, full_thoughts
)
usage = ResponseUsage(
input_tokens=p_tok,
@@ -1537,8 +2140,9 @@ def make_event(etype: str, data: dict) -> str:
final_response_contents,
usage,
request,
- None,
full_thoughts,
+ message_item_id,
+ thought_item_id,
)
_persist_conversation(
db,
@@ -1548,7 +2152,6 @@ def make_event(etype: str, data: dict) -> str:
messages,
storage_output,
detected_tool_calls,
- full_thoughts,
)
yield make_event(
@@ -1570,7 +2173,8 @@ def make_event(etype: str, data: dict) -> str:
@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)
@@ -1580,7 +2184,6 @@ 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),
):
base_url = str(raw_request.base_url)
pool, db = GeminiClientPool(), LMDBConversationStore()
@@ -1594,9 +2197,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,
@@ -1608,8 +2212,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,
@@ -1626,10 +2228,9 @@ 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")
+ logger.error(f"Error in preparing conversation: {e}")
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail=str(e)
) from e
@@ -1643,19 +2244,20 @@ 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")
+ logger.error(f"Gemini API error: {e}")
raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail=str(e)) from e
if request.stream:
+ 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,
@@ -1664,46 +2266,108 @@ async def create_chat_completion(
structured_requirement,
)
- try:
- 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.")
- raise HTTPException(
- status_code=status.HTTP_502_BAD_GATEWAY, detail="Malformed response."
- ) from exc
+ assert isinstance(resp_or_stream, ModelOutput)
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,
)
- # Process images for OpenAI non-streaming flow
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 = ""
- seen_hashes = set()
- for image in images:
- try:
- _, _, _, fname, fhash = await _image_to_base64(image, image_store)
+ media_markdown = ""
+ seen_hashes = {}
+ 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:
- (image_store / fname).unlink(missing_ok=True)
+ (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")
+ image_markdown += f"\n\n"
+
+ elif rtype == "media":
+ m_dict = media_data
+ if not m_dict:
continue
- seen_hashes.add(fhash)
- img_url = f"})"
- image_markdown += f"\n\n{img_url}"
- except Exception as exc:
- logger.warning(f"Failed to process image in OpenAI response: {exc}")
+ 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"[]({video_url})"
+ if current_thumb
+ else f"[{title}]({video_url})"
+ )
+ if audio_url:
+ md_parts.append(
+ f"[]({audio_url})"
+ if current_thumb
+ else f"[{title} - Audio]({audio_url})"
+ )
+
+ if md_parts:
+ media_markdown += f"\n\n{'\n\n'.join(md_parts)}"
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)}")
+ if media_markdown:
+ visible_output += media_markdown
+ storage_output += media_markdown
p_tok, c_tok, t_tok, r_tok = _calculate_usage(
- request.messages, visible_output, tool_calls, thoughts
+ app_messages, storage_output, tool_calls, thoughts
)
usage = {
"prompt_tokens": p_tok,
@@ -1716,7 +2380,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,
@@ -1726,10 +2390,9 @@ async def create_chat_completion(
model.model_name,
client.id,
session.metadata,
- msgs, # Use prepared messages 'msgs'
+ msgs,
storage_output,
tool_calls,
- thoughts,
)
return payload
@@ -1740,36 +2403,37 @@ 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),
):
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 []
+ base_messages = _convert_responses_to_app_messages(request.input)
+ structured_requirement = _build_structured_requirement(request.response_format)
+ extra_instr = [structured_requirement.instruction] if structured_requirement 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)
+ 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, ToolChoiceFunction)) else None
+ request.tool_choice
+ if isinstance(request.tool_choice, (str, ChatCompletionNamedToolChoice))
+ else None
)
messages = _prepare_messages_for_model(
@@ -1788,7 +2452,7 @@ async def create_response(
if session:
msgs = _prepare_messages_for_model(
remain,
- request.tools,
+ request.tools, # type: ignore
request.tool_choice,
None,
False,
@@ -1805,7 +2469,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
@@ -1819,13 +2483,14 @@ 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")
+ logger.error(f"Gemini API error: {e}")
raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail=str(e)) from e
if request.stream:
+ assert not isinstance(resp_or_stream, ModelOutput)
return _create_responses_real_streaming_response(
resp_or_stream,
response_id,
@@ -1837,80 +2502,128 @@ async def create_response(
client,
session,
request,
- image_store,
base_url,
- struct_req,
+ structured_requirement,
)
- try:
- 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
+ assert isinstance(resp_or_stream, ModelOutput)
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 (
- 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.")
+ 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 = set()
- for img in images:
- try:
- b64, w, h, fname, fhash = await _image_to_base64(img, image_store)
+ seen_hashes = {}
+ 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:
- (image_store / fname).unlink(missing_ok=True)
- 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]
- 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=fmt, size=f"{w}x{h}" if w and h else None
+ )
)
- contents.append(
- ResponseOutputContent(
- type="output_text",
- text=f"})",
+ 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"[]({video_url})"
+ if current_thumb
+ else f"[{title}]({video_url})"
)
- )
- img_calls.append(
- ResponseImageGenerationCall(
- id=img_id,
- result=b64,
- output_format=img_format,
- size=f"{w}x{h}" if w and h else None,
+ if audio_url:
+ md_parts.append(
+ f"[]({audio_url})"
+ if current_thumb
+ else f"[{title} - Audio]({audio_url})"
)
- )
- except Exception as e:
- logger.warning(f"Image error: {e}")
+
+ if md_parts:
+ media_markdown += f"\n\n{'\n\n'.join(md_parts)}"
if assistant_text:
- contents.append(ResponseOutputContent(type="output_text", text=assistant_text))
- if not contents:
- contents.append(ResponseOutputContent(type="output_text", text=""))
+ contents.append(ResponseOutputText(type="output_text", text=assistant_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"})"
- image_markdown += f"\n\n{img_url}"
+ 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"
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,
@@ -1926,7 +2639,6 @@ async def create_response(
contents,
usage,
request,
- norm_input,
thoughts,
)
_persist_conversation(
@@ -1937,6 +2649,5 @@ async def create_response(
messages,
storage_output,
tool_calls,
- thoughts,
)
return payload
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/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 2fa016b..a0593b4 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/services/client.py b/app/services/client.py
index b8f976b..a83e1e6 100644
--- a/app/services/client.py
+++ b/app/services/client.py
@@ -1,25 +1,19 @@
+import io
from pathlib import Path
-from typing import Any, cast
+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 Message
+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,
)
-_UNSET = object()
-
-
-def _resolve(value: Any, fallback: Any):
- return fallback if value is _UNSET else value
-
class GeminiClientWrapper(GeminiClient):
"""Gemini client with helper methods."""
@@ -28,36 +22,18 @@ def __init__(self, client_id: str, **kwargs):
super().__init__(**kwargs)
self.id = client_id
- async def init(
- 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))
-
try:
await super().init(
- timeout=timeout,
- watchdog_timeout=watchdog_timeout,
- auto_close=auto_close,
- close_delay=close_delay,
- auto_refresh=auto_refresh,
- refresh_interval=refresh_interval,
- verbose=verbose,
+ timeout=config.timeout,
+ watchdog_timeout=config.watchdog_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}")
@@ -68,7 +44,10 @@ def running(self) -> bool:
@staticmethod
async def process_message(
- message: Message, tempdir: Path | None = None, tagged: bool = True, wrap_tool: bool = True
+ 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.
@@ -83,29 +62,31 @@ 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:
- 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":
- 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"
@@ -154,10 +135,10 @@ async def process_message(
@staticmethod
async def process_conversation(
- messages: list[Message], tempdir: Path | None = None
- ) -> tuple[str, list[Path | str]]:
+ messages: list[AppMessage], 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):
@@ -185,15 +166,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/app/services/lmdb.py b/app/services/lmdb.py
index 07f0a23..3284b8d 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,15 +8,17 @@
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 (
+ 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
@@ -40,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:
@@ -50,17 +52,14 @@ 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: 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
@@ -71,39 +70,20 @@ def _hash_message(message: Message, fuzzy: bool = False) -> str:
elif isinstance(content, list):
text_parts = []
for item in content:
- text_val = ""
- if isinstance(item, ContentItem) and item.type == "text":
- text_val = item.text
- elif isinstance(item, dict) and item.get("type") == "text":
- text_val = item.get("text")
-
- 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, 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")
- )
- 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")
- )
- 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")
@@ -112,7 +92,7 @@ def _hash_message(message: Message, fuzzy: bool = False) -> str:
calls_data.append(
{
- "name": tc.function.name,
+ "name": name,
"arguments": canon_args,
}
)
@@ -126,7 +106,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[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()
@@ -168,7 +148,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 +169,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 +184,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
@@ -220,10 +200,11 @@ def _get_transaction(self, write: bool = False):
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)
@@ -236,24 +217,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:
@@ -263,29 +242,35 @@ def _remove_from_index(txn: lmdb.Transaction, prefix: str, hash_val: str, storag
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:
@@ -302,9 +287,8 @@ 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 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,17 +318,17 @@ 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[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
@@ -357,16 +341,7 @@ def find(self, model: str, messages: list[Message]) -> ConversationInStore | Non
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):
@@ -381,7 +356,7 @@ def find(self, model: str, messages: list[Message]) -> ConversationInStore | Non
def _find_by_message_list(
self,
model: str,
- messages: list[Message],
+ messages: list[AppMessage],
fuzzy: bool = False,
) -> ConversationInStore | None:
"""
@@ -423,7 +398,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 +413,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 +439,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
@@ -478,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
@@ -490,7 +465,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
@@ -510,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
):
@@ -529,7 +504,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 +527,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 +545,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 {}
@@ -584,68 +559,3 @@ def close(self) -> None:
def __del__(self):
"""Cleanup on destruction."""
self.close()
-
- @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 = 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
-
- for item in msg.content:
- if isinstance(item, ContentItem) 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 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/services/pool.py b/app/services/pool.py
index 3b4197c..847e79a 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
@@ -24,9 +25,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,
- proxy=c.proxy,
+ **c.model_dump(exclude={"id"}),
)
self._clients.append(client)
self._id_map[c.id] = client
@@ -34,24 +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(
- 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,
- )
- 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")
@@ -92,13 +87,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:
@@ -110,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/app/utils/config.py b/app/utils/config.py
index 69af2e1..f84385a 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
@@ -39,8 +39,8 @@ 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")
proxy: str | None = Field(default=None, description="Proxy URL for this Gemini client")
@field_validator("proxy", mode="before")
@@ -67,7 +67,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
@@ -82,15 +85,15 @@ 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")
- 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")
+ 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 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")
+ verbose: bool = Field(True, description="Enable verbose logging for Gemini API requests")
max_chars_per_request: int = Field(
default=1_000_000,
ge=1,
@@ -103,9 +106,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")
@@ -151,9 +157,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
@@ -228,10 +234,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):
@@ -244,7 +250,7 @@ def extract_gemini_clients_env() -> dict[int, dict[str, str]]:
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
@@ -252,7 +258,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:
@@ -300,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
@@ -322,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:
@@ -346,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()
+ config = Config()
- # Then, initialize Config with pydantic_settings
- config = Config() # type: ignore
-
- # 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 187f310..781ec33 100644
--- a/app/utils/helper.py
+++ b/app/utils/helper.py
@@ -11,10 +11,10 @@
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 FunctionCall, Message, ToolCall
+from app.models import AppMessage, AppToolCall, AppToolCallFunction
VALID_TAG_ROLES = {"user", "assistant", "system", "tool"}
TOOL_WRAP_HINT = (
@@ -33,39 +33,35 @@
"[/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"\\?\[\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,
)
@@ -180,7 +174,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,13 +189,19 @@ 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:
+ async with requests.AsyncSession(
+ impersonate="chrome", allow_redirects=CurlFollow.SAFE
+ ) as client:
resp = await client.get(url)
resp.raise_for_status()
data = resp.content
@@ -278,7 +278,7 @@ 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[AppToolCall]]:
"""
Extract tool metadata and return text stripped of technical markers.
Arguments are parsed into JSON and assigned deterministic call IDs.
@@ -286,7 +286,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[AppToolCall] = []
def _create_tool_call(name: str, raw_args: str) -> None:
if not extract:
@@ -321,10 +321,10 @@ def _create_tool_call(name: str, raw_args: str) -> None:
call_id = f"call_{hashlib.sha256(seed).hexdigest()[:24]}"
tool_calls.append(
- ToolCall(
+ AppToolCall(
id=call_id,
type="function",
- function=FunctionCall(name=name, arguments=arguments),
+ function=AppToolCallFunction(name=name, arguments=arguments),
)
)
@@ -341,12 +341,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[AppToolCall]]:
"""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: AppMessage) -> str:
"""Concatenate text and tool arguments from a message for token estimation."""
base_text = ""
if isinstance(message.content, str):
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 bd9fbc0..fbce483 100644
--- a/config/config.yaml
+++ b/config/config.yaml
@@ -18,24 +18,24 @@ cors:
gemini:
clients:
- - id: "example-id-1" # Arbitrary client ID
- secure_1psid: "YOUR_SECURE_1PSID_HERE"
- secure_1psidts: "YOUR_SECURE_1PSIDTS_HERE"
+ - 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)
- timeout: 600 # Init timeout in seconds (Not less than 30s)
- watchdog_timeout: 300 # Watchdog 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)
- 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: []
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
logging:
- level: "INFO" # Log level: DEBUG, INFO, WARNING, ERROR
+ level: "DEBUG" # Log level: DEBUG, INFO, WARNING, ERROR
diff --git a/pyproject.toml b/pyproject.toml
index 90a2d5c..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",
- "fastapi>=0.135.0",
- "gemini-webapi>=1.19.2",
+ "curl-cffi>=0.15.0",
+ "fastapi>=0.135.3",
+ "gemini-webapi>=2.0.0",
"httptools>=0.7.1",
- "lmdb>=1.7.5",
+ "lmdb>=2.2.0",
"loguru>=0.7.3",
- "orjson>=3.11.7",
+ "orjson>=3.11.8",
"pydantic-settings[yaml]>=2.13.1",
- "uvicorn>=0.41.0",
+ "uvicorn>=0.44.0",
"uvloop>=0.22.1; sys_platform != 'win32'",
]
@@ -22,8 +22,9 @@ Repository = "https://github.com/Nativu5/Gemini-FastAPI"
[project.optional-dependencies]
dev = [
- "pytest>=9.0.2",
- "ruff>=0.15.4",
+ "pytest",
+ "ruff",
+ "ty",
]
[dependency-groups]
diff --git a/scripts/dump_lmdb.py b/scripts/dump_lmdb.py
index 889af4f..3ef4805 100644
--- a/scripts/dump_lmdb.py
+++ b/scripts/dump_lmdb.py
@@ -5,25 +5,27 @@
import lmdb
import orjson
+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:
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():
- 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
-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:
diff --git a/uv.lock b/uv.lock
index 77addf8..82c3743 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]]
@@ -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,30 +87,32 @@ 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]]
name = "fastapi"
-version = "0.135.1"
+version = "0.135.3"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "annotated-doc" },
@@ -119,9 +121,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/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/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/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]]
@@ -145,6 +147,7 @@ dependencies = [
dev = [
{ name = "pytest" },
{ name = "ruff" },
+ { name = "ty" },
]
[package.dev-dependencies]
@@ -154,17 +157,18 @@ dev = [
[package.metadata]
requires-dist = [
- { name = "curl-cffi", specifier = ">=0.14.0" },
- { name = "fastapi", specifier = ">=0.135.0" },
- { name = "gemini-webapi", specifier = ">=1.19.2" },
+ { name = "curl-cffi", specifier = ">=0.15.0" },
+ { name = "fastapi", specifier = ">=0.135.3" },
+ { name = "gemini-webapi", specifier = ">=2.0.0" },
{ name = "httptools", specifier = ">=0.7.1" },
- { name = "lmdb", specifier = ">=1.7.5" },
+ { 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'", specifier = ">=9.0.2" },
- { name = "ruff", marker = "extra == 'dev'", specifier = ">=0.15.4" },
- { name = "uvicorn", specifier = ">=0.41.0" },
+ { name = "pytest", marker = "extra == 'dev'" },
+ { name = "ruff", marker = "extra == 'dev'" },
+ { name = "ty", marker = "extra == 'dev'" },
+ { name = "uvicorn", specifier = ">=0.44.0" },
{ name = "uvloop", marker = "sys_platform != 'win32'", specifier = ">=0.22.1" },
]
provides-extras = ["dev"]
@@ -174,17 +178,17 @@ dev = [{ name = "gemini-fastapi", extras = ["dev"] }]
[[package]]
name = "gemini-webapi"
-version = "1.21.0"
+version = "2.0.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
- { name = "httpx", extra = ["http2"] },
+ { name = "curl-cffi" },
{ name = "loguru" },
{ name = "orjson" },
{ name = "pydantic" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/b3/1e/ec164b7af828b2561a6d0d6235d895e2997698d16fcbbcc652b20ca0ba49/gemini_webapi-1.21.0.tar.gz", hash = "sha256:0b75c29b5380b109f663b35c8df76f5244dae819b12a472bd25df9016a46b76c", size = 272612, upload-time = "2026-03-06T23:15:05.005Z" }
+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/a8/c7/1e94cbfdf7c6add59f61897d0069e83ca099eafb0affeac7a56881c4298e/gemini_webapi-1.21.0-py3-none-any.whl", hash = "sha256:04fbaef555537892c2c824ea127796770d210dbe65df4f178723897dd969b72e", size = 69739, upload-time = "2026-03-06T23:15:03.54Z" },
+ { 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]]
@@ -196,41 +200,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"
@@ -246,35 +215,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"
@@ -295,17 +235,16 @@ wheels = [
[[package]]
name = "lmdb"
-version = "1.7.5"
+version = "2.2.0"
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/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/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/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]]
@@ -321,27 +260,48 @@ 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.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]]
@@ -432,16 +392,16 @@ 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]]
name = "pytest"
-version = "9.0.2"
+version = "9.0.3"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "colorama", marker = "sys_platform == 'win32'" },
@@ -450,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]]
@@ -482,41 +442,78 @@ 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.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.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]]
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/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]]
+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/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/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]]
@@ -542,15 +539,15 @@ wheels = [
[[package]]
name = "uvicorn"
-version = "0.41.0"
+version = "0.44.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/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/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/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]]