├── app ├── __init__.py ├── server │ ├── __init__.py │ ├── images.py │ ├── health.py │ ├── middleware.py │ └── chat.py ├── models │ ├── __init__.py │ └── models.py ├── utils │ ├── __init__.py │ ├── singleton.py │ ├── helper.py │ ├── logging.py │ └── config.py ├── services │ ├── __init__.py │ ├── pool.py │ ├── client.py │ └── lmdb.py └── main.py ├── .gitignore ├── Dockerfile ├── .github └── workflows │ ├── ruff.yaml │ ├── docker.yaml │ └── track.yml ├── pyproject.toml ├── scripts ├── USAGE.md ├── dump_lmdb.py └── rotate_lmdb.py ├── LICENSE ├── run.py ├── config └── config.yaml ├── README.zh.md ├── README.md └── uv.lock /app/__init__.py: -------------------------------------------------------------------------------- 1 | # Gemini API Server Package 2 | -------------------------------------------------------------------------------- /app/server/__init__.py: -------------------------------------------------------------------------------- 1 | # API routes package 2 | -------------------------------------------------------------------------------- /app/models/__init__.py: -------------------------------------------------------------------------------- 1 | from .models import * # noqa: F403 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .python-version 2 | .vscode 3 | .cursor 4 | .idea 5 | 6 | .venv 7 | *.egg-info 8 | .ruff_cache 9 | __pycache__ 10 | 11 | .env 12 | config.debug.yaml 13 | data/ -------------------------------------------------------------------------------- /app/utils/__init__.py: -------------------------------------------------------------------------------- 1 | from .config import initialize_config 2 | from .logging import setup_logging 3 | 4 | # Singleton configuration instance 5 | g_config = initialize_config() 6 | 7 | __all__ = [ 8 | "g_config", 9 | "setup_logging", 10 | ] 11 | -------------------------------------------------------------------------------- /app/services/__init__.py: -------------------------------------------------------------------------------- 1 | from .client import GeminiClientWrapper 2 | from .lmdb import LMDBConversationStore 3 | from .pool import GeminiClientPool 4 | 5 | __all__ = [ 6 | "GeminiClientPool", 7 | "GeminiClientWrapper", 8 | "LMDBConversationStore", 9 | ] 10 | -------------------------------------------------------------------------------- /app/utils/singleton.py: -------------------------------------------------------------------------------- 1 | from typing import ClassVar, Dict 2 | 3 | 4 | class Singleton(type): 5 | _instances: ClassVar[Dict[type, object]] = {} 6 | 7 | def __call__(cls, *args, **kwargs): 8 | if cls not in cls._instances: 9 | cls._instances[cls] = super(Singleton, cls).__call__(*args, **kwargs) 10 | return cls._instances[cls] 11 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM ghcr.io/astral-sh/uv:python3.12-bookworm-slim 2 | 3 | LABEL org.opencontainers.image.description="Web-based Gemini models wrapped into an OpenAI-compatible API." 4 | 5 | WORKDIR /app 6 | 7 | # Install dependencies 8 | COPY pyproject.toml uv.lock ./ 9 | RUN uv sync --no-cache --no-dev 10 | 11 | COPY app/ app/ 12 | COPY config/ config/ 13 | COPY run.py . 14 | 15 | # Command to run the application 16 | CMD ["uv", "run", "--no-dev", "run.py"] 17 | -------------------------------------------------------------------------------- /.github/workflows/ruff.yaml: -------------------------------------------------------------------------------- 1 | name: Ruff Lint 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | pull_request: 8 | types: 9 | - opened 10 | 11 | jobs: 12 | lint: 13 | runs-on: ubuntu-latest 14 | 15 | steps: 16 | - name: Checkout repository 17 | uses: actions/checkout@v4 18 | 19 | - name: Set up Python 20 | uses: actions/setup-python@v5 21 | with: 22 | python-version: "3.12" 23 | 24 | - name: Install Ruff 25 | run: | 26 | python -m pip install --upgrade pip 27 | pip install "ruff>=0.11.7" 28 | 29 | - name: Run Ruff 30 | run: ruff check . 31 | -------------------------------------------------------------------------------- /app/server/images.py: -------------------------------------------------------------------------------- 1 | from fastapi import APIRouter, HTTPException, Query 2 | from fastapi.responses import FileResponse 3 | 4 | from ..server.middleware import get_image_store_dir, verify_image_token 5 | 6 | router = APIRouter() 7 | 8 | 9 | @router.get("/images/{filename}", tags=["Images"]) 10 | async def get_image(filename: str, token: str | None = Query(default=None)): 11 | if not verify_image_token(filename, token): 12 | raise HTTPException(status_code=403, detail="Invalid token") 13 | 14 | image_store = get_image_store_dir() 15 | file_path = image_store / filename 16 | if not file_path.exists(): 17 | raise HTTPException(status_code=404, detail="Image not found") 18 | return FileResponse(file_path) 19 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "gemini-fastapi" 3 | version = "1.0.0" 4 | description = "FastAPI Server built on Gemini Web API" 5 | readme = "README.md" 6 | requires-python = "==3.12.*" 7 | dependencies = [ 8 | "fastapi>=0.115.12", 9 | "gemini-webapi>=1.17.0", 10 | "lmdb>=1.6.2", 11 | "loguru>=0.7.0", 12 | "pydantic-settings[yaml]>=2.9.1", 13 | "uvicorn>=0.34.1", 14 | "uvloop>=0.21.0; sys_platform != 'win32'", 15 | ] 16 | 17 | [project.optional-dependencies] 18 | dev = [ 19 | "ruff>=0.11.7", 20 | ] 21 | 22 | [tool.ruff] 23 | line-length = 100 24 | lint.select = ["E", "F", "W", "I", "RUF"] 25 | lint.ignore = ["E501"] 26 | 27 | [tool.ruff.format] 28 | quote-style = "double" 29 | indent-style = "space" 30 | 31 | [dependency-groups] 32 | dev = [ 33 | "ruff>=0.11.13", 34 | ] 35 | -------------------------------------------------------------------------------- /scripts/USAGE.md: -------------------------------------------------------------------------------- 1 | # Scripts Usage 2 | 3 | This directory contains maintenance utilities for the project. 4 | 5 | ## Table of Contents 6 | 7 | - [dump_lmdb.py](#dump_lmdbpy) 8 | - [rotate_lmdb.py](#rotate_lmdbpy) 9 | 10 | ## dump_lmdb.py 11 | 12 | Dump records from an LMDB database as a JSON array. If no keys are provided, the script outputs every record. When keys are supplied, only the specified records are returned. 13 | 14 | ### Usage 15 | 16 | Dump all entries: 17 | 18 | ```bash 19 | python scripts/dump_lmdb.py /path/to/lmdb 20 | ``` 21 | 22 | Dump specific keys: 23 | 24 | ```bash 25 | python scripts/dump_lmdb.py /path/to/lmdb key1 key2 26 | ``` 27 | 28 | ## rotate_lmdb.py 29 | 30 | Delete LMDB records older than a given duration or remove all records. 31 | 32 | ### Usage 33 | 34 | Delete entries older than 14 days: 35 | 36 | ```bash 37 | python scripts/rotate_lmdb.py /path/to/lmdb 14d 38 | ``` 39 | 40 | Delete all entries: 41 | 42 | ```bash 43 | python scripts/rotate_lmdb.py /path/to/lmdb all 44 | ``` 45 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 Yongkun Li 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /app/server/health.py: -------------------------------------------------------------------------------- 1 | from fastapi import APIRouter 2 | from loguru import logger 3 | 4 | from ..models import HealthCheckResponse 5 | from ..services import GeminiClientPool, LMDBConversationStore 6 | 7 | router = APIRouter() 8 | 9 | 10 | @router.get("/health", response_model=HealthCheckResponse) 11 | async def health_check(): 12 | pool = GeminiClientPool() 13 | db = LMDBConversationStore() 14 | 15 | try: 16 | await pool.init() 17 | except Exception as e: 18 | logger.error(f"Failed to initialize Gemini clients: {e}") 19 | return HealthCheckResponse(ok=False, error=str(e)) 20 | 21 | client_status = pool.status() 22 | 23 | if not all(client_status.values()): 24 | logger.warning("One or more Gemini clients not running") 25 | 26 | stat = db.stats() 27 | if not stat: 28 | logger.error("Failed to retrieve LMDB conversation store stats") 29 | return HealthCheckResponse( 30 | ok=False, error="LMDB conversation store unavailable", clients=client_status 31 | ) 32 | 33 | return HealthCheckResponse(ok=all(client_status.values()), storage=stat, clients=client_status) 34 | -------------------------------------------------------------------------------- /run.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sys 3 | 4 | import uvicorn 5 | from loguru import logger 6 | 7 | from app.main import create_app 8 | from app.utils import g_config, setup_logging 9 | 10 | app = create_app() 11 | 12 | if __name__ == "__main__": 13 | # Setup loguru logging 14 | setup_logging(level=g_config.logging.level) 15 | 16 | # Check HTTPS configuration 17 | if g_config.server.https.enabled: 18 | key_path = g_config.server.https.key_file 19 | cert_path = g_config.server.https.cert_file 20 | 21 | # Check if the certificate files exist 22 | if not os.path.exists(key_path) or not os.path.exists(cert_path): 23 | logger.critical( 24 | f"HTTPS enabled but SSL certificate files not found: {key_path}, {cert_path}" 25 | ) 26 | sys.exit(1) 27 | 28 | logger.info(f"Starting server at https://{g_config.server.host}:{g_config.server.port} ...") 29 | uvicorn.run( 30 | app, 31 | host=g_config.server.host, 32 | port=g_config.server.port, 33 | log_config=None, 34 | ssl_keyfile=key_path, 35 | ssl_certfile=cert_path, 36 | ) 37 | else: 38 | logger.info(f"Starting server at http://{g_config.server.host}:{g_config.server.port} ...") 39 | uvicorn.run( 40 | app, 41 | host=g_config.server.host, 42 | port=g_config.server.port, 43 | log_config=None, 44 | ) 45 | -------------------------------------------------------------------------------- /config/config.yaml: -------------------------------------------------------------------------------- 1 | # Gemini FastAPI Configuration File 2 | 3 | server: 4 | host: "0.0.0.0" # Server bind address 5 | port: 8000 # Server port 6 | api_key: null # API key for authentication (null for no auth) 7 | https: 8 | enabled: false # Enable HTTPS 9 | key_file: "certs/privkey.pem" # SSL private key file path 10 | cert_file: "certs/fullchain.pem" # SSL certificate file path 11 | 12 | cors: 13 | enabled: true # Enable CORS 14 | allow_origins: ["*"] 15 | allow_credentials: true 16 | allow_methods: ["*"] 17 | allow_headers: ["*"] 18 | 19 | gemini: 20 | clients: 21 | - id: "example-id-1" # Arbitrary client ID 22 | secure_1psid: "YOUR_SECURE_1PSID_HERE" 23 | secure_1psidts: "YOUR_SECURE_1PSIDTS_HERE" 24 | proxy: null # Optional proxy URL (null/empty means direct connection) 25 | timeout: 120 # Init timeout in seconds 26 | auto_refresh: true # Auto-refresh session cookies 27 | refresh_interval: 540 # Refresh interval in seconds 28 | verbose: false # Enable verbose logging for Gemini requests 29 | max_chars_per_request: 1000000 # Maximum characters Gemini Web accepts per request. Non-pro users might have a lower limit 30 | 31 | storage: 32 | path: "data/lmdb" # Database storage path 33 | max_size: 268435456 # Maximum database size (256 MB) 34 | retention_days: 14 # Number of days to retain conversations before cleanup 35 | 36 | logging: 37 | level: "INFO" # Log level: DEBUG, INFO, WARNING, ERROR 38 | -------------------------------------------------------------------------------- /.github/workflows/docker.yaml: -------------------------------------------------------------------------------- 1 | name: Build and Push Docker Image 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | tags: 8 | - "v*" 9 | paths-ignore: 10 | - "**/*.md" 11 | - ".github/workflows/ruff.yaml" 12 | - ".github/workflows/track.yml" 13 | 14 | env: 15 | REGISTRY: ghcr.io 16 | IMAGE_NAME: ${{ github.repository }} 17 | 18 | jobs: 19 | build-and-push: 20 | runs-on: ubuntu-latest 21 | permissions: 22 | contents: read 23 | packages: write 24 | 25 | steps: 26 | - name: Checkout repository 27 | uses: actions/checkout@v4 28 | 29 | - name: Set up Docker Buildx 30 | uses: docker/setup-buildx-action@v3 31 | 32 | - name: Log in to Container Registry 33 | uses: docker/login-action@v3 34 | with: 35 | registry: ${{ env.REGISTRY }} 36 | username: ${{ github.actor }} 37 | password: ${{ secrets.GITHUB_TOKEN }} 38 | 39 | - name: Extract metadata 40 | id: meta 41 | uses: docker/metadata-action@v5 42 | with: 43 | images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} 44 | tags: | 45 | type=ref,event=branch 46 | type=semver,pattern={{version}} 47 | type=semver,pattern={{major}}.{{minor}} 48 | type=semver,pattern={{major}} 49 | type=raw,value=latest,enable={{is_default_branch}} 50 | 51 | - name: Build and push Docker image 52 | uses: docker/build-push-action@v5 53 | with: 54 | context: . 55 | push: true 56 | tags: ${{ steps.meta.outputs.tags }} 57 | labels: ${{ steps.meta.outputs.labels }} 58 | platforms: linux/amd64,linux/arm64 59 | cache-from: type=gha 60 | cache-to: type=gha,mode=max 61 | -------------------------------------------------------------------------------- /scripts/dump_lmdb.py: -------------------------------------------------------------------------------- 1 | import argparse 2 | from pathlib import Path 3 | from typing import Any, Iterable, List 4 | 5 | import lmdb 6 | import orjson 7 | 8 | 9 | def _decode_value(value: bytes) -> Any: 10 | """Decode a value from LMDB to Python data.""" 11 | try: 12 | return orjson.loads(value) 13 | except orjson.JSONDecodeError: 14 | return value.decode("utf-8", errors="replace") 15 | 16 | 17 | def _dump_all(txn: lmdb.Transaction) -> List[dict[str, Any]]: 18 | """Return all records from the database.""" 19 | result: List[dict[str, Any]] = [] 20 | for key, value in txn.cursor(): 21 | result.append({"key": key.decode("utf-8"), "value": _decode_value(value)}) 22 | return result 23 | 24 | 25 | def _dump_selected(txn: lmdb.Transaction, keys: Iterable[str]) -> List[dict[str, Any]]: 26 | """Return records for the provided keys.""" 27 | result: List[dict[str, Any]] = [] 28 | for key in keys: 29 | raw = txn.get(key.encode("utf-8")) 30 | if raw is not None: 31 | result.append({"key": key, "value": _decode_value(raw)}) 32 | return result 33 | 34 | 35 | def dump_lmdb(path: Path, keys: Iterable[str] | None = None) -> None: 36 | """Print selected or all key-value pairs from the LMDB database.""" 37 | env = lmdb.open(str(path), readonly=True, lock=False) 38 | with env.begin() as txn: 39 | if keys: 40 | records = _dump_selected(txn, keys) 41 | else: 42 | records = _dump_all(txn) 43 | env.close() 44 | 45 | print(orjson.dumps(records, option=orjson.OPT_INDENT_2).decode()) 46 | 47 | 48 | def main() -> None: 49 | parser = argparse.ArgumentParser(description="Dump LMDB records as JSON") 50 | parser.add_argument("path", type=Path, help="Path to LMDB directory") 51 | parser.add_argument("keys", nargs="*", help="Keys to retrieve") 52 | args = parser.parse_args() 53 | 54 | dump_lmdb(args.path, args.keys) 55 | 56 | 57 | if __name__ == "__main__": 58 | main() 59 | -------------------------------------------------------------------------------- /app/utils/helper.py: -------------------------------------------------------------------------------- 1 | import base64 2 | import mimetypes 3 | import tempfile 4 | from pathlib import Path 5 | 6 | import httpx 7 | from loguru import logger 8 | 9 | VALID_TAG_ROLES = {"user", "assistant", "system", "tool"} 10 | 11 | 12 | def add_tag(role: str, content: str, unclose: bool = False) -> str: 13 | """Surround content with role tags""" 14 | if role not in VALID_TAG_ROLES: 15 | logger.warning(f"Unknown role: {role}, returning content without tags") 16 | return content 17 | 18 | return f"<|im_start|>{role}\n{content}" + ("\n<|im_end|>" if not unclose else "") 19 | 20 | 21 | def estimate_tokens(text: str) -> int: 22 | """Estimate the number of tokens heuristically based on character count""" 23 | return int(len(text) / 3) 24 | 25 | 26 | async def save_file_to_tempfile( 27 | file_in_base64: str, file_name: str = "", tempdir: Path | None = None 28 | ) -> Path: 29 | data = base64.b64decode(file_in_base64) 30 | suffix = Path(file_name).suffix if file_name else ".bin" 31 | 32 | with tempfile.NamedTemporaryFile(delete=False, suffix=suffix, dir=tempdir) as tmp: 33 | tmp.write(data) 34 | path = Path(tmp.name) 35 | 36 | return path 37 | 38 | 39 | async def save_url_to_tempfile(url: str, tempdir: Path | None = None): 40 | data: bytes | None = None 41 | suffix: str | None = None 42 | if url.startswith("data:image/"): 43 | # Base64 encoded image 44 | metadata_part = url.split(",")[0] 45 | mime_type = metadata_part.split(":")[1].split(";")[0] 46 | 47 | base64_data = url.split(",")[1] 48 | data = base64.b64decode(base64_data) 49 | 50 | # Guess extension from mime type, default to the subtype if not found 51 | suffix = mimetypes.guess_extension(mime_type) 52 | if not suffix: 53 | suffix = f".{mime_type.split('/')[1]}" 54 | else: 55 | # http files 56 | async with httpx.AsyncClient() as client: 57 | resp = await client.get(url) 58 | resp.raise_for_status() 59 | data = resp.content 60 | suffix = Path(url).suffix or ".bin" 61 | 62 | with tempfile.NamedTemporaryFile(delete=False, suffix=suffix, dir=tempdir) as tmp: 63 | tmp.write(data) 64 | path = Path(tmp.name) 65 | 66 | return path 67 | -------------------------------------------------------------------------------- /scripts/rotate_lmdb.py: -------------------------------------------------------------------------------- 1 | import argparse 2 | from datetime import datetime, timedelta 3 | from pathlib import Path 4 | from typing import Any 5 | 6 | import lmdb 7 | import orjson 8 | 9 | 10 | def _parse_duration(value: str) -> timedelta: 11 | """Parse duration in the format '14d' or '24h'.""" 12 | if value.endswith("d"): 13 | return timedelta(days=int(value[:-1])) 14 | if value.endswith("h"): 15 | return timedelta(hours=int(value[:-1])) 16 | raise ValueError("Invalid duration format. Use Nd or Nh") 17 | 18 | 19 | def _should_delete(record: dict[str, Any], threshold: datetime) -> bool: 20 | """Check if the record is older than the threshold.""" 21 | timestamp = record.get("updated_at") or record.get("created_at") 22 | if not timestamp: 23 | return False 24 | try: 25 | ts = datetime.fromisoformat(timestamp) 26 | except ValueError: 27 | return False 28 | return ts < threshold 29 | 30 | 31 | def rotate_lmdb(path: Path, keep: str) -> None: 32 | """Remove records older than the specified duration.""" 33 | env = lmdb.open(str(path), writemap=True, readahead=False, meminit=False) 34 | if keep == "all": 35 | with env.begin(write=True) as txn: 36 | cursor = txn.cursor() 37 | for key, _ in cursor: 38 | txn.delete(key) 39 | env.close() 40 | return 41 | 42 | delta = _parse_duration(keep) 43 | threshold = datetime.now() - delta 44 | 45 | with env.begin(write=True) as txn: 46 | cursor = txn.cursor() 47 | for key, value in cursor: 48 | try: 49 | record = orjson.loads(value) 50 | except orjson.JSONDecodeError: 51 | continue 52 | if _should_delete(record, threshold): 53 | txn.delete(key) 54 | env.close() 55 | 56 | 57 | def main() -> None: 58 | parser = argparse.ArgumentParser(description="Remove outdated LMDB records") 59 | parser.add_argument("path", type=Path, help="Path to LMDB directory") 60 | parser.add_argument( 61 | "keep", 62 | help="Retention period, e.g. 14d or 24h. Use 'all' to delete every record", 63 | ) 64 | args = parser.parse_args() 65 | 66 | rotate_lmdb(args.path, args.keep) 67 | 68 | 69 | if __name__ == "__main__": 70 | main() 71 | -------------------------------------------------------------------------------- /app/utils/logging.py: -------------------------------------------------------------------------------- 1 | import inspect 2 | import logging 3 | import sys 4 | from typing import Literal 5 | 6 | from loguru import logger 7 | 8 | 9 | def setup_logging( 10 | level: Literal["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"] = "DEBUG", 11 | diagnose: bool = True, 12 | backtrace: bool = True, 13 | colorize: bool = True, 14 | ) -> None: 15 | """ 16 | Setup loguru logging configuration to unify all project logging output 17 | 18 | Args: 19 | level: Log level 20 | diagnose: Whether to enable diagnostic information 21 | backtrace: Whether to enable backtrace information 22 | colorize: Whether to enable colors 23 | """ 24 | # Reset all logger handlers 25 | logger.remove() 26 | 27 | # Add unified handler for all logs 28 | logger.add( 29 | sys.stderr, 30 | level=level, 31 | colorize=colorize, 32 | backtrace=backtrace, 33 | diagnose=diagnose, 34 | enqueue=True, 35 | ) 36 | 37 | # Setup standard logging library interceptor 38 | _setup_logging_intercept() 39 | 40 | logger.debug("Logger initialized.") 41 | 42 | 43 | def _setup_logging_intercept() -> None: 44 | """Setup standard logging library interceptor to redirect to loguru""" 45 | 46 | class InterceptHandler(logging.Handler): 47 | def emit(self, record: logging.LogRecord) -> None: 48 | # Get corresponding Loguru level if it exists. 49 | try: 50 | level: str | int = logger.level(record.levelname).name 51 | except ValueError: 52 | level = record.levelno 53 | 54 | # Find caller from where originated the logged message. 55 | frame, depth = inspect.currentframe(), 0 56 | while frame: 57 | filename = frame.f_code.co_filename 58 | is_logging = filename == logging.__file__ 59 | is_frozen = "importlib" in filename and "_bootstrap" in filename 60 | if depth > 0 and not (is_logging or is_frozen): 61 | break 62 | frame = frame.f_back 63 | depth += 1 64 | 65 | logger.opt(depth=depth, exception=record.exc_info).log(level, record.getMessage()) 66 | 67 | # Remove all existing handlers and add our interceptor 68 | logging.basicConfig(handlers=[InterceptHandler()], level="INFO", force=True) 69 | -------------------------------------------------------------------------------- /.github/workflows/track.yml: -------------------------------------------------------------------------------- 1 | name: Update gemini-webapi 2 | 3 | on: 4 | schedule: 5 | - cron: "0 0 * * *" # Runs every day at midnight 6 | workflow_dispatch: 7 | 8 | jobs: 9 | update-dep: 10 | runs-on: ubuntu-latest 11 | permissions: 12 | contents: write 13 | pull-requests: write 14 | steps: 15 | - uses: actions/checkout@v4 16 | 17 | - name: Install uv 18 | uses: astral-sh/setup-uv@v5 19 | with: 20 | version: "latest" 21 | 22 | - name: Update gemini-webapi 23 | id: update 24 | run: | 25 | # Install dependencies first to enable uv pip show 26 | uv sync 27 | 28 | # Get current version of gemini-webapi before upgrade 29 | OLD_VERSION=$(uv pip show gemini-webapi 2>/dev/null | grep ^Version: | awk '{print $2}') 30 | if [ -z "$OLD_VERSION" ]; then 31 | echo "Error: Could not extract current gemini-webapi version" >&2 32 | exit 1 33 | fi 34 | echo "Current gemini-webapi version: $OLD_VERSION" 35 | 36 | # Update the package using uv, which handles pyproject.toml and uv.lock 37 | uv add --upgrade gemini-webapi 38 | 39 | # Get new version of gemini-webapi after upgrade 40 | NEW_VERSION=$(uv pip show gemini-webapi | grep ^Version: | awk '{print $2}') 41 | if [ -z "$NEW_VERSION" ]; then 42 | echo "Error: Could not extract new gemini-webapi version" >&2 43 | exit 1 44 | fi 45 | echo "New gemini-webapi version: $NEW_VERSION" 46 | 47 | # Only proceed if gemini-webapi version has changed 48 | if [ "$OLD_VERSION" != "$NEW_VERSION" ]; then 49 | echo "gemini-webapi has been updated from $OLD_VERSION to $NEW_VERSION" 50 | echo "updated=true" >> $GITHUB_OUTPUT 51 | echo "version=$NEW_VERSION" >> $GITHUB_OUTPUT 52 | else 53 | echo "No updates available for gemini-webapi (version $OLD_VERSION unchanged)" 54 | echo "updated=false" >> $GITHUB_OUTPUT 55 | fi 56 | 57 | - name: Create Pull Request 58 | if: steps.update.outputs.updated == 'true' 59 | uses: peter-evans/create-pull-request@v6 60 | with: 61 | token: ${{ secrets.GITHUB_TOKEN }} 62 | commit-message: ":arrow_up: update gemini-webapi to ${{ steps.update.outputs.version }}" 63 | title: ":arrow_up: update gemini-webapi to ${{ steps.update.outputs.version }}" 64 | body: | 65 | Update `gemini-webapi` to version `${{ steps.update.outputs.version }}`. 66 | 67 | Auto-generated by GitHub Actions using `uv`. 68 | branch: update-gemini-webapi 69 | base: main 70 | delete-branch: true 71 | labels: dependency, automated 72 | -------------------------------------------------------------------------------- /app/main.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | from contextlib import asynccontextmanager 3 | 4 | from fastapi import FastAPI 5 | from loguru import logger 6 | 7 | from .server.chat import router as chat_router 8 | from .server.health import router as health_router 9 | from .server.images import router as images_router 10 | from .server.middleware import ( 11 | add_cors_middleware, 12 | add_exception_handler, 13 | cleanup_expired_images, 14 | ) 15 | from .services import GeminiClientPool, LMDBConversationStore 16 | 17 | RETENTION_CLEANUP_INTERVAL_SECONDS = 6 * 60 * 60 # 6 hours 18 | 19 | 20 | async def _run_retention_cleanup(stop_event: asyncio.Event) -> None: 21 | """ 22 | Periodically enforce LMDB retention policy until the stop_event is set. 23 | """ 24 | store = LMDBConversationStore() 25 | if store.retention_days <= 0: 26 | logger.info("LMDB retention cleanup disabled; skipping scheduler.") 27 | return 28 | 29 | logger.info( 30 | f"Starting LMDB retention cleanup task (retention={store.retention_days} day(s), interval={RETENTION_CLEANUP_INTERVAL_SECONDS} seconds)." 31 | ) 32 | 33 | while not stop_event.is_set(): 34 | try: 35 | store.cleanup_expired() 36 | cleanup_expired_images(store.retention_days) 37 | except Exception: 38 | logger.exception("LMDB retention cleanup task failed.") 39 | 40 | try: 41 | await asyncio.wait_for( 42 | stop_event.wait(), 43 | timeout=RETENTION_CLEANUP_INTERVAL_SECONDS, 44 | ) 45 | except asyncio.TimeoutError: 46 | continue 47 | 48 | logger.info("LMDB retention cleanup task stopped.") 49 | 50 | 51 | @asynccontextmanager 52 | async def lifespan(app: FastAPI): 53 | cleanup_stop_event = asyncio.Event() 54 | 55 | pool = GeminiClientPool() 56 | try: 57 | await pool.init() 58 | except Exception as e: 59 | logger.exception(f"Failed to initialize Gemini clients: {e}") 60 | raise 61 | 62 | cleanup_task = asyncio.create_task(_run_retention_cleanup(cleanup_stop_event)) 63 | # Give the cleanup task a chance to start and surface immediate failures. 64 | await asyncio.sleep(0) 65 | if cleanup_task.done(): 66 | try: 67 | cleanup_task.result() 68 | except Exception: 69 | logger.exception("LMDB retention cleanup task failed to start.") 70 | raise 71 | 72 | logger.info(f"Gemini clients initialized: {[c.id for c in pool.clients]}.") 73 | logger.info("Gemini API Server ready to serve requests.") 74 | 75 | try: 76 | yield 77 | finally: 78 | cleanup_stop_event.set() 79 | try: 80 | await cleanup_task 81 | except asyncio.CancelledError: 82 | logger.debug("LMDB retention cleanup task cancelled during shutdown.") 83 | except Exception: 84 | logger.exception( 85 | "LMDB retention cleanup task terminated with an unexpected error during shutdown." 86 | ) 87 | 88 | 89 | def create_app() -> FastAPI: 90 | app = FastAPI( 91 | title="Gemini API Server", 92 | description="OpenAI-compatible API for Gemini Web", 93 | version="1.0.0", 94 | lifespan=lifespan, 95 | ) 96 | 97 | add_cors_middleware(app) 98 | add_exception_handler(app) 99 | 100 | app.include_router(health_router, tags=["Health"]) 101 | app.include_router(chat_router, tags=["Chat"]) 102 | app.include_router(images_router, tags=["Images"]) 103 | 104 | return app 105 | -------------------------------------------------------------------------------- /app/server/middleware.py: -------------------------------------------------------------------------------- 1 | import hashlib 2 | import hmac 3 | import tempfile 4 | import time 5 | from pathlib import Path 6 | 7 | from fastapi import Depends, FastAPI, HTTPException, Request, status 8 | from fastapi.middleware.cors import CORSMiddleware 9 | from fastapi.responses import ORJSONResponse 10 | from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer 11 | from loguru import logger 12 | 13 | from ..utils import g_config 14 | 15 | # Persistent directory for storing generated images 16 | IMAGE_STORE_DIR = Path(tempfile.gettempdir()) / "ai_generated_images" 17 | IMAGE_STORE_DIR.mkdir(parents=True, exist_ok=True) 18 | 19 | 20 | def get_image_store_dir() -> Path: 21 | """Returns a persistent directory for storing images.""" 22 | return IMAGE_STORE_DIR 23 | 24 | 25 | def get_image_token(filename: str) -> str: 26 | """Generate a HMAC-SHA256 token for a filename using the API key.""" 27 | secret = g_config.server.api_key 28 | if not secret: 29 | return "" 30 | 31 | msg = filename.encode("utf-8") 32 | secret_bytes = secret.encode("utf-8") 33 | return hmac.new(secret_bytes, msg, hashlib.sha256).hexdigest() 34 | 35 | 36 | def verify_image_token(filename: str, token: str | None) -> bool: 37 | """Verify the provided token against the filename.""" 38 | expected = get_image_token(filename) 39 | if not expected: 40 | return True # No auth required 41 | if not token: 42 | return False 43 | return hmac.compare_digest(token, expected) 44 | 45 | 46 | def cleanup_expired_images(retention_days: int) -> int: 47 | """Delete images in IMAGE_STORE_DIR older than retention_days.""" 48 | if retention_days <= 0: 49 | return 0 50 | 51 | now = time.time() 52 | retention_seconds = retention_days * 24 * 60 * 60 53 | cutoff = now - retention_seconds 54 | 55 | count = 0 56 | for file_path in IMAGE_STORE_DIR.iterdir(): 57 | if not file_path.is_file(): 58 | continue 59 | try: 60 | if file_path.stat().st_mtime < cutoff: 61 | file_path.unlink() 62 | count += 1 63 | except Exception as e: 64 | logger.warning(f"Failed to delete expired image {file_path}: {e}") 65 | 66 | if count > 0: 67 | logger.info(f"Cleaned up {count} expired images.") 68 | return count 69 | 70 | 71 | def global_exception_handler(request: Request, exc: Exception): 72 | if isinstance(exc, HTTPException): 73 | return ORJSONResponse( 74 | status_code=exc.status_code, 75 | content={"error": {"message": exc.detail}}, 76 | ) 77 | 78 | return ORJSONResponse( 79 | status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, 80 | content={"error": {"message": str(exc)}}, 81 | ) 82 | 83 | 84 | def get_temp_dir(): 85 | temp_dir = tempfile.TemporaryDirectory() 86 | try: 87 | yield Path(temp_dir.name) 88 | finally: 89 | temp_dir.cleanup() 90 | 91 | 92 | def verify_api_key( 93 | credentials: HTTPAuthorizationCredentials = Depends(HTTPBearer(auto_error=False)), 94 | ): 95 | if not g_config.server.api_key: 96 | return "" 97 | 98 | if credentials is None or credentials.scheme.lower() != "bearer": 99 | raise HTTPException(status.HTTP_401_UNAUTHORIZED, detail="Invalid or missing token") 100 | 101 | api_key = credentials.credentials 102 | if api_key != g_config.server.api_key: 103 | raise HTTPException(status.HTTP_403_FORBIDDEN, detail="Wrong API key") 104 | 105 | return api_key 106 | 107 | 108 | def add_exception_handler(app: FastAPI): 109 | app.add_exception_handler(Exception, global_exception_handler) 110 | 111 | 112 | def add_cors_middleware(app: FastAPI): 113 | if g_config.cors.enabled: 114 | cors = g_config.cors 115 | app.add_middleware( 116 | CORSMiddleware, 117 | allow_origins=cors.allow_origins, 118 | allow_credentials=cors.allow_credentials, 119 | allow_methods=cors.allow_methods, 120 | allow_headers=cors.allow_headers, 121 | ) 122 | -------------------------------------------------------------------------------- /app/services/pool.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | from collections import deque 3 | from typing import Dict, List, Optional 4 | 5 | from loguru import logger 6 | 7 | from ..utils import g_config 8 | from ..utils.singleton import Singleton 9 | from .client import GeminiClientWrapper 10 | 11 | 12 | class GeminiClientPool(metaclass=Singleton): 13 | """Pool of GeminiClient instances identified by unique ids.""" 14 | 15 | def __init__(self) -> None: 16 | self._clients: List[GeminiClientWrapper] = [] 17 | self._id_map: Dict[str, GeminiClientWrapper] = {} 18 | self._round_robin: deque[GeminiClientWrapper] = deque() 19 | self._restart_locks: Dict[str, asyncio.Lock] = {} 20 | 21 | if len(g_config.gemini.clients) == 0: 22 | raise ValueError("No Gemini clients configured") 23 | 24 | for c in g_config.gemini.clients: 25 | client = GeminiClientWrapper( 26 | client_id=c.id, 27 | secure_1psid=c.secure_1psid, 28 | secure_1psidts=c.secure_1psidts, 29 | proxy=c.proxy, 30 | ) 31 | self._clients.append(client) 32 | self._id_map[c.id] = client 33 | self._round_robin.append(client) 34 | self._restart_locks[c.id] = asyncio.Lock() # Pre-initialize 35 | 36 | async def init(self) -> None: 37 | """Initialize all clients in the pool.""" 38 | success_count = 0 39 | for client in self._clients: 40 | if not client.running(): 41 | try: 42 | await client.init( 43 | timeout=g_config.gemini.timeout, 44 | auto_refresh=g_config.gemini.auto_refresh, 45 | verbose=g_config.gemini.verbose, 46 | refresh_interval=g_config.gemini.refresh_interval, 47 | ) 48 | except Exception: 49 | logger.exception(f"Failed to initialize client {client.id}") 50 | 51 | if client.running(): 52 | success_count += 1 53 | 54 | if success_count == 0: 55 | raise RuntimeError("Failed to initialize any Gemini clients") 56 | 57 | async def acquire(self, client_id: Optional[str] = None) -> GeminiClientWrapper: 58 | """Return a healthy client by id or using round-robin.""" 59 | if not self._round_robin: 60 | raise RuntimeError("No Gemini clients configured") 61 | 62 | if client_id: 63 | client = self._id_map.get(client_id) 64 | if not client: 65 | raise ValueError(f"Client id {client_id} not found") 66 | if await self._ensure_client_ready(client): 67 | return client 68 | raise RuntimeError( 69 | f"Gemini client {client_id} is not running and could not be restarted" 70 | ) 71 | 72 | for _ in range(len(self._round_robin)): 73 | client = self._round_robin[0] 74 | self._round_robin.rotate(-1) 75 | if await self._ensure_client_ready(client): 76 | return client 77 | 78 | raise RuntimeError("No Gemini clients are currently available") 79 | 80 | async def _ensure_client_ready(self, client: GeminiClientWrapper) -> bool: 81 | """Make sure the client is running, attempting a restart if needed.""" 82 | if client.running(): 83 | return True 84 | 85 | lock = self._restart_locks.get(client.id) 86 | if lock is None: 87 | return False # Should not happen 88 | 89 | async with lock: 90 | if client.running(): 91 | return True 92 | 93 | try: 94 | await client.init( 95 | timeout=g_config.gemini.timeout, 96 | auto_refresh=g_config.gemini.auto_refresh, 97 | verbose=g_config.gemini.verbose, 98 | refresh_interval=g_config.gemini.refresh_interval, 99 | ) 100 | logger.info(f"Restarted Gemini client {client.id} after it stopped.") 101 | return True 102 | except Exception: 103 | logger.exception(f"Failed to restart Gemini client {client.id}") 104 | return False 105 | 106 | @property 107 | def clients(self) -> List[GeminiClientWrapper]: 108 | """Return managed clients.""" 109 | return self._clients 110 | 111 | def status(self) -> Dict[str, bool]: 112 | """Return running status for each client.""" 113 | return {client.id: client.running() for client in self._clients} 114 | -------------------------------------------------------------------------------- /README.zh.md: -------------------------------------------------------------------------------- 1 | # Gemini-FastAPI 2 | 3 | [![Python 3.12](https://img.shields.io/badge/python-3.12+-blue.svg)](https://www.python.org/downloads/) 4 | [![FastAPI](https://img.shields.io/badge/FastAPI-0.115+-green.svg)](https://fastapi.tiangolo.com/) 5 | [![License](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE) 6 | 7 | 8 | [ [English](README.md) | 中文 ] 9 | 10 | 将 Gemini 网页端模型封装为兼容 OpenAI API 的 API Server。基于 [HanaokaYuzu/Gemini-API](https://github.com/HanaokaYuzu/Gemini-API) 实现。 11 | 12 | **✅ 无需 API Key,免费通过 API 调用 Gemini 网页端模型!** 13 | 14 | ## 功能特性 15 | 16 | - 🔐 **无需 Google API Key**:只需网页 Cookie,即可免费通过 API 调用 Gemini 模型。 17 | - 🔍 **内置 Google 搜索**:API 已内置 Gemini 网页端的搜索能力,模型响应更加准确。 18 | - 💾 **会话持久化**:基于 LMDB 存储,支持多轮对话历史记录。 19 | - 🖼️ **多模态支持**:可处理文本、图片及文件上传。 20 | - ⚖️ **多账户负载均衡**:支持多账户分发请求,可为每个账户单独配置代理。 21 | 22 | ## 快速开始 23 | 24 | **如需 Docker 部署,请参见下方 [Docker 部署](#docker-部署) 部分。** 25 | 26 | ### 前置条件 27 | 28 | - Python 3.12 29 | - 拥有网页版 Gemini 访问权限的 Google 账号 30 | - 从 Gemini 网页获取的 `secure_1psid` 和 `secure_1psidts` Cookie 31 | 32 | ### 安装 33 | 34 | #### 使用 uv (推荐) 35 | 36 | ```bash 37 | git clone https://github.com/Nativu5/Gemini-FastAPI.git 38 | cd Gemini-FastAPI 39 | uv sync 40 | ``` 41 | 42 | #### 使用 pip 43 | 44 | ```bash 45 | git clone https://github.com/Nativu5/Gemini-FastAPI.git 46 | cd Gemini-FastAPI 47 | pip install -e . 48 | ``` 49 | 50 | ### 配置 51 | 52 | 编辑 `config/config.yaml` 并提供至少一组凭证: 53 | ```yaml 54 | gemini: 55 | clients: 56 | - id: "client-a" 57 | secure_1psid: "YOUR_SECURE_1PSID_HERE" 58 | secure_1psidts: "YOUR_SECURE_1PSIDTS_HERE" 59 | proxy: null # Optional proxy URL (null/empty keeps direct connection) 60 | ``` 61 | 62 | > [!NOTE] 63 | > 详细说明请参见下方 [配置](#配置说明) 部分。 64 | 65 | ### 启动服务 66 | 67 | ```bash 68 | # 使用 uv 69 | uv run python run.py 70 | 71 | # 直接用 Python 72 | python run.py 73 | ``` 74 | 75 | 服务默认启动在 `http://localhost:8000`。 76 | 77 | ## Docker 部署 78 | 79 | ### 直接运行 80 | 81 | ```bash 82 | docker run -p 8000:8000 \ 83 | -v $(pwd)/data:/app/data \ 84 | -v $(pwd)/cache:/app/cache \ 85 | -e CONFIG_SERVER__API_KEY="your-api-key-here" \ 86 | -e CONFIG_GEMINI__CLIENTS__0__ID="client-a" \ 87 | -e CONFIG_GEMINI__CLIENTS__0__SECURE_1PSID="your-secure-1psid" \ 88 | -e CONFIG_GEMINI__CLIENTS__0__SECURE_1PSIDTS="your-secure-1psidts" \ 89 | -e GEMINI_COOKIE_PATH="/app/cache" \ 90 | ghcr.io/nativu5/gemini-fastapi 91 | ``` 92 | 93 | > [!TIP] 94 | > 需要代理时可添加 `CONFIG_GEMINI__CLIENTS__0__PROXY`;省略该变量将保持直连。 95 | > 96 | > `GEMINI_COOKIE_PATH` 指定容器内保存刷新后 Cookie 的目录。将其挂载(例如 `-v $(pwd)/cache:/app/cache`)可以在容器重建或重启后保留这些 Cookie,避免频繁重新认证。 97 | 98 | ### 使用 Docker Compose 99 | 100 | 创建 `docker-compose.yml` 文件: 101 | 102 | ```yaml 103 | services: 104 | gemini-fastapi: 105 | image: ghcr.io/nativu5/gemini-fastapi:latest 106 | ports: 107 | - "8000:8000" 108 | volumes: 109 | # - ./config:/app/config # Uncomment to use a custom config file 110 | # - ./certs:/app/certs # Uncomment to enable HTTPS with your certs 111 | - ./data:/app/data 112 | - ./cache:/app/cache 113 | environment: 114 | - CONFIG_SERVER__HOST=0.0.0.0 115 | - CONFIG_SERVER__PORT=8000 116 | - CONFIG_SERVER__API_KEY=${API_KEY} 117 | - CONFIG_GEMINI__CLIENTS__0__ID=client-a 118 | - CONFIG_GEMINI__CLIENTS__0__SECURE_1PSID=${SECURE_1PSID} 119 | - CONFIG_GEMINI__CLIENTS__0__SECURE_1PSIDTS=${SECURE_1PSIDTS} 120 | - GEMINI_COOKIE_PATH=/app/cache # must match the cache volume mount above 121 | restart: on-failure:3 # Avoid retrying too many times 122 | ``` 123 | 124 | 然后运行: 125 | 126 | ```bash 127 | docker compose up -d 128 | ``` 129 | 130 | > [!IMPORTANT] 131 | > 请务必挂载 `/app/data` 卷以保证对话数据在容器重启后持久化。 132 | > 同时挂载 `/app/cache`(或与 `GEMINI_COOKIE_PATH` 对应的目录)以保存刷新后的 Cookie,这样在容器重建/重启后无需频繁重新认证。 133 | 134 | ## 配置说明 135 | 136 | 服务器读取 `config/config.yaml` 配置文件。 137 | 138 | 各项配置说明请参见 [`config/config.yaml`](https://github.com/Nativu5/Gemini-FastAPI/blob/main/config/config.yaml) 文件中的注释。 139 | 140 | ### 环境变量覆盖 141 | 142 | > [!TIP] 143 | > 该功能适用于 Docker 部署和生产环境,可将敏感信息与配置文件分离。 144 | 145 | 你可以通过带有 `CONFIG_` 前缀的环境变量覆盖任意配置项,嵌套键用双下划线(`__`)分隔,例如: 146 | 147 | ```bash 148 | # 覆盖服务器设置 149 | export CONFIG_SERVER__API_KEY="your-secure-api-key" 150 | 151 | # 覆盖 Client 0 的用户凭据 152 | export CONFIG_GEMINI__CLIENTS__0__ID="client-a" 153 | export CONFIG_GEMINI__CLIENTS__0__SECURE_1PSID="your-secure-1psid" 154 | export CONFIG_GEMINI__CLIENTS__0__SECURE_1PSIDTS="your-secure-1psidts" 155 | 156 | # 覆盖 Client 0 的代理设置 157 | export CONFIG_GEMINI__CLIENTS__0__PROXY="socks5://127.0.0.1:1080" 158 | 159 | # 覆盖对话存储大小限制 160 | export CONFIG_STORAGE__MAX_SIZE=268435456 # 256 MB 161 | ``` 162 | 163 | ### 客户端 ID 与会话重用 164 | 165 | 会话在保存时会绑定创建它的客户端 ID。请在配置中保持这些 `id` 值稳定, 166 | 这样在更新 Cookie 列表时依然可以复用旧会话。 167 | 168 | ### Gemini 凭据 169 | 170 | > [!WARNING] 171 | > 请妥善保管这些凭据,切勿提交到版本控制。这些 Cookie 可访问你的 Google 账号。 172 | 173 | 使用 Gemini-FastAPI 需提取 Gemini 会话 Cookie: 174 | 175 | 1. 在无痕/隐私窗口打开 [Gemini](https://gemini.google.com/) 并登录 176 | 2. 打开开发者工具(F12) 177 | 3. 进入 **Application** → **Storage** → **Cookies** 178 | 4. 查找并复制以下值: 179 | - `__Secure-1PSID` 180 | - `__Secure-1PSIDTS` 181 | 182 | > [!TIP] 183 | > 详细操作请参考 [HanaokaYuzu/Gemini-API 认证指南](https://github.com/HanaokaYuzu/Gemini-API?tab=readme-ov-file#authentication)。 184 | 185 | ### 代理设置 186 | 187 | 每个客户端条目可以配置不同的代理,从而规避速率限制。省略 `proxy` 字段或将其设置为 `null` 或空字符串以保持直连。 188 | 189 | ## 鸣谢 190 | 191 | - [HanaokaYuzu/Gemini-API](https://github.com/HanaokaYuzu/Gemini-API) - 底层 Gemini Web API 客户端 192 | - [zhiyu1998/Gemi2Api-Server](https://github.com/zhiyu1998/Gemi2Api-Server) - 本项目最初基于此仓库,经过深度重构与工程化改进,现已成为独立项目,并增加了多轮会话复用等新特性。在此表示特别感谢。 193 | 194 | ## 免责声明 195 | 196 | 本项目与 Google 或 OpenAI 无关,仅供学习和研究使用。本项目使用了逆向工程 API,可能不符合 Google 服务条款。使用风险自负。 -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Gemini-FastAPI 2 | 3 | [![Python 3.12](https://img.shields.io/badge/python-3.12+-blue.svg)](https://www.python.org/downloads/) 4 | [![FastAPI](https://img.shields.io/badge/FastAPI-0.115+-green.svg)](https://fastapi.tiangolo.com/) 5 | [![License](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE) 6 | 7 | [ English | [中文](README.zh.md) ] 8 | 9 | Web-based Gemini models wrapped into an OpenAI-compatible API. Powered by [HanaokaYuzu/Gemini-API](https://github.com/HanaokaYuzu/Gemini-API). 10 | 11 | **✅ Call Gemini's web-based models via API without an API Key, completely free!** 12 | 13 | ## Features 14 | 15 | - **🔐 No Google API Key Required**: Use web cookies to freely access Gemini's models via API. 16 | - **🔍 Google Search Included**: Get up-to-date answers using web-based Gemini's search capabilities. 17 | - **💾 Conversation Persistence**: LMDB-based storage supporting multi-turn conversations. 18 | - **🖼️ Multi-modal Support**: Support for handling text, images, and file uploads. 19 | - **⚖️ Multi-account Load Balancing**: Distribute requests across multiple accounts with per-account proxy settings. 20 | 21 | ## Quick Start 22 | 23 | **For Docker deployment, see the [Docker Deployment](#docker-deployment) section below.** 24 | 25 | ### Prerequisites 26 | 27 | - Python 3.12 28 | - Google account with Gemini access on web 29 | - `secure_1psid` and `secure_1psidts` cookies from Gemini web interface 30 | 31 | ### Installation 32 | 33 | #### Using uv (Recommended) 34 | 35 | ```bash 36 | git clone https://github.com/Nativu5/Gemini-FastAPI.git 37 | cd Gemini-FastAPI 38 | uv sync 39 | ``` 40 | 41 | #### Using pip 42 | 43 | ```bash 44 | git clone https://github.com/Nativu5/Gemini-FastAPI.git 45 | cd Gemini-FastAPI 46 | pip install -e . 47 | ``` 48 | 49 | ### Configuration 50 | 51 | Edit `config/config.yaml` and provide at least one credential pair: 52 | 53 | ```yaml 54 | gemini: 55 | clients: 56 | - id: "client-a" 57 | secure_1psid: "YOUR_SECURE_1PSID_HERE" 58 | secure_1psidts: "YOUR_SECURE_1PSIDTS_HERE" 59 | proxy: null # Optional proxy URL (null/empty keeps direct connection) 60 | ``` 61 | 62 | > [!NOTE] 63 | > For details, refer to the [Configuration](#configuration-1) section below. 64 | 65 | ### Running the Server 66 | 67 | ```bash 68 | # Using uv 69 | uv run python run.py 70 | 71 | # Using Python directly 72 | python run.py 73 | ``` 74 | 75 | The server will start on `http://localhost:8000` by default. 76 | 77 | ## Docker Deployment 78 | 79 | ### Run with Options 80 | 81 | ```bash 82 | docker run -p 8000:8000 \ 83 | -v $(pwd)/data:/app/data \ 84 | -v $(pwd)/cache:/app/cache \ 85 | -e CONFIG_SERVER__API_KEY="your-api-key-here" \ 86 | -e CONFIG_GEMINI__CLIENTS__0__ID="client-a" \ 87 | -e CONFIG_GEMINI__CLIENTS__0__SECURE_1PSID="your-secure-1psid" \ 88 | -e CONFIG_GEMINI__CLIENTS__0__SECURE_1PSIDTS="your-secure-1psidts" \ 89 | -e GEMINI_COOKIE_PATH="/app/cache" \ 90 | ghcr.io/nativu5/gemini-fastapi 91 | ``` 92 | 93 | > [!TIP] 94 | > Add `CONFIG_GEMINI__CLIENTS__N__PROXY` only if you need a proxy; omit the variable to keep direct connections. 95 | > 96 | > `GEMINI_COOKIE_PATH` points to the directory inside the container where refreshed cookies are stored. Bind-mounting it (e.g. `-v $(pwd)/cache:/app/cache`) preserves those cookies across container rebuilds/recreations so you rarely need to re-authenticate. 97 | 98 | ### Run with Docker Compose 99 | 100 | Create a `docker-compose.yml` file: 101 | 102 | ```yaml 103 | services: 104 | gemini-fastapi: 105 | image: ghcr.io/nativu5/gemini-fastapi:latest 106 | ports: 107 | - "8000:8000" 108 | volumes: 109 | # - ./config:/app/config # Uncomment to use a custom config file 110 | # - ./certs:/app/certs # Uncomment to enable HTTPS with your certs 111 | - ./data:/app/data 112 | - ./cache:/app/cache 113 | environment: 114 | - CONFIG_SERVER__HOST=0.0.0.0 115 | - CONFIG_SERVER__PORT=8000 116 | - CONFIG_SERVER__API_KEY=${API_KEY} 117 | - CONFIG_GEMINI__CLIENTS__0__ID=client-a 118 | - CONFIG_GEMINI__CLIENTS__0__SECURE_1PSID=${SECURE_1PSID} 119 | - CONFIG_GEMINI__CLIENTS__0__SECURE_1PSIDTS=${SECURE_1PSIDTS} 120 | - GEMINI_COOKIE_PATH=/app/cache # must match the cache volume mount above 121 | restart: on-failure:3 # Avoid retrying too many times 122 | ``` 123 | 124 | Then run: 125 | 126 | ```bash 127 | docker compose up -d 128 | ``` 129 | 130 | > [!IMPORTANT] 131 | > Make sure to mount the `/app/data` volume to persist conversation data between container restarts. 132 | > Also mount `/app/cache` so refreshed cookies (including rotated 1PSIDTS values) survive container rebuilds/recreates without re-auth. 133 | 134 | ## Configuration 135 | 136 | The server reads a YAML configuration file located at `config/config.yaml`. 137 | 138 | For details on each configuration option, refer to the comments in the [`config/config.yaml`](https://github.com/Nativu5/Gemini-FastAPI/blob/main/config/config.yaml) file. 139 | 140 | ### Environment Variable Overrides 141 | 142 | > [!TIP] 143 | > This feature is particularly useful for Docker deployments and production environments where you want to keep sensitive credentials separate from configuration files. 144 | 145 | You can override any configuration option using environment variables with the `CONFIG_` prefix. Use double underscores (`__`) to represent nested keys, for example: 146 | 147 | ```bash 148 | # Override server settings 149 | export CONFIG_SERVER__API_KEY="your-secure-api-key" 150 | 151 | # Override Gemini credentials for client 0 152 | export CONFIG_GEMINI__CLIENTS__0__ID="client-a" 153 | export CONFIG_GEMINI__CLIENTS__0__SECURE_1PSID="your-secure-1psid" 154 | export CONFIG_GEMINI__CLIENTS__0__SECURE_1PSIDTS="your-secure-1psidts" 155 | 156 | # Override optional proxy settings for client 0 157 | export CONFIG_GEMINI__CLIENTS__0__PROXY="socks5://127.0.0.1:1080" 158 | 159 | # Override conversation storage size limit 160 | export CONFIG_STORAGE__MAX_SIZE=268435456 # 256 MB 161 | ``` 162 | 163 | ### Client IDs and Conversation Reuse 164 | 165 | Conversations are stored with the ID of the client that generated them. 166 | Keep these identifiers stable in your configuration so that sessions remain valid 167 | when you update the cookie list. 168 | 169 | ### Gemini Credentials 170 | 171 | > [!WARNING] 172 | > Keep these credentials secure and never commit them to version control. These cookies provide access to your Google account. 173 | 174 | To use Gemini-FastAPI, you need to extract your Gemini session cookies: 175 | 176 | 1. Open [Gemini](https://gemini.google.com/) in a private/incognito browser window and sign in 177 | 2. Open Developer Tools (F12) 178 | 3. Navigate to **Application** → **Storage** → **Cookies** 179 | 4. Find and copy the values for: 180 | - `__Secure-1PSID` 181 | - `__Secure-1PSIDTS` 182 | 183 | > [!TIP] 184 | > For detailed instructions, refer to the [HanaokaYuzu/Gemini-API authentication guide](https://github.com/HanaokaYuzu/Gemini-API?tab=readme-ov-file#authentication). 185 | 186 | ### Proxy Settings 187 | 188 | Each client entry can be configured with a different proxy to work around rate limits. Omit the `proxy` field or set it to `null` or an empty string to keep a direct connection. 189 | 190 | ## Acknowledgments 191 | 192 | - [HanaokaYuzu/Gemini-API](https://github.com/HanaokaYuzu/Gemini-API) - The underlying Gemini web API client 193 | - [zhiyu1998/Gemi2Api-Server](https://github.com/zhiyu1998/Gemi2Api-Server) - This project originated from this repository. After extensive refactoring and engineering improvements, it has evolved into an independent project, featuring multi-turn conversation reuse among other enhancements. Special thanks for the inspiration and foundational work provided. 194 | 195 | ## Disclaimer 196 | 197 | This project is not affiliated with Google or OpenAI and is intended solely for educational and research purposes. It uses reverse-engineered APIs and may not comply with Google's Terms of Service. Use at your own risk. 198 | -------------------------------------------------------------------------------- /app/utils/config.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sys 3 | from typing import Literal, Optional 4 | 5 | from loguru import logger 6 | from pydantic import BaseModel, Field, ValidationError, field_validator 7 | from pydantic_settings import ( 8 | BaseSettings, 9 | SettingsConfigDict, 10 | YamlConfigSettingsSource, 11 | ) 12 | 13 | CONFIG_PATH = "config/config.yaml" 14 | 15 | 16 | class HTTPSConfig(BaseModel): 17 | """HTTPS configuration""" 18 | 19 | enabled: bool = Field(default=False, description="Enable HTTPS") 20 | key_file: str = Field(default="certs/privkey.pem", description="SSL private key file path") 21 | cert_file: str = Field(default="certs/fullchain.pem", description="SSL certificate file path") 22 | 23 | 24 | class ServerConfig(BaseModel): 25 | """Server configuration""" 26 | 27 | host: str = Field(default="0.0.0.0", description="Server host address") 28 | port: int = Field(default=8000, ge=1, le=65535, description="Server port number") 29 | api_key: Optional[str] = Field( 30 | default=None, 31 | description="API key for authentication, if set, will enable API key validation", 32 | ) 33 | https: HTTPSConfig = Field(default=HTTPSConfig(), description="HTTPS configuration") 34 | 35 | 36 | class GeminiClientSettings(BaseModel): 37 | """Credential set for one Gemini client.""" 38 | 39 | id: str = Field(..., description="Unique identifier for the client") 40 | secure_1psid: str = Field(..., description="Gemini Secure 1PSID") 41 | secure_1psidts: str = Field(..., description="Gemini Secure 1PSIDTS") 42 | proxy: Optional[str] = Field(default=None, description="Proxy URL for this Gemini client") 43 | 44 | @field_validator("proxy", mode="before") 45 | @classmethod 46 | def _blank_proxy_to_none(cls, value: Optional[str]) -> Optional[str]: 47 | if value is None: 48 | return None 49 | stripped = value.strip() 50 | return stripped or None 51 | 52 | 53 | class GeminiConfig(BaseModel): 54 | """Gemini API configuration""" 55 | 56 | clients: list[GeminiClientSettings] = Field( 57 | ..., description="List of Gemini client credential pairs" 58 | ) 59 | timeout: int = Field(default=120, ge=1, description="Init timeout") 60 | auto_refresh: bool = Field(True, description="Enable auto-refresh for Gemini cookies") 61 | refresh_interval: int = Field( 62 | default=540, ge=1, description="Interval in seconds to refresh Gemini cookies" 63 | ) 64 | verbose: bool = Field(False, description="Enable verbose logging for Gemini API requests") 65 | max_chars_per_request: int = Field( 66 | default=1_000_000, 67 | ge=1, 68 | description="Maximum characters Gemini Web can accept per request", 69 | ) 70 | 71 | 72 | class CORSConfig(BaseModel): 73 | """CORS configuration""" 74 | 75 | enabled: bool = Field(default=True, description="Enable CORS support") 76 | allow_origins: list[str] = Field( 77 | default=["*"], description="List of allowed origins for CORS requests" 78 | ) 79 | allow_credentials: bool = Field(default=True, description="Allow credentials in CORS requests") 80 | allow_methods: list[str] = Field( 81 | default=["*"], description="List of allowed HTTP methods for CORS requests" 82 | ) 83 | allow_headers: list[str] = Field( 84 | default=["*"], description="List of allowed headers for CORS requests" 85 | ) 86 | 87 | 88 | class StorageConfig(BaseModel): 89 | """LMDB Storage configuration""" 90 | 91 | path: str = Field( 92 | default="data/lmdb", 93 | description="Path to the storage directory where data will be saved", 94 | ) 95 | max_size: int = Field( 96 | default=1024**2 * 256, # 256 MB 97 | ge=1, 98 | description="Maximum size of the storage in bytes", 99 | ) 100 | retention_days: int = Field( 101 | default=14, 102 | ge=0, 103 | description="Number of days to retain conversations before automatic cleanup (0 disables cleanup)", 104 | ) 105 | 106 | 107 | class LoggingConfig(BaseModel): 108 | """Logging configuration""" 109 | 110 | level: Literal["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"] = Field( 111 | default="DEBUG", 112 | description="Logging level", 113 | ) 114 | 115 | 116 | class Config(BaseSettings): 117 | """Application configuration""" 118 | 119 | # Server configuration 120 | server: ServerConfig = Field( 121 | default=ServerConfig(), 122 | description="Server configuration, including host, port, and API key", 123 | ) 124 | 125 | # CORS configuration 126 | cors: CORSConfig = Field( 127 | default=CORSConfig(), 128 | description="CORS configuration, allows cross-origin requests", 129 | ) 130 | 131 | # Gemini API configuration 132 | gemini: GeminiConfig = Field(..., description="Gemini API configuration, must be set") 133 | 134 | storage: StorageConfig = Field( 135 | default=StorageConfig(), 136 | description="Storage configuration, defines where and how data will be stored", 137 | ) 138 | 139 | # Logging configuration 140 | logging: LoggingConfig = Field( 141 | default=LoggingConfig(), 142 | description="Logging configuration", 143 | ) 144 | 145 | model_config = SettingsConfigDict( 146 | env_prefix="CONFIG_", 147 | env_nested_delimiter="__", 148 | nested_model_default_partial_update=True, 149 | yaml_file=os.getenv("CONFIG_PATH", CONFIG_PATH), 150 | ) 151 | 152 | @classmethod 153 | def settings_customise_sources( 154 | cls, 155 | settings_cls, 156 | init_settings, 157 | env_settings, 158 | dotenv_settings, 159 | file_secret_settings, 160 | ): 161 | """Read settings: env -> yaml -> default""" 162 | return ( 163 | env_settings, 164 | YamlConfigSettingsSource(settings_cls), 165 | ) 166 | 167 | 168 | def extract_gemini_clients_env() -> dict[int, dict[str, str]]: 169 | """Extract and remove all Gemini clients related environment variables, return a mapping from index to field dict.""" 170 | prefix = "CONFIG_GEMINI__CLIENTS__" 171 | env_overrides: dict[int, dict[str, str]] = {} 172 | to_delete = [] 173 | for k, v in os.environ.items(): 174 | if k.startswith(prefix): 175 | parts = k.split("__") 176 | if len(parts) < 4: 177 | continue 178 | index_str, field = parts[2], parts[3].lower() 179 | if not index_str.isdigit(): 180 | continue 181 | idx = int(index_str) 182 | env_overrides.setdefault(idx, {})[field] = v 183 | to_delete.append(k) 184 | # Remove these environment variables to avoid Pydantic parsing errors 185 | for k in to_delete: 186 | del os.environ[k] 187 | return env_overrides 188 | 189 | 190 | def _merge_clients_with_env( 191 | base_clients: list[GeminiClientSettings] | None, 192 | env_overrides: dict[int, dict[str, str]], 193 | ): 194 | """Override base_clients with env_overrides, return the new clients list.""" 195 | if not env_overrides: 196 | return base_clients 197 | result_clients: list[GeminiClientSettings] = [] 198 | if base_clients: 199 | result_clients = [client.model_copy() for client in base_clients] 200 | for idx in sorted(env_overrides): 201 | overrides = env_overrides[idx] 202 | if idx < len(result_clients): 203 | client_dict = result_clients[idx].model_dump() 204 | client_dict.update(overrides) 205 | result_clients[idx] = GeminiClientSettings(**client_dict) 206 | elif idx == len(result_clients): 207 | new_client = GeminiClientSettings(**overrides) 208 | result_clients.append(new_client) 209 | else: 210 | raise IndexError(f"Client index {idx} in env is out of range.") 211 | return result_clients if result_clients else base_clients 212 | 213 | 214 | def initialize_config() -> Config: 215 | """ 216 | Initialize the configuration. 217 | 218 | Returns: 219 | Config: Configuration object 220 | """ 221 | try: 222 | # First, extract and remove Gemini clients related environment variables 223 | env_clients_overrides = extract_gemini_clients_env() 224 | 225 | # Then, initialize Config with pydantic_settings 226 | config = Config() # type: ignore 227 | 228 | # Synthesize clients 229 | config.gemini.clients = _merge_clients_with_env( 230 | config.gemini.clients, env_clients_overrides 231 | ) # type: ignore 232 | 233 | return config 234 | except ValidationError as e: 235 | logger.error(f"Configuration validation failed: {e!s}") 236 | sys.exit(1) 237 | -------------------------------------------------------------------------------- /app/models/models.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from datetime import datetime 4 | from typing import Any, Dict, List, Literal, Optional, Union 5 | 6 | from pydantic import BaseModel, Field, model_validator 7 | 8 | 9 | class ContentItem(BaseModel): 10 | """Content item model""" 11 | 12 | type: Literal["text", "image_url", "file", "input_audio"] 13 | text: Optional[str] = None 14 | image_url: Optional[Dict[str, str]] = None 15 | input_audio: Optional[Dict[str, Any]] = None 16 | file: Optional[Dict[str, str]] = None 17 | annotations: List[Dict[str, Any]] = Field(default_factory=list) 18 | 19 | 20 | class Message(BaseModel): 21 | """Message model""" 22 | 23 | role: str 24 | content: Union[str, List[ContentItem], None] = None 25 | name: Optional[str] = None 26 | tool_calls: Optional[List["ToolCall"]] = None 27 | refusal: Optional[str] = None 28 | reasoning_content: Optional[str] = None 29 | audio: Optional[Dict[str, Any]] = None 30 | annotations: List[Dict[str, Any]] = Field(default_factory=list) 31 | 32 | 33 | class Choice(BaseModel): 34 | """Choice model""" 35 | 36 | index: int 37 | message: Message 38 | finish_reason: str 39 | logprobs: Optional[Dict[str, Any]] = None 40 | 41 | 42 | class FunctionCall(BaseModel): 43 | """Function call payload""" 44 | 45 | name: str 46 | arguments: str 47 | 48 | 49 | class ToolCall(BaseModel): 50 | """Tool call item""" 51 | 52 | id: str 53 | type: Literal["function"] 54 | function: FunctionCall 55 | 56 | 57 | class ToolFunctionDefinition(BaseModel): 58 | """Function definition for tool.""" 59 | 60 | name: str 61 | description: Optional[str] = None 62 | parameters: Optional[Dict[str, Any]] = None 63 | 64 | 65 | class Tool(BaseModel): 66 | """Tool specification.""" 67 | 68 | type: Literal["function"] 69 | function: ToolFunctionDefinition 70 | 71 | 72 | class ToolChoiceFunctionDetail(BaseModel): 73 | """Detail of a tool choice function.""" 74 | 75 | name: str 76 | 77 | 78 | class ToolChoiceFunction(BaseModel): 79 | """Tool choice forcing a specific function.""" 80 | 81 | type: Literal["function"] 82 | function: ToolChoiceFunctionDetail 83 | 84 | 85 | class Usage(BaseModel): 86 | """Usage statistics model""" 87 | 88 | prompt_tokens: int 89 | completion_tokens: int 90 | total_tokens: int 91 | prompt_tokens_details: Optional[Dict[str, int]] = None 92 | completion_tokens_details: Optional[Dict[str, int]] = None 93 | 94 | 95 | class ModelData(BaseModel): 96 | """Model data model""" 97 | 98 | id: str 99 | object: str = "model" 100 | created: int 101 | owned_by: str = "google" 102 | 103 | 104 | class ChatCompletionRequest(BaseModel): 105 | """Chat completion request model""" 106 | 107 | model: str 108 | messages: List[Message] 109 | stream: Optional[bool] = False 110 | user: Optional[str] = None 111 | temperature: Optional[float] = 0.7 112 | top_p: Optional[float] = 1.0 113 | max_tokens: Optional[int] = None 114 | tools: Optional[List["Tool"]] = None 115 | tool_choice: Optional[ 116 | Union[Literal["none"], Literal["auto"], Literal["required"], "ToolChoiceFunction"] 117 | ] = None 118 | response_format: Optional[Dict[str, Any]] = None 119 | 120 | 121 | class ChatCompletionResponse(BaseModel): 122 | """Chat completion response model""" 123 | 124 | id: str 125 | object: str = "chat.completion" 126 | created: int 127 | model: str 128 | choices: List[Choice] 129 | usage: Usage 130 | 131 | 132 | class ModelListResponse(BaseModel): 133 | """Model list model""" 134 | 135 | object: str = "list" 136 | data: List[ModelData] 137 | 138 | 139 | class HealthCheckResponse(BaseModel): 140 | """Health check response model""" 141 | 142 | ok: bool 143 | storage: Optional[Dict[str, str | int]] = None 144 | clients: Optional[Dict[str, bool]] = None 145 | error: Optional[str] = None 146 | 147 | 148 | class ConversationInStore(BaseModel): 149 | """Conversation model for storing in the database.""" 150 | 151 | created_at: Optional[datetime] = Field(default=None) 152 | updated_at: Optional[datetime] = Field(default=None) 153 | 154 | # NOTE: Gemini Web API do not support changing models once a conversation is created. 155 | model: str = Field(..., description="Model used for the conversation") 156 | client_id: str = Field(..., description="Identifier of the Gemini client") 157 | metadata: list[str | None] = Field( 158 | ..., description="Metadata for Gemini API to locate the conversation" 159 | ) 160 | messages: list[Message] = Field(..., description="Message contents in the conversation") 161 | 162 | 163 | class ResponseInputContent(BaseModel): 164 | """Content item for Responses API input.""" 165 | 166 | type: Literal["input_text", "input_image", "input_file"] 167 | text: Optional[str] = None 168 | image_url: Optional[str] = None 169 | detail: Optional[Literal["auto", "low", "high"]] = None 170 | file_url: Optional[str] = None 171 | file_data: Optional[str] = None 172 | filename: Optional[str] = None 173 | annotations: List[Dict[str, Any]] = Field(default_factory=list) 174 | 175 | @model_validator(mode="before") 176 | @classmethod 177 | def normalize_output_text(cls, data: Any) -> Any: 178 | """Allow output_text (from previous turns) to be treated as input_text.""" 179 | if isinstance(data, dict) and data.get("type") == "output_text": 180 | data["type"] = "input_text" 181 | return data 182 | 183 | 184 | class ResponseInputItem(BaseModel): 185 | """Single input item for Responses API.""" 186 | 187 | type: Optional[Literal["message"]] = "message" 188 | role: Literal["user", "assistant", "system", "developer"] 189 | content: Union[str, List[ResponseInputContent]] 190 | 191 | 192 | class ResponseToolChoice(BaseModel): 193 | """Tool choice enforcing a specific tool in Responses API.""" 194 | 195 | type: Literal["function", "image_generation"] 196 | function: Optional[ToolChoiceFunctionDetail] = None 197 | 198 | 199 | class ResponseImageTool(BaseModel): 200 | """Image generation tool specification for Responses API.""" 201 | 202 | type: Literal["image_generation"] 203 | model: Optional[str] = None 204 | output_format: Optional[str] = None 205 | 206 | 207 | class ResponseCreateRequest(BaseModel): 208 | """Responses API request payload.""" 209 | 210 | model: str 211 | input: Union[str, List[ResponseInputItem]] 212 | instructions: Optional[Union[str, List[ResponseInputItem]]] = None 213 | temperature: Optional[float] = 0.7 214 | top_p: Optional[float] = 1.0 215 | max_output_tokens: Optional[int] = None 216 | stream: Optional[bool] = False 217 | tool_choice: Optional[Union[str, ResponseToolChoice]] = None 218 | tools: Optional[List[Union[Tool, ResponseImageTool]]] = None 219 | store: Optional[bool] = None 220 | user: Optional[str] = None 221 | response_format: Optional[Dict[str, Any]] = None 222 | metadata: Optional[Dict[str, Any]] = None 223 | 224 | 225 | class ResponseUsage(BaseModel): 226 | """Usage statistics for Responses API.""" 227 | 228 | input_tokens: int 229 | output_tokens: int 230 | total_tokens: int 231 | 232 | 233 | class ResponseOutputContent(BaseModel): 234 | """Content item for Responses API output.""" 235 | 236 | type: Literal["output_text"] 237 | text: Optional[str] = "" 238 | annotations: List[Dict[str, Any]] = Field(default_factory=list) 239 | 240 | 241 | class ResponseOutputMessage(BaseModel): 242 | """Assistant message returned by Responses API.""" 243 | 244 | id: str 245 | type: Literal["message"] 246 | role: Literal["assistant"] 247 | content: List[ResponseOutputContent] 248 | 249 | 250 | class ResponseImageGenerationCall(BaseModel): 251 | """Image generation call record emitted in Responses API.""" 252 | 253 | id: str 254 | type: Literal["image_generation_call"] = "image_generation_call" 255 | status: Literal["completed", "in_progress", "generating", "failed"] = "completed" 256 | result: Optional[str] = None 257 | output_format: Optional[str] = None 258 | size: Optional[str] = None 259 | revised_prompt: Optional[str] = None 260 | 261 | 262 | class ResponseToolCall(BaseModel): 263 | """Tool call record emitted in Responses API.""" 264 | 265 | id: str 266 | type: Literal["tool_call"] = "tool_call" 267 | status: Literal["in_progress", "completed", "failed", "requires_action"] = "completed" 268 | function: FunctionCall 269 | 270 | 271 | class ResponseCreateResponse(BaseModel): 272 | """Responses API response payload.""" 273 | 274 | id: str 275 | object: Literal["response"] = "response" 276 | created_at: int 277 | model: str 278 | output: List[Union[ResponseOutputMessage, ResponseImageGenerationCall, ResponseToolCall]] 279 | status: Literal[ 280 | "in_progress", 281 | "completed", 282 | "failed", 283 | "incomplete", 284 | "cancelled", 285 | "requires_action", 286 | ] = "completed" 287 | tool_choice: Optional[Union[str, ResponseToolChoice]] = None 288 | tools: Optional[List[Union[Tool, ResponseImageTool]]] = None 289 | usage: ResponseUsage 290 | error: Optional[Dict[str, Any]] = None 291 | metadata: Optional[Dict[str, Any]] = None 292 | input: Optional[Union[str, List[ResponseInputItem]]] = None 293 | 294 | 295 | # Rebuild models with forward references 296 | Message.model_rebuild() 297 | ToolCall.model_rebuild() 298 | ChatCompletionRequest.model_rebuild() 299 | -------------------------------------------------------------------------------- /app/services/client.py: -------------------------------------------------------------------------------- 1 | import html 2 | import json 3 | import re 4 | from pathlib import Path 5 | from typing import Any, cast 6 | 7 | from gemini_webapi import GeminiClient, ModelOutput 8 | from loguru import logger 9 | 10 | from ..models import Message 11 | from ..utils import g_config 12 | from ..utils.helper import add_tag, save_file_to_tempfile, save_url_to_tempfile 13 | 14 | XML_WRAP_HINT = ( 15 | "\nYou MUST wrap every tool call response inside a single fenced block exactly like:\n" 16 | '```xml\n{"arg": "value"}\n```\n' 17 | "Do not surround the fence with any other text or whitespace; otherwise the call will be ignored.\n" 18 | ) 19 | CODE_BLOCK_HINT = ( 20 | "\nWhenever you include code, markup, or shell snippets, wrap each snippet in a Markdown fenced " 21 | "block and supply the correct language label (for example, ```python ... ``` or ```html ... ```).\n" 22 | "Fence ONLY the actual code/markup; keep all narrative or explanatory text outside the fences.\n" 23 | ) 24 | HTML_ESCAPE_RE = re.compile(r"&(?:lt|gt|amp|quot|apos|#[0-9]+|#x[0-9a-fA-F]+);") 25 | MARKDOWN_ESCAPE_RE = re.compile(r"\\(?=[-\\`*_{}\[\]()#+.!<>])") 26 | CODE_FENCE_RE = re.compile(r"(```.*?```|`[^`\n]+?`)", re.DOTALL) 27 | FILE_PATH_PATTERN = re.compile( 28 | r"^(?=.*[./\\]|.*:\d+|^(?:Dockerfile|Makefile|Jenkinsfile|Procfile|Rakefile|Gemfile|Vagrantfile|Caddyfile|Justfile|LICENSE|README|CONTRIBUTING|CODEOWNERS|AUTHORS|NOTICE|CHANGELOG)$)([a-zA-Z0-9_./\\-]+(?::\d+)?)$", 29 | re.IGNORECASE, 30 | ) 31 | GOOGLE_SEARCH_LINK_PATTERN = re.compile( 32 | r"`?\[`?(.+?)`?`?]\((https://www\.google\.com/search\?q=)([^)]*)\)`?" 33 | ) 34 | _UNSET = object() 35 | 36 | 37 | def _resolve(value: Any, fallback: Any): 38 | return fallback if value is _UNSET else value 39 | 40 | 41 | class GeminiClientWrapper(GeminiClient): 42 | """Gemini client with helper methods.""" 43 | 44 | def __init__(self, client_id: str, **kwargs): 45 | super().__init__(**kwargs) 46 | self.id = client_id 47 | 48 | async def init( 49 | self, 50 | timeout: float = cast(float, _UNSET), 51 | auto_close: bool = False, 52 | close_delay: float = 300, 53 | auto_refresh: bool = cast(bool, _UNSET), 54 | refresh_interval: float = cast(float, _UNSET), 55 | verbose: bool = cast(bool, _UNSET), 56 | ) -> None: 57 | """ 58 | Inject default configuration values. 59 | """ 60 | config = g_config.gemini 61 | timeout = cast(float, _resolve(timeout, config.timeout)) 62 | auto_refresh = cast(bool, _resolve(auto_refresh, config.auto_refresh)) 63 | refresh_interval = cast(float, _resolve(refresh_interval, config.refresh_interval)) 64 | verbose = cast(bool, _resolve(verbose, config.verbose)) 65 | 66 | try: 67 | await super().init( 68 | timeout=timeout, 69 | auto_close=auto_close, 70 | close_delay=close_delay, 71 | auto_refresh=auto_refresh, 72 | refresh_interval=refresh_interval, 73 | verbose=verbose, 74 | ) 75 | except Exception: 76 | logger.exception(f"Failed to initialize GeminiClient {self.id}") 77 | raise 78 | 79 | def running(self) -> bool: 80 | return self._running 81 | 82 | @staticmethod 83 | async def process_message( 84 | message: Message, tempdir: Path | None = None, tagged: bool = True 85 | ) -> tuple[str, list[Path | str]]: 86 | """ 87 | Process a single message and return model input. 88 | """ 89 | files: list[Path | str] = [] 90 | text_fragments: list[str] = [] 91 | 92 | if isinstance(message.content, str): 93 | # Pure text content 94 | if message.content: 95 | text_fragments.append(message.content) 96 | elif isinstance(message.content, list): 97 | # Mixed content 98 | # TODO: Use Pydantic to enforce the value checking 99 | for item in message.content: 100 | if item.type == "text": 101 | # Append multiple text fragments 102 | if item.text: 103 | text_fragments.append(item.text) 104 | 105 | elif item.type == "image_url": 106 | if not item.image_url: 107 | raise ValueError("Image URL cannot be empty") 108 | if url := item.image_url.get("url", None): 109 | files.append(await save_url_to_tempfile(url, tempdir)) 110 | else: 111 | raise ValueError("Image URL must contain 'url' key") 112 | 113 | elif item.type == "file": 114 | if not item.file: 115 | raise ValueError("File cannot be empty") 116 | if file_data := item.file.get("file_data", None): 117 | filename = item.file.get("filename", "") 118 | files.append(await save_file_to_tempfile(file_data, filename, tempdir)) 119 | elif url := item.file.get("url", None): 120 | files.append(await save_url_to_tempfile(url, tempdir)) 121 | else: 122 | raise ValueError("File must contain 'file_data' or 'url' key") 123 | elif message.content is not None: 124 | raise ValueError("Unsupported message content type.") 125 | 126 | if message.tool_calls: 127 | tool_blocks: list[str] = [] 128 | for call in message.tool_calls: 129 | args_text = call.function.arguments.strip() 130 | try: 131 | parsed_args = json.loads(args_text) 132 | args_text = json.dumps(parsed_args, ensure_ascii=False) 133 | except (json.JSONDecodeError, TypeError): 134 | # Leave args_text as is if it is not valid JSON 135 | pass 136 | tool_blocks.append( 137 | f'{args_text}' 138 | ) 139 | 140 | if tool_blocks: 141 | tool_section = "```xml\n" + "\n".join(tool_blocks) + "\n```" 142 | text_fragments.append(tool_section) 143 | 144 | model_input = "\n".join(fragment for fragment in text_fragments if fragment) 145 | 146 | # Add role tag if needed 147 | if model_input: 148 | if tagged: 149 | model_input = add_tag(message.role, model_input) 150 | 151 | return model_input, files 152 | 153 | @staticmethod 154 | async def process_conversation( 155 | messages: list[Message], tempdir: Path | None = None 156 | ) -> tuple[str, list[Path | str]]: 157 | """ 158 | Process the entire conversation and return a formatted string and list of 159 | files. The last message is assumed to be the assistant's response. 160 | """ 161 | # Determine once whether we need to wrap messages with role tags: only required 162 | # if the history already contains assistant/system messages. When every message 163 | # so far is from the user, we can skip tagging entirely. 164 | need_tag = any(m.role != "user" for m in messages) 165 | 166 | conversation: list[str] = [] 167 | files: list[Path | str] = [] 168 | 169 | for msg in messages: 170 | input_part, files_part = await GeminiClientWrapper.process_message( 171 | msg, tempdir, tagged=need_tag 172 | ) 173 | conversation.append(input_part) 174 | files.extend(files_part) 175 | 176 | # Append an opening assistant tag only when we used tags above so that Gemini 177 | # knows where to start its reply. 178 | if need_tag: 179 | conversation.append(add_tag("assistant", "", unclose=True)) 180 | 181 | return "\n".join(conversation), files 182 | 183 | @staticmethod 184 | def extract_output(response: ModelOutput, include_thoughts: bool = True) -> str: 185 | """ 186 | Extract and format the output text from the Gemini response. 187 | """ 188 | text = "" 189 | 190 | if include_thoughts and response.thoughts: 191 | text += f"{response.thoughts}\n" 192 | 193 | if response.text: 194 | text += response.text 195 | else: 196 | text += str(response) 197 | 198 | # Fix some escaped characters 199 | def _unescape_html(text_content: str) -> str: 200 | parts: list[str] = [] 201 | last_index = 0 202 | for match in CODE_FENCE_RE.finditer(text_content): 203 | non_code = text_content[last_index : match.start()] 204 | if non_code: 205 | parts.append(HTML_ESCAPE_RE.sub(lambda m: html.unescape(m.group(0)), non_code)) 206 | parts.append(match.group(0)) 207 | last_index = match.end() 208 | tail = text_content[last_index:] 209 | if tail: 210 | parts.append(HTML_ESCAPE_RE.sub(lambda m: html.unescape(m.group(0)), tail)) 211 | return "".join(parts) 212 | 213 | def _unescape_markdown(text_content: str) -> str: 214 | parts: list[str] = [] 215 | last_index = 0 216 | for match in CODE_FENCE_RE.finditer(text_content): 217 | non_code = text_content[last_index : match.start()] 218 | if non_code: 219 | parts.append(MARKDOWN_ESCAPE_RE.sub("", non_code)) 220 | parts.append(match.group(0)) 221 | last_index = match.end() 222 | tail = text_content[last_index:] 223 | if tail: 224 | parts.append(MARKDOWN_ESCAPE_RE.sub("", tail)) 225 | return "".join(parts) 226 | 227 | text = _unescape_html(text) 228 | text = _unescape_markdown(text) 229 | 230 | def extract_file_path_from_display_text(text_content: str) -> str | None: 231 | match = re.match(FILE_PATH_PATTERN, text_content) 232 | if match: 233 | return match.group(1) 234 | return None 235 | 236 | def replacer(match: re.Match) -> str: 237 | display_text = str(match.group(1)).strip() 238 | google_search_prefix = match.group(2) 239 | query_part = match.group(3) 240 | 241 | file_path = extract_file_path_from_display_text(display_text) 242 | 243 | if file_path: 244 | # If it's a file path, transform it into a self-referencing Markdown link 245 | return f"[`{file_path}`]({file_path})" 246 | else: 247 | # Otherwise, reconstruct the original Google search link with the display_text 248 | original_google_search_url = f"{google_search_prefix}{query_part}" 249 | return f"[`{display_text}`]({original_google_search_url})" 250 | 251 | return re.sub(GOOGLE_SEARCH_LINK_PATTERN, replacer, text) 252 | -------------------------------------------------------------------------------- /app/services/lmdb.py: -------------------------------------------------------------------------------- 1 | import hashlib 2 | import re 3 | from contextlib import contextmanager 4 | from datetime import datetime, timedelta 5 | from pathlib import Path 6 | from typing import Any, Dict, List, Optional 7 | 8 | import lmdb 9 | import orjson 10 | from loguru import logger 11 | 12 | from ..models import ConversationInStore, Message 13 | from ..utils import g_config 14 | from ..utils.singleton import Singleton 15 | 16 | 17 | def _hash_message(message: Message) -> str: 18 | """Generate a hash for a single message.""" 19 | # Convert message to dict and sort keys for consistent hashing 20 | message_dict = message.model_dump(mode="json") 21 | message_bytes = orjson.dumps(message_dict, option=orjson.OPT_SORT_KEYS) 22 | return hashlib.sha256(message_bytes).hexdigest() 23 | 24 | 25 | def _hash_conversation(client_id: str, model: str, messages: List[Message]) -> str: 26 | """Generate a hash for a list of messages and client id.""" 27 | # Create a combined hash from all individual message hashes 28 | combined_hash = hashlib.sha256() 29 | combined_hash.update(client_id.encode("utf-8")) 30 | combined_hash.update(model.encode("utf-8")) 31 | for message in messages: 32 | message_hash = _hash_message(message) 33 | combined_hash.update(message_hash.encode("utf-8")) 34 | return combined_hash.hexdigest() 35 | 36 | 37 | class LMDBConversationStore(metaclass=Singleton): 38 | """LMDB-based storage for Message lists with hash-based key-value operations.""" 39 | 40 | HASH_LOOKUP_PREFIX = "hash:" 41 | 42 | def __init__( 43 | self, 44 | db_path: Optional[str] = None, 45 | max_db_size: Optional[int] = None, 46 | retention_days: Optional[int] = None, 47 | ): 48 | """ 49 | Initialize LMDB store. 50 | 51 | Args: 52 | db_path: Path to LMDB database directory 53 | max_db_size: Maximum database size in bytes (default: 256 MB) 54 | retention_days: Number of days to retain conversations (default: 14, 0 disables cleanup) 55 | """ 56 | 57 | if db_path is None: 58 | db_path = g_config.storage.path 59 | if max_db_size is None: 60 | max_db_size = g_config.storage.max_size 61 | if retention_days is None: 62 | retention_days = g_config.storage.retention_days 63 | 64 | self.db_path: Path = Path(db_path) 65 | self.max_db_size: int = max_db_size 66 | self.retention_days: int = max(0, int(retention_days)) 67 | self._env: lmdb.Environment | None = None 68 | 69 | self._ensure_db_path() 70 | self._init_environment() 71 | 72 | def _ensure_db_path(self) -> None: 73 | """Ensure database directory exists.""" 74 | self.db_path.parent.mkdir(parents=True, exist_ok=True) 75 | 76 | def _init_environment(self) -> None: 77 | """Initialize LMDB environment.""" 78 | try: 79 | self._env = lmdb.open( 80 | str(self.db_path), 81 | map_size=self.max_db_size, 82 | max_dbs=3, # main, metadata, and index databases 83 | writemap=True, 84 | readahead=False, 85 | meminit=False, 86 | ) 87 | logger.info(f"LMDB environment initialized at {self.db_path}") 88 | except Exception as e: 89 | logger.error(f"Failed to initialize LMDB environment: {e}") 90 | raise 91 | 92 | @contextmanager 93 | def _get_transaction(self, write: bool = False): 94 | """Get LMDB transaction context manager.""" 95 | if not self._env: 96 | raise RuntimeError("LMDB environment not initialized") 97 | 98 | txn: lmdb.Transaction = self._env.begin(write=write) 99 | try: 100 | yield txn 101 | if write: 102 | txn.commit() 103 | except Exception: 104 | if write: 105 | txn.abort() 106 | raise 107 | finally: 108 | pass # Transaction is automatically cleaned up 109 | 110 | def store( 111 | self, 112 | conv: ConversationInStore, 113 | custom_key: Optional[str] = None, 114 | ) -> str: 115 | """ 116 | Store a conversation model in LMDB. 117 | 118 | Args: 119 | conv: Conversation model to store 120 | custom_key: Optional custom key, if not provided, hash will be used 121 | 122 | Returns: 123 | str: The key used to store the messages (hash or custom key) 124 | """ 125 | if not conv: 126 | raise ValueError("Messages list cannot be empty") 127 | 128 | # Generate hash for the message list 129 | message_hash = _hash_conversation(conv.client_id, conv.model, conv.messages) 130 | storage_key = custom_key or message_hash 131 | 132 | # Prepare data for storage 133 | now = datetime.now() 134 | if conv.created_at is None: 135 | conv.created_at = now 136 | conv.updated_at = now 137 | 138 | value = orjson.dumps(conv.model_dump(mode="json")) 139 | 140 | try: 141 | with self._get_transaction(write=True) as txn: 142 | # Store main data 143 | txn.put(storage_key.encode("utf-8"), value, overwrite=True) 144 | 145 | # Store hash -> key mapping for reverse lookup 146 | txn.put( 147 | f"{self.HASH_LOOKUP_PREFIX}{message_hash}".encode("utf-8"), 148 | storage_key.encode("utf-8"), 149 | ) 150 | 151 | logger.debug(f"Stored {len(conv.messages)} messages with key: {storage_key}") 152 | return storage_key 153 | 154 | except Exception as e: 155 | logger.error(f"Failed to store conversation: {e}") 156 | raise 157 | 158 | def get(self, key: str) -> Optional[ConversationInStore]: 159 | """ 160 | Retrieve conversation data by key. 161 | 162 | Args: 163 | key: Storage key (hash or custom key) 164 | 165 | Returns: 166 | Conversation or None if not found 167 | """ 168 | try: 169 | with self._get_transaction(write=False) as txn: 170 | data = txn.get(key.encode("utf-8"), default=None) 171 | if not data: 172 | return None 173 | 174 | storage_data = orjson.loads(data) # type: ignore 175 | conv = ConversationInStore.model_validate(storage_data) 176 | 177 | logger.debug(f"Retrieved {len(conv.messages)} messages for key: {key}") 178 | return conv 179 | 180 | except Exception as e: 181 | logger.error(f"Failed to retrieve messages for key {key}: {e}") 182 | return None 183 | 184 | def find(self, model: str, messages: List[Message]) -> Optional[ConversationInStore]: 185 | """ 186 | Search conversation data by message list. 187 | 188 | Args: 189 | model: Model name of the conversations 190 | messages: List of messages to search for 191 | 192 | Returns: 193 | Conversation or None if not found 194 | """ 195 | if not messages: 196 | return None 197 | 198 | # --- Find with raw messages --- 199 | if conv := self._find_by_message_list(model, messages): 200 | logger.debug("Found conversation with raw message history.") 201 | return conv 202 | 203 | # --- Find with cleaned messages --- 204 | cleaned_messages = self.sanitize_assistant_messages(messages) 205 | if conv := self._find_by_message_list(model, cleaned_messages): 206 | logger.debug("Found conversation with cleaned message history.") 207 | return conv 208 | 209 | logger.debug("No conversation found for either raw or cleaned history.") 210 | return None 211 | 212 | def _find_by_message_list( 213 | self, model: str, messages: List[Message] 214 | ) -> Optional[ConversationInStore]: 215 | """Internal find implementation based on a message list.""" 216 | for c in g_config.gemini.clients: 217 | message_hash = _hash_conversation(c.id, model, messages) 218 | 219 | key = f"{self.HASH_LOOKUP_PREFIX}{message_hash}" 220 | try: 221 | with self._get_transaction(write=False) as txn: 222 | if mapped := txn.get(key.encode("utf-8")): # type: ignore 223 | return self.get(mapped.decode("utf-8")) # type: ignore 224 | except Exception as e: 225 | logger.error( 226 | f"Failed to retrieve messages by message list for hash {message_hash} and client {c.id}: {e}" 227 | ) 228 | continue 229 | 230 | if conv := self.get(message_hash): 231 | return conv 232 | return None 233 | 234 | def exists(self, key: str) -> bool: 235 | """ 236 | Check if a key exists in the store. 237 | 238 | Args: 239 | key: Storage key to check 240 | 241 | Returns: 242 | bool: True if key exists, False otherwise 243 | """ 244 | try: 245 | with self._get_transaction(write=False) as txn: 246 | return txn.get(key.encode("utf-8")) is not None 247 | except Exception as e: 248 | logger.error(f"Failed to check existence of key {key}: {e}") 249 | return False 250 | 251 | def delete(self, key: str) -> Optional[ConversationInStore]: 252 | """ 253 | Delete conversation model by key. 254 | 255 | Args: 256 | key: Storage key to delete 257 | 258 | Returns: 259 | ConversationInStore: The deleted conversation data, or None if not found 260 | """ 261 | try: 262 | with self._get_transaction(write=True) as txn: 263 | # Get data first to clean up hash mapping 264 | data = txn.get(key.encode("utf-8")) 265 | if not data: 266 | return None 267 | 268 | storage_data = orjson.loads(data) # type: ignore 269 | conv = ConversationInStore.model_validate(storage_data) 270 | message_hash = _hash_conversation(conv.client_id, conv.model, conv.messages) 271 | 272 | # Delete main data 273 | txn.delete(key.encode("utf-8")) 274 | 275 | # Clean up hash mapping if it exists 276 | if message_hash and key != message_hash: 277 | txn.delete(f"{self.HASH_LOOKUP_PREFIX}{message_hash}".encode("utf-8")) 278 | 279 | logger.debug(f"Deleted messages with key: {key}") 280 | return conv 281 | 282 | except Exception as e: 283 | logger.error(f"Failed to delete key {key}: {e}") 284 | return None 285 | 286 | def keys(self, prefix: str = "", limit: Optional[int] = None) -> List[str]: 287 | """ 288 | List all keys in the store, optionally filtered by prefix. 289 | 290 | Args: 291 | prefix: Optional prefix to filter keys 292 | limit: Optional limit on number of keys returned 293 | 294 | Returns: 295 | List of keys 296 | """ 297 | keys = [] 298 | try: 299 | with self._get_transaction(write=False) as txn: 300 | cursor = txn.cursor() 301 | cursor.first() 302 | 303 | count = 0 304 | for key, _ in cursor: 305 | key_str = key.decode("utf-8") 306 | # Skip internal hash mappings 307 | if key_str.startswith(self.HASH_LOOKUP_PREFIX): 308 | continue 309 | 310 | if not prefix or key_str.startswith(prefix): 311 | keys.append(key_str) 312 | count += 1 313 | 314 | if limit and count >= limit: 315 | break 316 | 317 | except Exception as e: 318 | logger.error(f"Failed to list keys: {e}") 319 | 320 | return keys 321 | 322 | def cleanup_expired(self, retention_days: Optional[int] = None) -> int: 323 | """ 324 | Delete conversations older than the given retention period. 325 | 326 | Args: 327 | retention_days: Optional override for retention period in days. 328 | 329 | Returns: 330 | Number of conversations removed. 331 | """ 332 | retention_value = ( 333 | self.retention_days if retention_days is None else max(0, int(retention_days)) 334 | ) 335 | if retention_value <= 0: 336 | logger.debug("Retention cleanup skipped because retention is disabled.") 337 | return 0 338 | 339 | cutoff = datetime.now() - timedelta(days=retention_value) 340 | expired_entries: list[tuple[str, ConversationInStore]] = [] 341 | 342 | try: 343 | with self._get_transaction(write=False) as txn: 344 | cursor = txn.cursor() 345 | 346 | for key_bytes, value_bytes in cursor: 347 | key_str = key_bytes.decode("utf-8") 348 | if key_str.startswith(self.HASH_LOOKUP_PREFIX): 349 | continue 350 | 351 | try: 352 | storage_data = orjson.loads(value_bytes) # type: ignore[arg-type] 353 | conv = ConversationInStore.model_validate(storage_data) 354 | except Exception as exc: 355 | logger.warning(f"Failed to decode record for key {key_str}: {exc}") 356 | continue 357 | 358 | timestamp = conv.created_at or conv.updated_at 359 | if not timestamp: 360 | continue 361 | 362 | if timestamp < cutoff: 363 | expired_entries.append((key_str, conv)) 364 | except Exception as exc: 365 | logger.error(f"Failed to scan LMDB for retention cleanup: {exc}") 366 | raise 367 | 368 | if not expired_entries: 369 | return 0 370 | 371 | removed = 0 372 | try: 373 | with self._get_transaction(write=True) as txn: 374 | for key_str, conv in expired_entries: 375 | key_bytes = key_str.encode("utf-8") 376 | if not txn.delete(key_bytes): 377 | continue 378 | 379 | message_hash = _hash_conversation(conv.client_id, conv.model, conv.messages) 380 | if message_hash and key_str != message_hash: 381 | txn.delete(f"{self.HASH_LOOKUP_PREFIX}{message_hash}".encode("utf-8")) 382 | removed += 1 383 | except Exception as exc: 384 | logger.error(f"Failed to delete expired conversations: {exc}") 385 | raise 386 | 387 | if removed: 388 | logger.info( 389 | f"LMDB retention cleanup removed {removed} conversation(s) older than {cutoff.isoformat()}." 390 | ) 391 | 392 | return removed 393 | 394 | def stats(self) -> Dict[str, Any]: 395 | """ 396 | Get database statistics. 397 | 398 | Returns: 399 | Dict with database statistics 400 | """ 401 | if not self._env: 402 | logger.error("LMDB environment not initialized") 403 | return {} 404 | 405 | try: 406 | return self._env.stat() 407 | except Exception as e: 408 | logger.error(f"Failed to get database stats: {e}") 409 | return {} 410 | 411 | def close(self) -> None: 412 | """Close the LMDB environment.""" 413 | if self._env: 414 | self._env.close() 415 | self._env = None 416 | logger.info("LMDB environment closed") 417 | 418 | def __del__(self): 419 | """Cleanup on destruction.""" 420 | self.close() 421 | 422 | @staticmethod 423 | def remove_think_tags(text: str) -> str: 424 | """ 425 | Remove ... tags at the start of text and strip whitespace. 426 | """ 427 | cleaned_content = re.sub(r"^(\s*.*?\n?)", "", text, flags=re.DOTALL) 428 | return cleaned_content.strip() 429 | 430 | @staticmethod 431 | def sanitize_assistant_messages(messages: list[Message]) -> list[Message]: 432 | """ 433 | Create a new list of messages with assistant content cleaned of tags. 434 | This is useful for store the chat history. 435 | """ 436 | cleaned_messages = [] 437 | for msg in messages: 438 | if msg.role == "assistant" and isinstance(msg.content, str): 439 | normalized_content = LMDBConversationStore.remove_think_tags(msg.content) 440 | # Only create a new object if content actually changed 441 | if normalized_content != msg.content: 442 | cleaned_msg = Message(role=msg.role, content=normalized_content, name=msg.name) 443 | cleaned_messages.append(cleaned_msg) 444 | else: 445 | cleaned_messages.append(msg) 446 | else: 447 | cleaned_messages.append(msg) 448 | 449 | return cleaned_messages 450 | -------------------------------------------------------------------------------- /uv.lock: -------------------------------------------------------------------------------- 1 | version = 1 2 | revision = 3 3 | requires-python = "==3.12.*" 4 | 5 | [[package]] 6 | name = "annotated-doc" 7 | version = "0.0.4" 8 | source = { registry = "https://pypi.org/simple" } 9 | sdist = { url = "https://files.pythonhosted.org/packages/57/ba/046ceea27344560984e26a590f90bc7f4a75b06701f653222458922b558c/annotated_doc-0.0.4.tar.gz", hash = "sha256:fbcda96e87e9c92ad167c2e53839e57503ecfda18804ea28102353485033faa4", size = 7288, upload-time = "2025-11-10T22:07:42.062Z" } 10 | wheels = [ 11 | { url = "https://files.pythonhosted.org/packages/1e/d3/26bf1008eb3d2daa8ef4cacc7f3bfdc11818d111f7e2d0201bc6e3b49d45/annotated_doc-0.0.4-py3-none-any.whl", hash = "sha256:571ac1dc6991c450b25a9c2d84a3705e2ae7a53467b5d111c24fa8baabbed320", size = 5303, upload-time = "2025-11-10T22:07:40.673Z" }, 12 | ] 13 | 14 | [[package]] 15 | name = "annotated-types" 16 | version = "0.7.0" 17 | source = { registry = "https://pypi.org/simple" } 18 | sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" } 19 | wheels = [ 20 | { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" }, 21 | ] 22 | 23 | [[package]] 24 | name = "anyio" 25 | version = "4.12.0" 26 | source = { registry = "https://pypi.org/simple" } 27 | dependencies = [ 28 | { name = "idna" }, 29 | { name = "typing-extensions" }, 30 | ] 31 | sdist = { url = "https://files.pythonhosted.org/packages/16/ce/8a777047513153587e5434fd752e89334ac33e379aa3497db860eeb60377/anyio-4.12.0.tar.gz", hash = "sha256:73c693b567b0c55130c104d0b43a9baf3aa6a31fc6110116509f27bf75e21ec0", size = 228266, upload-time = "2025-11-28T23:37:38.911Z" } 32 | wheels = [ 33 | { url = "https://files.pythonhosted.org/packages/7f/9c/36c5c37947ebfb8c7f22e0eb6e4d188ee2d53aa3880f3f2744fb894f0cb1/anyio-4.12.0-py3-none-any.whl", hash = "sha256:dad2376a628f98eeca4881fc56cd06affd18f659b17a747d3ff0307ced94b1bb", size = 113362, upload-time = "2025-11-28T23:36:57.897Z" }, 34 | ] 35 | 36 | [[package]] 37 | name = "certifi" 38 | version = "2025.11.12" 39 | source = { registry = "https://pypi.org/simple" } 40 | sdist = { url = "https://files.pythonhosted.org/packages/a2/8c/58f469717fa48465e4a50c014a0400602d3c437d7c0c468e17ada824da3a/certifi-2025.11.12.tar.gz", hash = "sha256:d8ab5478f2ecd78af242878415affce761ca6bc54a22a27e026d7c25357c3316", size = 160538, upload-time = "2025-11-12T02:54:51.517Z" } 41 | wheels = [ 42 | { url = "https://files.pythonhosted.org/packages/70/7d/9bc192684cea499815ff478dfcdc13835ddf401365057044fb721ec6bddb/certifi-2025.11.12-py3-none-any.whl", hash = "sha256:97de8790030bbd5c2d96b7ec782fc2f7820ef8dba6db909ccf95449f2d062d4b", size = 159438, upload-time = "2025-11-12T02:54:49.735Z" }, 43 | ] 44 | 45 | [[package]] 46 | name = "click" 47 | version = "8.3.1" 48 | source = { registry = "https://pypi.org/simple" } 49 | dependencies = [ 50 | { name = "colorama", marker = "sys_platform == 'win32'" }, 51 | ] 52 | 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" } 53 | wheels = [ 54 | { 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" }, 55 | ] 56 | 57 | [[package]] 58 | name = "colorama" 59 | version = "0.4.6" 60 | source = { registry = "https://pypi.org/simple" } 61 | sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } 62 | wheels = [ 63 | { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, 64 | ] 65 | 66 | [[package]] 67 | name = "fastapi" 68 | version = "0.123.10" 69 | source = { registry = "https://pypi.org/simple" } 70 | dependencies = [ 71 | { name = "annotated-doc" }, 72 | { name = "pydantic" }, 73 | { name = "starlette" }, 74 | { name = "typing-extensions" }, 75 | ] 76 | sdist = { url = "https://files.pythonhosted.org/packages/22/ff/e01087de891010089f1620c916c0c13130f3898177955c13e2b02d22ec4a/fastapi-0.123.10.tar.gz", hash = "sha256:624d384d7cda7c096449c889fc776a0571948ba14c3c929fa8e9a78cd0b0a6a8", size = 356360, upload-time = "2025-12-05T21:27:46.237Z" } 77 | wheels = [ 78 | { url = "https://files.pythonhosted.org/packages/d7/f0/7cb92c4a720def85240fd63fbbcf147ce19e7a731c8e1032376bb5a486ac/fastapi-0.123.10-py3-none-any.whl", hash = "sha256:0503b7b7bc71bc98f7c90c9117d21fdf6147c0d74703011b87936becc86985c1", size = 111774, upload-time = "2025-12-05T21:27:44.78Z" }, 79 | ] 80 | 81 | [[package]] 82 | name = "gemini-fastapi" 83 | version = "1.0.0" 84 | source = { virtual = "." } 85 | dependencies = [ 86 | { name = "fastapi" }, 87 | { name = "gemini-webapi" }, 88 | { name = "lmdb" }, 89 | { name = "loguru" }, 90 | { name = "pydantic-settings", extra = ["yaml"] }, 91 | { name = "uvicorn" }, 92 | { name = "uvloop", marker = "sys_platform != 'win32'" }, 93 | ] 94 | 95 | [package.optional-dependencies] 96 | dev = [ 97 | { name = "ruff" }, 98 | ] 99 | 100 | [package.dev-dependencies] 101 | dev = [ 102 | { name = "ruff" }, 103 | ] 104 | 105 | [package.metadata] 106 | requires-dist = [ 107 | { name = "fastapi", specifier = ">=0.115.12" }, 108 | { name = "gemini-webapi", specifier = ">=1.17.0" }, 109 | { name = "lmdb", specifier = ">=1.6.2" }, 110 | { name = "loguru", specifier = ">=0.7.0" }, 111 | { name = "pydantic-settings", extras = ["yaml"], specifier = ">=2.9.1" }, 112 | { name = "ruff", marker = "extra == 'dev'", specifier = ">=0.11.7" }, 113 | { name = "uvicorn", specifier = ">=0.34.1" }, 114 | { name = "uvloop", marker = "sys_platform != 'win32'", specifier = ">=0.21.0" }, 115 | ] 116 | provides-extras = ["dev"] 117 | 118 | [package.metadata.requires-dev] 119 | dev = [{ name = "ruff", specifier = ">=0.11.13" }] 120 | 121 | [[package]] 122 | name = "gemini-webapi" 123 | version = "1.17.3" 124 | source = { registry = "https://pypi.org/simple" } 125 | dependencies = [ 126 | { name = "httpx" }, 127 | { name = "loguru" }, 128 | { name = "orjson" }, 129 | { name = "pydantic" }, 130 | ] 131 | sdist = { url = "https://files.pythonhosted.org/packages/aa/74/1a31f3605250eb5cbcbfb15559c43b0d71734c8d286cfa9a7833841306e3/gemini_webapi-1.17.3.tar.gz", hash = "sha256:6201f9eaf5f562c5dc589d71c0edbba9e2eb8f780febbcf35307697bf474d577", size = 259418, upload-time = "2025-12-05T22:38:44.426Z" } 132 | wheels = [ 133 | { url = "https://files.pythonhosted.org/packages/4c/a3/a88ff45197dce68a81d92c8d40368e4c26f67faf3af3273357f3f71f5c3d/gemini_webapi-1.17.3-py3-none-any.whl", hash = "sha256:d83969b1fa3236f3010d856d191b35264c936ece81f1be4c1de53ec1cf0855c8", size = 56659, upload-time = "2025-12-05T22:38:42.93Z" }, 134 | ] 135 | 136 | [[package]] 137 | name = "h11" 138 | version = "0.16.0" 139 | source = { registry = "https://pypi.org/simple" } 140 | sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" } 141 | wheels = [ 142 | { 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" }, 143 | ] 144 | 145 | [[package]] 146 | name = "httpcore" 147 | version = "1.0.9" 148 | source = { registry = "https://pypi.org/simple" } 149 | dependencies = [ 150 | { name = "certifi" }, 151 | { name = "h11" }, 152 | ] 153 | 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" } 154 | wheels = [ 155 | { 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" }, 156 | ] 157 | 158 | [[package]] 159 | name = "httpx" 160 | version = "0.28.1" 161 | source = { registry = "https://pypi.org/simple" } 162 | dependencies = [ 163 | { name = "anyio" }, 164 | { name = "certifi" }, 165 | { name = "httpcore" }, 166 | { name = "idna" }, 167 | ] 168 | 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" } 169 | wheels = [ 170 | { 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" }, 171 | ] 172 | 173 | [[package]] 174 | name = "idna" 175 | version = "3.11" 176 | source = { registry = "https://pypi.org/simple" } 177 | sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582, upload-time = "2025-10-12T14:55:20.501Z" } 178 | wheels = [ 179 | { url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" }, 180 | ] 181 | 182 | [[package]] 183 | name = "lmdb" 184 | version = "1.7.5" 185 | source = { registry = "https://pypi.org/simple" } 186 | 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" } 187 | wheels = [ 188 | { url = "https://files.pythonhosted.org/packages/34/b4/8b862c4d7fd6f68cb33e2a919169fda8924121dc5ff61e3cc105304a6dd4/lmdb-1.7.5-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b48c2359eea876d7b634b49f84019ecc8c1626da97c795fc7b39a793676815df", size = 100910, upload-time = "2025-10-15T03:39:00.727Z" }, 189 | { url = "https://files.pythonhosted.org/packages/27/64/8ab5da48180d5f13a293ea00a9f8758b1bee080e76ea0ab0d6be0d51b55f/lmdb-1.7.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2f84793baeb430ba984eb6c1b4e08c0a508b1c03e79ce79fcda0f29ecc06a95a", size = 99376, upload-time = "2025-10-15T03:39:01.791Z" }, 190 | { url = "https://files.pythonhosted.org/packages/43/e0/51bc942fe5ed3fce69c631b54f52d97785de3d94487376139be6de1e199a/lmdb-1.7.5-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:68cc21314a33faac1b749645a976b7655e7fa7cc104a72365d2429d2db7f6342", size = 298556, upload-time = "2025-10-15T03:39:02.787Z" }, 191 | { url = "https://files.pythonhosted.org/packages/66/c5/19ea75c88b91d12da5c6f4bbe2aca633047b6b270fd613d557583d32cc5c/lmdb-1.7.5-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f2d9b7e102fcfe5e0cfb3acdebd403eb55ccbe5f7202d8f49d60bdafb1546d1e", size = 299449, upload-time = "2025-10-15T03:39:03.903Z" }, 192 | { url = "https://files.pythonhosted.org/packages/1b/74/365194203dbff47d3a1621366d6a1133cdcce261f4ac0e1d0496f01e6ace/lmdb-1.7.5-cp312-cp312-win_amd64.whl", hash = "sha256:69de89cc79e03e191fc6f95797f1bef91b45c415d1ea9d38872b00b2d989a50f", size = 99328, upload-time = "2025-10-15T03:39:04.949Z" }, 193 | { url = "https://files.pythonhosted.org/packages/3f/3a/a441afebff5bd761f7f58d194fed7ac265279964957479a5c8a51c42f9ad/lmdb-1.7.5-cp312-cp312-win_arm64.whl", hash = "sha256:0c880ee4b309e900f2d58a710701f5e6316a351878588c6a95a9c0bcb640680b", size = 94191, upload-time = "2025-10-15T03:39:05.975Z" }, 194 | { 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" }, 195 | ] 196 | 197 | [[package]] 198 | name = "loguru" 199 | version = "0.7.3" 200 | source = { registry = "https://pypi.org/simple" } 201 | dependencies = [ 202 | { name = "colorama", marker = "sys_platform == 'win32'" }, 203 | { name = "win32-setctime", marker = "sys_platform == 'win32'" }, 204 | ] 205 | sdist = { url = "https://files.pythonhosted.org/packages/3a/05/a1dae3dffd1116099471c643b8924f5aa6524411dc6c63fdae648c4f1aca/loguru-0.7.3.tar.gz", hash = "sha256:19480589e77d47b8d85b2c827ad95d49bf31b0dcde16593892eb51dd18706eb6", size = 63559, upload-time = "2024-12-06T11:20:56.608Z" } 206 | wheels = [ 207 | { 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" }, 208 | ] 209 | 210 | [[package]] 211 | name = "orjson" 212 | version = "3.11.4" 213 | source = { registry = "https://pypi.org/simple" } 214 | sdist = { url = "https://files.pythonhosted.org/packages/c6/fe/ed708782d6709cc60eb4c2d8a361a440661f74134675c72990f2c48c785f/orjson-3.11.4.tar.gz", hash = "sha256:39485f4ab4c9b30a3943cfe99e1a213c4776fb69e8abd68f66b83d5a0b0fdc6d", size = 5945188, upload-time = "2025-10-24T15:50:38.027Z" } 215 | wheels = [ 216 | { url = "https://files.pythonhosted.org/packages/63/51/6b556192a04595b93e277a9ff71cd0cc06c21a7df98bcce5963fa0f5e36f/orjson-3.11.4-cp312-cp312-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:d4371de39319d05d3f482f372720b841c841b52f5385bd99c61ed69d55d9ab50", size = 243571, upload-time = "2025-10-24T15:49:10.008Z" }, 217 | { url = "https://files.pythonhosted.org/packages/1c/2c/2602392ddf2601d538ff11848b98621cd465d1a1ceb9db9e8043181f2f7b/orjson-3.11.4-cp312-cp312-macosx_15_0_arm64.whl", hash = "sha256:e41fd3b3cac850eaae78232f37325ed7d7436e11c471246b87b2cd294ec94853", size = 128891, upload-time = "2025-10-24T15:49:11.297Z" }, 218 | { url = "https://files.pythonhosted.org/packages/4e/47/bf85dcf95f7a3a12bf223394a4f849430acd82633848d52def09fa3f46ad/orjson-3.11.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:600e0e9ca042878c7fdf189cf1b028fe2c1418cc9195f6cb9824eb6ed99cb938", size = 130137, upload-time = "2025-10-24T15:49:12.544Z" }, 219 | { url = "https://files.pythonhosted.org/packages/b4/4d/a0cb31007f3ab6f1fd2a1b17057c7c349bc2baf8921a85c0180cc7be8011/orjson-3.11.4-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7bbf9b333f1568ef5da42bc96e18bf30fd7f8d54e9ae066d711056add508e415", size = 129152, upload-time = "2025-10-24T15:49:13.754Z" }, 220 | { url = "https://files.pythonhosted.org/packages/f7/ef/2811def7ce3d8576b19e3929fff8f8f0d44bc5eb2e0fdecb2e6e6cc6c720/orjson-3.11.4-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4806363144bb6e7297b8e95870e78d30a649fdc4e23fc84daa80c8ebd366ce44", size = 136834, upload-time = "2025-10-24T15:49:15.307Z" }, 221 | { url = "https://files.pythonhosted.org/packages/00/d4/9aee9e54f1809cec8ed5abd9bc31e8a9631d19460e3b8470145d25140106/orjson-3.11.4-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ad355e8308493f527d41154e9053b86a5be892b3b359a5c6d5d95cda23601cb2", size = 137519, upload-time = "2025-10-24T15:49:16.557Z" }, 222 | { url = "https://files.pythonhosted.org/packages/db/ea/67bfdb5465d5679e8ae8d68c11753aaf4f47e3e7264bad66dc2f2249e643/orjson-3.11.4-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c8a7517482667fb9f0ff1b2f16fe5829296ed7a655d04d68cd9711a4d8a4e708", size = 136749, upload-time = "2025-10-24T15:49:17.796Z" }, 223 | { url = "https://files.pythonhosted.org/packages/01/7e/62517dddcfce6d53a39543cd74d0dccfcbdf53967017c58af68822100272/orjson-3.11.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:97eb5942c7395a171cbfecc4ef6701fc3c403e762194683772df4c54cfbb2210", size = 136325, upload-time = "2025-10-24T15:49:19.347Z" }, 224 | { url = "https://files.pythonhosted.org/packages/18/ae/40516739f99ab4c7ec3aaa5cc242d341fcb03a45d89edeeaabc5f69cb2cf/orjson-3.11.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:149d95d5e018bdd822e3f38c103b1a7c91f88d38a88aada5c4e9b3a73a244241", size = 140204, upload-time = "2025-10-24T15:49:20.545Z" }, 225 | { url = "https://files.pythonhosted.org/packages/82/18/ff5734365623a8916e3a4037fcef1cd1782bfc14cf0992afe7940c5320bf/orjson-3.11.4-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:624f3951181eb46fc47dea3d221554e98784c823e7069edb5dbd0dc826ac909b", size = 406242, upload-time = "2025-10-24T15:49:21.884Z" }, 226 | { url = "https://files.pythonhosted.org/packages/e1/43/96436041f0a0c8c8deca6a05ebeaf529bf1de04839f93ac5e7c479807aec/orjson-3.11.4-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:03bfa548cf35e3f8b3a96c4e8e41f753c686ff3d8e182ce275b1751deddab58c", size = 150013, upload-time = "2025-10-24T15:49:23.185Z" }, 227 | { url = "https://files.pythonhosted.org/packages/1b/48/78302d98423ed8780479a1e682b9aecb869e8404545d999d34fa486e573e/orjson-3.11.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:525021896afef44a68148f6ed8a8bf8375553d6066c7f48537657f64823565b9", size = 139951, upload-time = "2025-10-24T15:49:24.428Z" }, 228 | { url = "https://files.pythonhosted.org/packages/4a/7b/ad613fdcdaa812f075ec0875143c3d37f8654457d2af17703905425981bf/orjson-3.11.4-cp312-cp312-win32.whl", hash = "sha256:b58430396687ce0f7d9eeb3dd47761ca7d8fda8e9eb92b3077a7a353a75efefa", size = 136049, upload-time = "2025-10-24T15:49:25.973Z" }, 229 | { url = "https://files.pythonhosted.org/packages/b9/3c/9cf47c3ff5f39b8350fb21ba65d789b6a1129d4cbb3033ba36c8a9023520/orjson-3.11.4-cp312-cp312-win_amd64.whl", hash = "sha256:c6dbf422894e1e3c80a177133c0dda260f81428f9de16d61041949f6a2e5c140", size = 131461, upload-time = "2025-10-24T15:49:27.259Z" }, 230 | { url = "https://files.pythonhosted.org/packages/c6/3b/e2425f61e5825dc5b08c2a5a2b3af387eaaca22a12b9c8c01504f8614c36/orjson-3.11.4-cp312-cp312-win_arm64.whl", hash = "sha256:d38d2bc06d6415852224fcc9c0bfa834c25431e466dc319f0edd56cca81aa96e", size = 126167, upload-time = "2025-10-24T15:49:28.511Z" }, 231 | ] 232 | 233 | [[package]] 234 | name = "pydantic" 235 | version = "2.12.5" 236 | source = { registry = "https://pypi.org/simple" } 237 | dependencies = [ 238 | { name = "annotated-types" }, 239 | { name = "pydantic-core" }, 240 | { name = "typing-extensions" }, 241 | { name = "typing-inspection" }, 242 | ] 243 | sdist = { url = "https://files.pythonhosted.org/packages/69/44/36f1a6e523abc58ae5f928898e4aca2e0ea509b5aa6f6f392a5d882be928/pydantic-2.12.5.tar.gz", hash = "sha256:4d351024c75c0f085a9febbb665ce8c0c6ec5d30e903bdb6394b7ede26aebb49", size = 821591, upload-time = "2025-11-26T15:11:46.471Z" } 244 | wheels = [ 245 | { url = "https://files.pythonhosted.org/packages/5a/87/b70ad306ebb6f9b585f114d0ac2137d792b48be34d732d60e597c2f8465a/pydantic-2.12.5-py3-none-any.whl", hash = "sha256:e561593fccf61e8a20fc46dfc2dfe075b8be7d0188df33f221ad1f0139180f9d", size = 463580, upload-time = "2025-11-26T15:11:44.605Z" }, 246 | ] 247 | 248 | [[package]] 249 | name = "pydantic-core" 250 | version = "2.41.5" 251 | source = { registry = "https://pypi.org/simple" } 252 | dependencies = [ 253 | { name = "typing-extensions" }, 254 | ] 255 | sdist = { url = "https://files.pythonhosted.org/packages/71/70/23b021c950c2addd24ec408e9ab05d59b035b39d97cdc1130e1bce647bb6/pydantic_core-2.41.5.tar.gz", hash = "sha256:08daa51ea16ad373ffd5e7606252cc32f07bc72b28284b6bc9c6df804816476e", size = 460952, upload-time = "2025-11-04T13:43:49.098Z" } 256 | wheels = [ 257 | { url = "https://files.pythonhosted.org/packages/5f/5d/5f6c63eebb5afee93bcaae4ce9a898f3373ca23df3ccaef086d0233a35a7/pydantic_core-2.41.5-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:f41a7489d32336dbf2199c8c0a215390a751c5b014c2c1c5366e817202e9cdf7", size = 2110990, upload-time = "2025-11-04T13:39:58.079Z" }, 258 | { url = "https://files.pythonhosted.org/packages/aa/32/9c2e8ccb57c01111e0fd091f236c7b371c1bccea0fa85247ac55b1e2b6b6/pydantic_core-2.41.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:070259a8818988b9a84a449a2a7337c7f430a22acc0859c6b110aa7212a6d9c0", size = 1896003, upload-time = "2025-11-04T13:39:59.956Z" }, 259 | { url = "https://files.pythonhosted.org/packages/68/b8/a01b53cb0e59139fbc9e4fda3e9724ede8de279097179be4ff31f1abb65a/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e96cea19e34778f8d59fe40775a7a574d95816eb150850a85a7a4c8f4b94ac69", size = 1919200, upload-time = "2025-11-04T13:40:02.241Z" }, 260 | { url = "https://files.pythonhosted.org/packages/38/de/8c36b5198a29bdaade07b5985e80a233a5ac27137846f3bc2d3b40a47360/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ed2e99c456e3fadd05c991f8f437ef902e00eedf34320ba2b0842bd1c3ca3a75", size = 2052578, upload-time = "2025-11-04T13:40:04.401Z" }, 261 | { url = "https://files.pythonhosted.org/packages/00/b5/0e8e4b5b081eac6cb3dbb7e60a65907549a1ce035a724368c330112adfdd/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:65840751b72fbfd82c3c640cff9284545342a4f1eb1586ad0636955b261b0b05", size = 2208504, upload-time = "2025-11-04T13:40:06.072Z" }, 262 | { url = "https://files.pythonhosted.org/packages/77/56/87a61aad59c7c5b9dc8caad5a41a5545cba3810c3e828708b3d7404f6cef/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e536c98a7626a98feb2d3eaf75944ef6f3dbee447e1f841eae16f2f0a72d8ddc", size = 2335816, upload-time = "2025-11-04T13:40:07.835Z" }, 263 | { url = "https://files.pythonhosted.org/packages/0d/76/941cc9f73529988688a665a5c0ecff1112b3d95ab48f81db5f7606f522d3/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eceb81a8d74f9267ef4081e246ffd6d129da5d87e37a77c9bde550cb04870c1c", size = 2075366, upload-time = "2025-11-04T13:40:09.804Z" }, 264 | { url = "https://files.pythonhosted.org/packages/d3/43/ebef01f69baa07a482844faaa0a591bad1ef129253ffd0cdaa9d8a7f72d3/pydantic_core-2.41.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d38548150c39b74aeeb0ce8ee1d8e82696f4a4e16ddc6de7b1d8823f7de4b9b5", size = 2171698, upload-time = "2025-11-04T13:40:12.004Z" }, 265 | { url = "https://files.pythonhosted.org/packages/b1/87/41f3202e4193e3bacfc2c065fab7706ebe81af46a83d3e27605029c1f5a6/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:c23e27686783f60290e36827f9c626e63154b82b116d7fe9adba1fda36da706c", size = 2132603, upload-time = "2025-11-04T13:40:13.868Z" }, 266 | { url = "https://files.pythonhosted.org/packages/49/7d/4c00df99cb12070b6bccdef4a195255e6020a550d572768d92cc54dba91a/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:482c982f814460eabe1d3bb0adfdc583387bd4691ef00b90575ca0d2b6fe2294", size = 2329591, upload-time = "2025-11-04T13:40:15.672Z" }, 267 | { url = "https://files.pythonhosted.org/packages/cc/6a/ebf4b1d65d458f3cda6a7335d141305dfa19bdc61140a884d165a8a1bbc7/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:bfea2a5f0b4d8d43adf9d7b8bf019fb46fdd10a2e5cde477fbcb9d1fa08c68e1", size = 2319068, upload-time = "2025-11-04T13:40:17.532Z" }, 268 | { url = "https://files.pythonhosted.org/packages/49/3b/774f2b5cd4192d5ab75870ce4381fd89cf218af999515baf07e7206753f0/pydantic_core-2.41.5-cp312-cp312-win32.whl", hash = "sha256:b74557b16e390ec12dca509bce9264c3bbd128f8a2c376eaa68003d7f327276d", size = 1985908, upload-time = "2025-11-04T13:40:19.309Z" }, 269 | { url = "https://files.pythonhosted.org/packages/86/45/00173a033c801cacf67c190fef088789394feaf88a98a7035b0e40d53dc9/pydantic_core-2.41.5-cp312-cp312-win_amd64.whl", hash = "sha256:1962293292865bca8e54702b08a4f26da73adc83dd1fcf26fbc875b35d81c815", size = 2020145, upload-time = "2025-11-04T13:40:21.548Z" }, 270 | { url = "https://files.pythonhosted.org/packages/f9/22/91fbc821fa6d261b376a3f73809f907cec5ca6025642c463d3488aad22fb/pydantic_core-2.41.5-cp312-cp312-win_arm64.whl", hash = "sha256:1746d4a3d9a794cacae06a5eaaccb4b8643a131d45fbc9af23e353dc0a5ba5c3", size = 1976179, upload-time = "2025-11-04T13:40:23.393Z" }, 271 | { url = "https://files.pythonhosted.org/packages/09/32/59b0c7e63e277fa7911c2fc70ccfb45ce4b98991e7ef37110663437005af/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:7da7087d756b19037bc2c06edc6c170eeef3c3bafcb8f532ff17d64dc427adfd", size = 2110495, upload-time = "2025-11-04T13:42:49.689Z" }, 272 | { url = "https://files.pythonhosted.org/packages/aa/81/05e400037eaf55ad400bcd318c05bb345b57e708887f07ddb2d20e3f0e98/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:aabf5777b5c8ca26f7824cb4a120a740c9588ed58df9b2d196ce92fba42ff8dc", size = 1915388, upload-time = "2025-11-04T13:42:52.215Z" }, 273 | { url = "https://files.pythonhosted.org/packages/6e/0d/e3549b2399f71d56476b77dbf3cf8937cec5cd70536bdc0e374a421d0599/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c007fe8a43d43b3969e8469004e9845944f1a80e6acd47c150856bb87f230c56", size = 1942879, upload-time = "2025-11-04T13:42:56.483Z" }, 274 | { url = "https://files.pythonhosted.org/packages/f7/07/34573da085946b6a313d7c42f82f16e8920bfd730665de2d11c0c37a74b5/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:76d0819de158cd855d1cbb8fcafdf6f5cf1eb8e470abe056d5d161106e38062b", size = 2139017, upload-time = "2025-11-04T13:42:59.471Z" }, 275 | ] 276 | 277 | [[package]] 278 | name = "pydantic-settings" 279 | version = "2.12.0" 280 | source = { registry = "https://pypi.org/simple" } 281 | dependencies = [ 282 | { name = "pydantic" }, 283 | { name = "python-dotenv" }, 284 | { name = "typing-inspection" }, 285 | ] 286 | sdist = { url = "https://files.pythonhosted.org/packages/43/4b/ac7e0aae12027748076d72a8764ff1c9d82ca75a7a52622e67ed3f765c54/pydantic_settings-2.12.0.tar.gz", hash = "sha256:005538ef951e3c2a68e1c08b292b5f2e71490def8589d4221b95dab00dafcfd0", size = 194184, upload-time = "2025-11-10T14:25:47.013Z" } 287 | wheels = [ 288 | { url = "https://files.pythonhosted.org/packages/c1/60/5d4751ba3f4a40a6891f24eec885f51afd78d208498268c734e256fb13c4/pydantic_settings-2.12.0-py3-none-any.whl", hash = "sha256:fddb9fd99a5b18da837b29710391e945b1e30c135477f484084ee513adb93809", size = 51880, upload-time = "2025-11-10T14:25:45.546Z" }, 289 | ] 290 | 291 | [package.optional-dependencies] 292 | yaml = [ 293 | { name = "pyyaml" }, 294 | ] 295 | 296 | [[package]] 297 | name = "python-dotenv" 298 | version = "1.2.1" 299 | source = { registry = "https://pypi.org/simple" } 300 | sdist = { url = "https://files.pythonhosted.org/packages/f0/26/19cadc79a718c5edbec86fd4919a6b6d3f681039a2f6d66d14be94e75fb9/python_dotenv-1.2.1.tar.gz", hash = "sha256:42667e897e16ab0d66954af0e60a9caa94f0fd4ecf3aaf6d2d260eec1aa36ad6", size = 44221, upload-time = "2025-10-26T15:12:10.434Z" } 301 | wheels = [ 302 | { url = "https://files.pythonhosted.org/packages/14/1b/a298b06749107c305e1fe0f814c6c74aea7b2f1e10989cb30f544a1b3253/python_dotenv-1.2.1-py3-none-any.whl", hash = "sha256:b81ee9561e9ca4004139c6cbba3a238c32b03e4894671e181b671e8cb8425d61", size = 21230, upload-time = "2025-10-26T15:12:09.109Z" }, 303 | ] 304 | 305 | [[package]] 306 | name = "pyyaml" 307 | version = "6.0.3" 308 | source = { registry = "https://pypi.org/simple" } 309 | sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } 310 | wheels = [ 311 | { url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" }, 312 | { url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" }, 313 | { url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" }, 314 | { url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" }, 315 | { url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" }, 316 | { url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" }, 317 | { url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" }, 318 | { url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" }, 319 | { url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" }, 320 | { url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" }, 321 | ] 322 | 323 | [[package]] 324 | name = "ruff" 325 | version = "0.14.8" 326 | source = { registry = "https://pypi.org/simple" } 327 | sdist = { url = "https://files.pythonhosted.org/packages/ed/d9/f7a0c4b3a2bf2556cd5d99b05372c29980249ef71e8e32669ba77428c82c/ruff-0.14.8.tar.gz", hash = "sha256:774ed0dd87d6ce925e3b8496feb3a00ac564bea52b9feb551ecd17e0a23d1eed", size = 5765385, upload-time = "2025-12-04T15:06:17.669Z" } 328 | wheels = [ 329 | { url = "https://files.pythonhosted.org/packages/48/b8/9537b52010134b1d2b72870cc3f92d5fb759394094741b09ceccae183fbe/ruff-0.14.8-py3-none-linux_armv6l.whl", hash = "sha256:ec071e9c82eca417f6111fd39f7043acb53cd3fde9b1f95bbed745962e345afb", size = 13441540, upload-time = "2025-12-04T15:06:14.896Z" }, 330 | { url = "https://files.pythonhosted.org/packages/24/00/99031684efb025829713682012b6dd37279b1f695ed1b01725f85fd94b38/ruff-0.14.8-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:8cdb162a7159f4ca36ce980a18c43d8f036966e7f73f866ac8f493b75e0c27e9", size = 13669384, upload-time = "2025-12-04T15:06:51.809Z" }, 331 | { url = "https://files.pythonhosted.org/packages/72/64/3eb5949169fc19c50c04f28ece2c189d3b6edd57e5b533649dae6ca484fe/ruff-0.14.8-py3-none-macosx_11_0_arm64.whl", hash = "sha256:2e2fcbefe91f9fad0916850edf0854530c15bd1926b6b779de47e9ab619ea38f", size = 12806917, upload-time = "2025-12-04T15:06:08.925Z" }, 332 | { url = "https://files.pythonhosted.org/packages/c4/08/5250babb0b1b11910f470370ec0cbc67470231f7cdc033cee57d4976f941/ruff-0.14.8-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a9d70721066a296f45786ec31916dc287b44040f553da21564de0ab4d45a869b", size = 13256112, upload-time = "2025-12-04T15:06:23.498Z" }, 333 | { url = "https://files.pythonhosted.org/packages/78/4c/6c588e97a8e8c2d4b522c31a579e1df2b4d003eddfbe23d1f262b1a431ff/ruff-0.14.8-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2c87e09b3cd9d126fc67a9ecd3b5b1d3ded2b9c7fce3f16e315346b9d05cfb52", size = 13227559, upload-time = "2025-12-04T15:06:33.432Z" }, 334 | { url = "https://files.pythonhosted.org/packages/23/ce/5f78cea13eda8eceac71b5f6fa6e9223df9b87bb2c1891c166d1f0dce9f1/ruff-0.14.8-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1d62cb310c4fbcb9ee4ac023fe17f984ae1e12b8a4a02e3d21489f9a2a5f730c", size = 13896379, upload-time = "2025-12-04T15:06:02.687Z" }, 335 | { url = "https://files.pythonhosted.org/packages/cf/79/13de4517c4dadce9218a20035b21212a4c180e009507731f0d3b3f5df85a/ruff-0.14.8-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:1af35c2d62633d4da0521178e8a2641c636d2a7153da0bac1b30cfd4ccd91344", size = 15372786, upload-time = "2025-12-04T15:06:29.828Z" }, 336 | { url = "https://files.pythonhosted.org/packages/00/06/33df72b3bb42be8a1c3815fd4fae83fa2945fc725a25d87ba3e42d1cc108/ruff-0.14.8-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:25add4575ffecc53d60eed3f24b1e934493631b48ebbc6ebaf9d8517924aca4b", size = 14990029, upload-time = "2025-12-04T15:06:36.812Z" }, 337 | { url = "https://files.pythonhosted.org/packages/64/61/0f34927bd90925880394de0e081ce1afab66d7b3525336f5771dcf0cb46c/ruff-0.14.8-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4c943d847b7f02f7db4201a0600ea7d244d8a404fbb639b439e987edcf2baf9a", size = 14407037, upload-time = "2025-12-04T15:06:39.979Z" }, 338 | { url = "https://files.pythonhosted.org/packages/96/bc/058fe0aefc0fbf0d19614cb6d1a3e2c048f7dc77ca64957f33b12cfdc5ef/ruff-0.14.8-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cb6e8bf7b4f627548daa1b69283dac5a296bfe9ce856703b03130732e20ddfe2", size = 14102390, upload-time = "2025-12-04T15:06:46.372Z" }, 339 | { url = "https://files.pythonhosted.org/packages/af/a4/e4f77b02b804546f4c17e8b37a524c27012dd6ff05855d2243b49a7d3cb9/ruff-0.14.8-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:7aaf2974f378e6b01d1e257c6948207aec6a9b5ba53fab23d0182efb887a0e4a", size = 14230793, upload-time = "2025-12-04T15:06:20.497Z" }, 340 | { url = "https://files.pythonhosted.org/packages/3f/52/bb8c02373f79552e8d087cedaffad76b8892033d2876c2498a2582f09dcf/ruff-0.14.8-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:e5758ca513c43ad8a4ef13f0f081f80f08008f410790f3611a21a92421ab045b", size = 13160039, upload-time = "2025-12-04T15:06:49.06Z" }, 341 | { url = "https://files.pythonhosted.org/packages/1f/ad/b69d6962e477842e25c0b11622548df746290cc6d76f9e0f4ed7456c2c31/ruff-0.14.8-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:f74f7ba163b6e85a8d81a590363bf71618847e5078d90827749bfda1d88c9cdf", size = 13205158, upload-time = "2025-12-04T15:06:54.574Z" }, 342 | { url = "https://files.pythonhosted.org/packages/06/63/54f23da1315c0b3dfc1bc03fbc34e10378918a20c0b0f086418734e57e74/ruff-0.14.8-py3-none-musllinux_1_2_i686.whl", hash = "sha256:eed28f6fafcc9591994c42254f5a5c5ca40e69a30721d2ab18bb0bb3baac3ab6", size = 13469550, upload-time = "2025-12-04T15:05:59.209Z" }, 343 | { url = "https://files.pythonhosted.org/packages/70/7d/a4d7b1961e4903bc37fffb7ddcfaa7beb250f67d97cfd1ee1d5cddb1ec90/ruff-0.14.8-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:21d48fa744c9d1cb8d71eb0a740c4dd02751a5de9db9a730a8ef75ca34cf138e", size = 14211332, upload-time = "2025-12-04T15:06:06.027Z" }, 344 | { url = "https://files.pythonhosted.org/packages/5d/93/2a5063341fa17054e5c86582136e9895db773e3c2ffb770dde50a09f35f0/ruff-0.14.8-py3-none-win32.whl", hash = "sha256:15f04cb45c051159baebb0f0037f404f1dc2f15a927418f29730f411a79bc4e7", size = 13151890, upload-time = "2025-12-04T15:06:11.668Z" }, 345 | { url = "https://files.pythonhosted.org/packages/02/1c/65c61a0859c0add13a3e1cbb6024b42de587456a43006ca2d4fd3d1618fe/ruff-0.14.8-py3-none-win_amd64.whl", hash = "sha256:9eeb0b24242b5bbff3011409a739929f497f3fb5fe3b5698aba5e77e8c833097", size = 14537826, upload-time = "2025-12-04T15:06:26.409Z" }, 346 | { url = "https://files.pythonhosted.org/packages/6d/63/8b41cea3afd7f58eb64ac9251668ee0073789a3bc9ac6f816c8c6fef986d/ruff-0.14.8-py3-none-win_arm64.whl", hash = "sha256:965a582c93c63fe715fd3e3f8aa37c4b776777203d8e1d8aa3cc0c14424a4b99", size = 13634522, upload-time = "2025-12-04T15:06:43.212Z" }, 347 | ] 348 | 349 | [[package]] 350 | name = "starlette" 351 | version = "0.50.0" 352 | source = { registry = "https://pypi.org/simple" } 353 | dependencies = [ 354 | { name = "anyio" }, 355 | { name = "typing-extensions" }, 356 | ] 357 | sdist = { url = "https://files.pythonhosted.org/packages/ba/b8/73a0e6a6e079a9d9cfa64113d771e421640b6f679a52eeb9b32f72d871a1/starlette-0.50.0.tar.gz", hash = "sha256:a2a17b22203254bcbc2e1f926d2d55f3f9497f769416b3190768befe598fa3ca", size = 2646985, upload-time = "2025-11-01T15:25:27.516Z" } 358 | wheels = [ 359 | { url = "https://files.pythonhosted.org/packages/d9/52/1064f510b141bd54025f9b55105e26d1fa970b9be67ad766380a3c9b74b0/starlette-0.50.0-py3-none-any.whl", hash = "sha256:9e5391843ec9b6e472eed1365a78c8098cfceb7a74bfd4d6b1c0c0095efb3bca", size = 74033, upload-time = "2025-11-01T15:25:25.461Z" }, 360 | ] 361 | 362 | [[package]] 363 | name = "typing-extensions" 364 | version = "4.15.0" 365 | source = { registry = "https://pypi.org/simple" } 366 | sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } 367 | wheels = [ 368 | { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, 369 | ] 370 | 371 | [[package]] 372 | name = "typing-inspection" 373 | version = "0.4.2" 374 | source = { registry = "https://pypi.org/simple" } 375 | dependencies = [ 376 | { name = "typing-extensions" }, 377 | ] 378 | sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" } 379 | wheels = [ 380 | { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" }, 381 | ] 382 | 383 | [[package]] 384 | name = "uvicorn" 385 | version = "0.38.0" 386 | source = { registry = "https://pypi.org/simple" } 387 | dependencies = [ 388 | { name = "click" }, 389 | { name = "h11" }, 390 | ] 391 | sdist = { url = "https://files.pythonhosted.org/packages/cb/ce/f06b84e2697fef4688ca63bdb2fdf113ca0a3be33f94488f2cadb690b0cf/uvicorn-0.38.0.tar.gz", hash = "sha256:fd97093bdd120a2609fc0d3afe931d4d4ad688b6e75f0f929fde1bc36fe0e91d", size = 80605, upload-time = "2025-10-18T13:46:44.63Z" } 392 | wheels = [ 393 | { url = "https://files.pythonhosted.org/packages/ee/d9/d88e73ca598f4f6ff671fb5fde8a32925c2e08a637303a1d12883c7305fa/uvicorn-0.38.0-py3-none-any.whl", hash = "sha256:48c0afd214ceb59340075b4a052ea1ee91c16fbc2a9b1469cca0e54566977b02", size = 68109, upload-time = "2025-10-18T13:46:42.958Z" }, 394 | ] 395 | 396 | [[package]] 397 | name = "uvloop" 398 | version = "0.22.1" 399 | source = { registry = "https://pypi.org/simple" } 400 | sdist = { url = "https://files.pythonhosted.org/packages/06/f0/18d39dbd1971d6d62c4629cc7fa67f74821b0dc1f5a77af43719de7936a7/uvloop-0.22.1.tar.gz", hash = "sha256:6c84bae345b9147082b17371e3dd5d42775bddce91f885499017f4607fdaf39f", size = 2443250, upload-time = "2025-10-16T22:17:19.342Z" } 401 | wheels = [ 402 | { url = "https://files.pythonhosted.org/packages/3d/ff/7f72e8170be527b4977b033239a83a68d5c881cc4775fca255c677f7ac5d/uvloop-0.22.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:fe94b4564e865d968414598eea1a6de60adba0c040ba4ed05ac1300de402cd42", size = 1359936, upload-time = "2025-10-16T22:16:29.436Z" }, 403 | { url = "https://files.pythonhosted.org/packages/c3/c6/e5d433f88fd54d81ef4be58b2b7b0cea13c442454a1db703a1eea0db1a59/uvloop-0.22.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:51eb9bd88391483410daad430813d982010f9c9c89512321f5b60e2cddbdddd6", size = 752769, upload-time = "2025-10-16T22:16:30.493Z" }, 404 | { url = "https://files.pythonhosted.org/packages/24/68/a6ac446820273e71aa762fa21cdcc09861edd3536ff47c5cd3b7afb10eeb/uvloop-0.22.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:700e674a166ca5778255e0e1dc4e9d79ab2acc57b9171b79e65feba7184b3370", size = 4317413, upload-time = "2025-10-16T22:16:31.644Z" }, 405 | { url = "https://files.pythonhosted.org/packages/5f/6f/e62b4dfc7ad6518e7eff2516f680d02a0f6eb62c0c212e152ca708a0085e/uvloop-0.22.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7b5b1ac819a3f946d3b2ee07f09149578ae76066d70b44df3fa990add49a82e4", size = 4426307, upload-time = "2025-10-16T22:16:32.917Z" }, 406 | { url = "https://files.pythonhosted.org/packages/90/60/97362554ac21e20e81bcef1150cb2a7e4ffdaf8ea1e5b2e8bf7a053caa18/uvloop-0.22.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e047cc068570bac9866237739607d1313b9253c3051ad84738cbb095be0537b2", size = 4131970, upload-time = "2025-10-16T22:16:34.015Z" }, 407 | { url = "https://files.pythonhosted.org/packages/99/39/6b3f7d234ba3964c428a6e40006340f53ba37993f46ed6e111c6e9141d18/uvloop-0.22.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:512fec6815e2dd45161054592441ef76c830eddaad55c8aa30952e6fe1ed07c0", size = 4296343, upload-time = "2025-10-16T22:16:35.149Z" }, 408 | ] 409 | 410 | [[package]] 411 | name = "win32-setctime" 412 | version = "1.2.0" 413 | source = { registry = "https://pypi.org/simple" } 414 | sdist = { url = "https://files.pythonhosted.org/packages/b3/8f/705086c9d734d3b663af0e9bb3d4de6578d08f46b1b101c2442fd9aecaa2/win32_setctime-1.2.0.tar.gz", hash = "sha256:ae1fdf948f5640aae05c511ade119313fb6a30d7eabe25fef9764dca5873c4c0", size = 4867, upload-time = "2024-12-07T15:28:28.314Z" } 415 | wheels = [ 416 | { url = "https://files.pythonhosted.org/packages/e1/07/c6fe3ad3e685340704d314d765b7912993bcb8dc198f0e7a89382d37974b/win32_setctime-1.2.0-py3-none-any.whl", hash = "sha256:95d644c4e708aba81dc3704a116d8cbc974d70b3bdb8be1d150e36be6e9d1390", size = 4083, upload-time = "2024-12-07T15:28:26.465Z" }, 417 | ] 418 | -------------------------------------------------------------------------------- /app/server/chat.py: -------------------------------------------------------------------------------- 1 | import base64 2 | import json 3 | import re 4 | import struct 5 | import uuid 6 | from dataclasses import dataclass 7 | from datetime import datetime, timezone 8 | from pathlib import Path 9 | from typing import Any, Iterator 10 | 11 | import orjson 12 | from fastapi import APIRouter, Depends, HTTPException, Request, status 13 | from fastapi.responses import StreamingResponse 14 | from gemini_webapi.client import ChatSession 15 | from gemini_webapi.constants import Model 16 | from gemini_webapi.exceptions import APIError 17 | from gemini_webapi.types.image import GeneratedImage, Image 18 | from loguru import logger 19 | 20 | from ..models import ( 21 | ChatCompletionRequest, 22 | ContentItem, 23 | ConversationInStore, 24 | FunctionCall, 25 | Message, 26 | ModelData, 27 | ModelListResponse, 28 | ResponseCreateRequest, 29 | ResponseCreateResponse, 30 | ResponseImageGenerationCall, 31 | ResponseImageTool, 32 | ResponseInputContent, 33 | ResponseInputItem, 34 | ResponseOutputContent, 35 | ResponseOutputMessage, 36 | ResponseToolCall, 37 | ResponseToolChoice, 38 | ResponseUsage, 39 | Tool, 40 | ToolCall, 41 | ToolChoiceFunction, 42 | ) 43 | from ..services import GeminiClientPool, GeminiClientWrapper, LMDBConversationStore 44 | from ..services.client import CODE_BLOCK_HINT, XML_WRAP_HINT 45 | from ..utils import g_config 46 | from ..utils.helper import estimate_tokens 47 | from .middleware import get_image_store_dir, get_image_token, get_temp_dir, verify_api_key 48 | 49 | # Maximum characters Gemini Web can accept in a single request (configurable) 50 | MAX_CHARS_PER_REQUEST = int(g_config.gemini.max_chars_per_request * 0.9) 51 | CONTINUATION_HINT = "\n(More messages to come, please reply with just 'ok.')" 52 | TOOL_BLOCK_RE = re.compile(r"```xml\s*(.*?)```", re.DOTALL | re.IGNORECASE) 53 | TOOL_CALL_RE = re.compile( 54 | r"(.*?)", re.DOTALL | re.IGNORECASE 55 | ) 56 | JSON_FENCE_RE = re.compile(r"^```(?:json)?\s*(.*?)\s*```$", re.DOTALL | re.IGNORECASE) 57 | CONTROL_TOKEN_RE = re.compile(r"<\|im_(?:start|end)\|>") 58 | XML_HINT_STRIPPED = XML_WRAP_HINT.strip() 59 | CODE_HINT_STRIPPED = CODE_BLOCK_HINT.strip() 60 | 61 | router = APIRouter() 62 | 63 | 64 | @dataclass 65 | class StructuredOutputRequirement: 66 | """Represents a structured response request from the client.""" 67 | 68 | schema_name: str 69 | schema: dict[str, Any] 70 | instruction: str 71 | raw_format: dict[str, Any] 72 | 73 | 74 | def _build_structured_requirement( 75 | response_format: dict[str, Any] | None, 76 | ) -> StructuredOutputRequirement | None: 77 | """Translate OpenAI-style response_format into internal instructions.""" 78 | if not response_format or not isinstance(response_format, dict): 79 | return None 80 | 81 | if response_format.get("type") != "json_schema": 82 | logger.warning(f"Unsupported response_format type requested: {response_format}") 83 | return None 84 | 85 | json_schema = response_format.get("json_schema") 86 | if not isinstance(json_schema, dict): 87 | logger.warning(f"Invalid json_schema payload in response_format: {response_format}") 88 | return None 89 | 90 | schema = json_schema.get("schema") 91 | if not isinstance(schema, dict): 92 | logger.warning(f"Missing `schema` object in response_format payload: {response_format}") 93 | return None 94 | 95 | schema_name = json_schema.get("name") or "response" 96 | strict = json_schema.get("strict", True) 97 | 98 | pretty_schema = json.dumps(schema, ensure_ascii=False, indent=2, sort_keys=True) 99 | instruction_parts = [ 100 | "You must respond with a single valid JSON document that conforms to the schema shown below.", 101 | "Do not include explanations, comments, or any text before or after the JSON.", 102 | f'Schema name: "{schema_name}"', 103 | "JSON Schema:", 104 | pretty_schema, 105 | ] 106 | if not strict: 107 | instruction_parts.insert( 108 | 1, 109 | "The schema allows unspecified fields, but include only what is necessary to satisfy the user's request.", 110 | ) 111 | 112 | instruction = "\n\n".join(instruction_parts) 113 | return StructuredOutputRequirement( 114 | schema_name=schema_name, 115 | schema=schema, 116 | instruction=instruction, 117 | raw_format=response_format, 118 | ) 119 | 120 | 121 | def _strip_code_fence(text: str) -> str: 122 | """Remove surrounding ```json fences if present.""" 123 | match = JSON_FENCE_RE.match(text.strip()) 124 | if match: 125 | return match.group(1).strip() 126 | return text.strip() 127 | 128 | 129 | def _build_tool_prompt( 130 | tools: list[Tool], 131 | tool_choice: str | ToolChoiceFunction | None, 132 | ) -> str: 133 | """Generate a system prompt chunk describing available tools.""" 134 | if not tools: 135 | return "" 136 | 137 | lines: list[str] = [ 138 | "You can invoke the following developer tools. Call a tool only when it is required and follow the JSON schema exactly when providing arguments." 139 | ] 140 | 141 | for tool in tools: 142 | function = tool.function 143 | description = function.description or "No description provided." 144 | lines.append(f"Tool `{function.name}`: {description}") 145 | if function.parameters: 146 | schema_text = json.dumps(function.parameters, ensure_ascii=False, indent=2) 147 | lines.append("Arguments JSON schema:") 148 | lines.append(schema_text) 149 | else: 150 | lines.append("Arguments JSON schema: {}") 151 | 152 | if tool_choice == "none": 153 | lines.append( 154 | "For this request you must not call any tool. Provide the best possible natural language answer." 155 | ) 156 | elif tool_choice == "required": 157 | lines.append( 158 | "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." 159 | ) 160 | elif isinstance(tool_choice, ToolChoiceFunction): 161 | target = tool_choice.function.name 162 | lines.append( 163 | f"You are required to call the tool named `{target}`. Do not call any other tool." 164 | ) 165 | # `auto` or None fall back to default instructions. 166 | 167 | lines.append( 168 | "When you decide to call a tool you MUST respond with nothing except a single fenced block exactly like the template below." 169 | ) 170 | lines.append( 171 | "The fenced block MUST use ```xml as the opening fence and ``` as the closing fence. Do not add text before or after it." 172 | ) 173 | lines.append("```xml") 174 | lines.append('{"argument": "value"}') 175 | lines.append("```") 176 | lines.append( 177 | "Use double quotes for JSON keys and values. If you omit the fenced block or include any extra text, the system will assume you are NOT calling a tool and your request will fail." 178 | ) 179 | lines.append( 180 | "If multiple tool calls are required, include multiple entries inside the same fenced block. Without a tool call, reply normally and do NOT emit any ```xml fence." 181 | ) 182 | 183 | return "\n".join(lines) 184 | 185 | 186 | def _build_image_generation_instruction( 187 | tools: list[ResponseImageTool] | None, 188 | tool_choice: ResponseToolChoice | None, 189 | ) -> str | None: 190 | """Construct explicit guidance so Gemini emits images when requested.""" 191 | has_forced_choice = tool_choice is not None and tool_choice.type == "image_generation" 192 | primary = tools[0] if tools else None 193 | 194 | if not has_forced_choice and primary is None: 195 | return None 196 | 197 | instructions: list[str] = [ 198 | "Image generation is enabled. When the user requests an image, you must return an actual generated image, not a text description.", 199 | "For new image requests, generate at least one new image matching the description.", 200 | "If the user provides an image and asks for edits or variations, return a newly generated image with the requested changes.", 201 | "Avoid all text replies unless a short caption is explicitly requested. Do not explain, apologize, or describe image creation steps.", 202 | "Never send placeholder text like 'Here is your image' or any other response without an actual image attachment.", 203 | ] 204 | 205 | if primary: 206 | if primary.model: 207 | instructions.append( 208 | f"Where styles differ, favor the `{primary.model}` image model when rendering the scene." 209 | ) 210 | if primary.output_format: 211 | instructions.append( 212 | f"Encode the image using the `{primary.output_format}` format whenever possible." 213 | ) 214 | 215 | if has_forced_choice: 216 | instructions.append( 217 | "Image generation was explicitly requested. You must return at least one generated image. Any response without an image will be treated as a failure." 218 | ) 219 | 220 | return "\n\n".join(instructions) 221 | 222 | 223 | def _append_xml_hint_to_last_user_message(messages: list[Message]) -> None: 224 | """Ensure the last user message carries the XML wrap hint.""" 225 | for msg in reversed(messages): 226 | if msg.role != "user" or msg.content is None: 227 | continue 228 | 229 | if isinstance(msg.content, str): 230 | if XML_HINT_STRIPPED not in msg.content: 231 | msg.content = f"{msg.content}{XML_WRAP_HINT}" 232 | return 233 | 234 | if isinstance(msg.content, list): 235 | for part in reversed(msg.content): 236 | if getattr(part, "type", None) != "text": 237 | continue 238 | text_value = part.text or "" 239 | if XML_HINT_STRIPPED in text_value: 240 | return 241 | part.text = f"{text_value}{XML_WRAP_HINT}" 242 | return 243 | 244 | messages_text = XML_WRAP_HINT.strip() 245 | msg.content.append(ContentItem(type="text", text=messages_text)) 246 | return 247 | 248 | # No user message to annotate; nothing to do. 249 | 250 | 251 | def _conversation_has_code_hint(messages: list[Message]) -> bool: 252 | """Return True if any system message already includes the code block hint.""" 253 | for msg in messages: 254 | if msg.role != "system" or msg.content is None: 255 | continue 256 | 257 | if isinstance(msg.content, str): 258 | if CODE_HINT_STRIPPED in msg.content: 259 | return True 260 | continue 261 | 262 | if isinstance(msg.content, list): 263 | for part in msg.content: 264 | if getattr(part, "type", None) != "text": 265 | continue 266 | if part.text and CODE_HINT_STRIPPED in part.text: 267 | return True 268 | 269 | return False 270 | 271 | 272 | def _prepare_messages_for_model( 273 | source_messages: list[Message], 274 | tools: list[Tool] | None, 275 | tool_choice: str | ToolChoiceFunction | None, 276 | extra_instructions: list[str] | None = None, 277 | ) -> list[Message]: 278 | """Return a copy of messages enriched with tool instructions when needed.""" 279 | prepared = [msg.model_copy(deep=True) for msg in source_messages] 280 | 281 | instructions: list[str] = [] 282 | if tools: 283 | tool_prompt = _build_tool_prompt(tools, tool_choice) 284 | if tool_prompt: 285 | instructions.append(tool_prompt) 286 | 287 | if extra_instructions: 288 | instructions.extend(instr for instr in extra_instructions if instr) 289 | logger.debug( 290 | f"Applied {len(extra_instructions)} extra instructions for tool/structured output." 291 | ) 292 | 293 | if not _conversation_has_code_hint(prepared): 294 | instructions.append(CODE_BLOCK_HINT) 295 | logger.debug("Injected default code block hint for Gemini conversation.") 296 | 297 | if not instructions: 298 | return prepared 299 | 300 | combined_instructions = "\n\n".join(instructions) 301 | 302 | if prepared and prepared[0].role == "system" and isinstance(prepared[0].content, str): 303 | existing = prepared[0].content or "" 304 | separator = "\n\n" if existing else "" 305 | prepared[0].content = f"{existing}{separator}{combined_instructions}" 306 | else: 307 | prepared.insert(0, Message(role="system", content=combined_instructions)) 308 | 309 | if tools and tool_choice != "none": 310 | _append_xml_hint_to_last_user_message(prepared) 311 | 312 | return prepared 313 | 314 | 315 | def _strip_system_hints(text: str) -> str: 316 | """Remove system-level hint text from a given string.""" 317 | if not text: 318 | return text 319 | cleaned = _strip_tagged_blocks(text) 320 | cleaned = cleaned.replace(XML_WRAP_HINT, "").replace(XML_HINT_STRIPPED, "") 321 | cleaned = cleaned.replace(CODE_BLOCK_HINT, "").replace(CODE_HINT_STRIPPED, "") 322 | cleaned = CONTROL_TOKEN_RE.sub("", cleaned) 323 | return cleaned.strip() 324 | 325 | 326 | def _strip_tagged_blocks(text: str) -> str: 327 | """Remove <|im_start|>role ... <|im_end|> sections, dropping tool blocks entirely. 328 | - tool blocks are removed entirely (if missing end marker, drop to EOF). 329 | - other roles: remove markers and role, keep inner content (if missing end marker, keep to EOF). 330 | """ 331 | if not text: 332 | return text 333 | 334 | result: list[str] = [] 335 | idx = 0 336 | length = len(text) 337 | start_marker = "<|im_start|>" 338 | end_marker = "<|im_end|>" 339 | 340 | while idx < length: 341 | start = text.find(start_marker, idx) 342 | if start == -1: 343 | result.append(text[idx:]) 344 | break 345 | 346 | # append any content before this block 347 | result.append(text[idx:start]) 348 | 349 | role_start = start + len(start_marker) 350 | newline = text.find("\n", role_start) 351 | if newline == -1: 352 | # malformed block; keep remainder as-is (safe behavior) 353 | result.append(text[start:]) 354 | break 355 | 356 | role = text[role_start:newline].strip().lower() 357 | 358 | end = text.find(end_marker, newline + 1) 359 | if end == -1: 360 | # missing end marker 361 | if role == "tool": 362 | # drop from start marker to EOF (skip remainder) 363 | break 364 | else: 365 | # keep inner content from after the role newline to EOF 366 | result.append(text[newline + 1 :]) 367 | break 368 | 369 | block_end = end + len(end_marker) 370 | 371 | if role == "tool": 372 | # drop whole block 373 | idx = block_end 374 | continue 375 | 376 | # keep the content without role markers 377 | content = text[newline + 1 : end] 378 | result.append(content) 379 | idx = block_end 380 | 381 | return "".join(result) 382 | 383 | 384 | def _response_items_to_messages( 385 | items: str | list[ResponseInputItem], 386 | ) -> tuple[list[Message], str | list[ResponseInputItem]]: 387 | """Convert Responses API input items into internal Message objects and normalized input.""" 388 | messages: list[Message] = [] 389 | 390 | if isinstance(items, str): 391 | messages.append(Message(role="user", content=items)) 392 | logger.debug("Normalized Responses input: single string message.") 393 | return messages, items 394 | 395 | normalized_input: list[ResponseInputItem] = [] 396 | for item in items: 397 | role = item.role 398 | if role == "developer": 399 | role = "system" 400 | 401 | content = item.content 402 | normalized_contents: list[ResponseInputContent] = [] 403 | if isinstance(content, str): 404 | normalized_contents.append(ResponseInputContent(type="input_text", text=content)) 405 | messages.append(Message(role=role, content=content)) 406 | else: 407 | converted: list[ContentItem] = [] 408 | for part in content: 409 | if part.type == "input_text": 410 | text_value = part.text or "" 411 | normalized_contents.append( 412 | ResponseInputContent(type="input_text", text=text_value) 413 | ) 414 | if text_value: 415 | converted.append(ContentItem(type="text", text=text_value)) 416 | elif part.type == "input_image": 417 | image_url = part.image_url 418 | if image_url: 419 | normalized_contents.append( 420 | ResponseInputContent( 421 | type="input_image", 422 | image_url=image_url, 423 | detail=part.detail if part.detail else "auto", 424 | ) 425 | ) 426 | converted.append( 427 | ContentItem( 428 | type="image_url", 429 | image_url={ 430 | "url": image_url, 431 | "detail": part.detail if part.detail else "auto", 432 | }, 433 | ) 434 | ) 435 | elif part.type == "input_file": 436 | if part.file_url or part.file_data: 437 | normalized_contents.append(part) 438 | file_info = {} 439 | if part.file_data: 440 | file_info["file_data"] = part.file_data 441 | file_info["filename"] = part.filename 442 | if part.file_url: 443 | file_info["url"] = part.file_url 444 | converted.append(ContentItem(type="file", file=file_info)) 445 | messages.append(Message(role=role, content=converted or None)) 446 | 447 | normalized_input.append( 448 | ResponseInputItem(type="message", role=item.role, content=normalized_contents or []) 449 | ) 450 | 451 | logger.debug( 452 | f"Normalized Responses input: {len(normalized_input)} message items (developer roles mapped to system)." 453 | ) 454 | return messages, normalized_input 455 | 456 | 457 | def _instructions_to_messages( 458 | instructions: str | list[ResponseInputItem] | None, 459 | ) -> list[Message]: 460 | """Normalize instructions payload into Message objects.""" 461 | if not instructions: 462 | return [] 463 | 464 | if isinstance(instructions, str): 465 | return [Message(role="system", content=instructions)] 466 | 467 | instruction_messages: list[Message] = [] 468 | for item in instructions: 469 | if item.type and item.type != "message": 470 | continue 471 | 472 | role = item.role 473 | if role == "developer": 474 | role = "system" 475 | 476 | content = item.content 477 | if isinstance(content, str): 478 | instruction_messages.append(Message(role=role, content=content)) 479 | else: 480 | converted: list[ContentItem] = [] 481 | for part in content: 482 | if part.type == "input_text": 483 | text_value = part.text or "" 484 | if text_value: 485 | converted.append(ContentItem(type="text", text=text_value)) 486 | elif part.type == "input_image": 487 | image_url = part.image_url 488 | if image_url: 489 | converted.append( 490 | ContentItem( 491 | type="image_url", 492 | image_url={ 493 | "url": image_url, 494 | "detail": part.detail if part.detail else "auto", 495 | }, 496 | ) 497 | ) 498 | elif part.type == "input_file": 499 | file_info = {} 500 | if part.file_data: 501 | file_info["file_data"] = part.file_data 502 | file_info["filename"] = part.filename 503 | if part.file_url: 504 | file_info["url"] = part.file_url 505 | if file_info: 506 | converted.append(ContentItem(type="file", file=file_info)) 507 | instruction_messages.append(Message(role=role, content=converted or None)) 508 | 509 | return instruction_messages 510 | 511 | 512 | def _remove_tool_call_blocks(text: str) -> str: 513 | """Strip tool call code blocks from text.""" 514 | if not text: 515 | return text 516 | cleaned = TOOL_BLOCK_RE.sub("", text) 517 | return _strip_system_hints(cleaned) 518 | 519 | 520 | def _extract_tool_calls(text: str) -> tuple[str, list[ToolCall]]: 521 | """Extract tool call definitions and return cleaned text.""" 522 | if not text: 523 | return text, [] 524 | 525 | tool_calls: list[ToolCall] = [] 526 | 527 | def _replace(match: re.Match[str]) -> str: 528 | block_content = match.group(1) 529 | if not block_content: 530 | return "" 531 | 532 | for call_match in TOOL_CALL_RE.finditer(block_content): 533 | name = (call_match.group(1) or "").strip() 534 | raw_args = (call_match.group(2) or "").strip() 535 | if not name: 536 | logger.warning( 537 | f"Encountered tool_call block without a function name: {block_content}" 538 | ) 539 | continue 540 | 541 | arguments = raw_args 542 | try: 543 | parsed_args = json.loads(raw_args) 544 | arguments = json.dumps(parsed_args, ensure_ascii=False) 545 | except json.JSONDecodeError: 546 | logger.warning( 547 | f"Failed to parse tool call arguments for '{name}'. Passing raw string." 548 | ) 549 | 550 | tool_calls.append( 551 | ToolCall( 552 | id=f"call_{uuid.uuid4().hex}", 553 | type="function", 554 | function=FunctionCall(name=name, arguments=arguments), 555 | ) 556 | ) 557 | 558 | return "" 559 | 560 | cleaned = TOOL_BLOCK_RE.sub(_replace, text) 561 | cleaned = _strip_system_hints(cleaned) 562 | return cleaned, tool_calls 563 | 564 | 565 | @router.get("/v1/models", response_model=ModelListResponse) 566 | async def list_models(api_key: str = Depends(verify_api_key)): 567 | now = int(datetime.now(tz=timezone.utc).timestamp()) 568 | 569 | models = [] 570 | for model in Model: 571 | m_name = model.model_name 572 | if not m_name or m_name == "unspecified": 573 | continue 574 | 575 | models.append( 576 | ModelData( 577 | id=m_name, 578 | created=now, 579 | owned_by="gemini-web", 580 | ) 581 | ) 582 | 583 | return ModelListResponse(data=models) 584 | 585 | 586 | @router.post("/v1/chat/completions") 587 | async def create_chat_completion( 588 | request: ChatCompletionRequest, 589 | api_key: str = Depends(verify_api_key), 590 | tmp_dir: Path = Depends(get_temp_dir), 591 | image_store: Path = Depends(get_image_store_dir), 592 | ): 593 | pool = GeminiClientPool() 594 | db = LMDBConversationStore() 595 | model = Model.from_name(request.model) 596 | 597 | if len(request.messages) == 0: 598 | raise HTTPException( 599 | status_code=status.HTTP_400_BAD_REQUEST, 600 | detail="At least one message is required in the conversation.", 601 | ) 602 | 603 | structured_requirement = _build_structured_requirement(request.response_format) 604 | if structured_requirement and request.stream: 605 | logger.debug( 606 | "Structured response requested with streaming enabled; will stream canonical JSON once ready." 607 | ) 608 | if structured_requirement: 609 | logger.debug( 610 | f"Structured response requested for /v1/chat/completions (schema={structured_requirement.schema_name})." 611 | ) 612 | 613 | extra_instructions = [structured_requirement.instruction] if structured_requirement else None 614 | 615 | # Check if conversation is reusable 616 | session, client, remaining_messages = await _find_reusable_session( 617 | db, pool, model, request.messages 618 | ) 619 | 620 | if session: 621 | messages_to_send = _prepare_messages_for_model( 622 | remaining_messages, request.tools, request.tool_choice, extra_instructions 623 | ) 624 | if not messages_to_send: 625 | raise HTTPException( 626 | status_code=status.HTTP_400_BAD_REQUEST, 627 | detail="No new messages to send for the existing session.", 628 | ) 629 | if len(messages_to_send) == 1: 630 | model_input, files = await GeminiClientWrapper.process_message( 631 | messages_to_send[0], tmp_dir, tagged=False 632 | ) 633 | else: 634 | model_input, files = await GeminiClientWrapper.process_conversation( 635 | messages_to_send, tmp_dir 636 | ) 637 | logger.debug( 638 | f"Reused session {session.metadata} - sending {len(messages_to_send)} prepared messages." 639 | ) 640 | else: 641 | # Start a new session and concat messages into a single string 642 | try: 643 | client = await pool.acquire() 644 | session = client.start_chat(model=model) 645 | messages_to_send = _prepare_messages_for_model( 646 | request.messages, request.tools, request.tool_choice, extra_instructions 647 | ) 648 | model_input, files = await GeminiClientWrapper.process_conversation( 649 | messages_to_send, tmp_dir 650 | ) 651 | except ValueError as e: 652 | raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) 653 | except RuntimeError as e: 654 | raise HTTPException(status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail=str(e)) 655 | except Exception as e: 656 | logger.exception(f"Error in preparing conversation: {e}") 657 | raise 658 | logger.debug("New session started.") 659 | 660 | # Generate response 661 | try: 662 | assert session and client, "Session and client not available" 663 | client_id = client.id 664 | logger.debug( 665 | f"Client ID: {client_id}, Input length: {len(model_input)}, files count: {len(files)}" 666 | ) 667 | response = await _send_with_split(session, model_input, files=files) 668 | except APIError as exc: 669 | client_id = client.id if client else "unknown" 670 | logger.warning(f"Gemini API returned invalid response for client {client_id}: {exc}") 671 | raise HTTPException( 672 | status_code=status.HTTP_503_SERVICE_UNAVAILABLE, 673 | detail="Gemini temporarily returned an invalid response. Please retry.", 674 | ) from exc 675 | except HTTPException: 676 | raise 677 | except Exception as e: 678 | logger.exception(f"Unexpected error generating content from Gemini API: {e}") 679 | raise HTTPException( 680 | status_code=status.HTTP_502_BAD_GATEWAY, 681 | detail="Gemini returned an unexpected error.", 682 | ) from e 683 | 684 | # Format the response from API 685 | try: 686 | raw_output_with_think = GeminiClientWrapper.extract_output(response, include_thoughts=True) 687 | raw_output_clean = GeminiClientWrapper.extract_output(response, include_thoughts=False) 688 | except IndexError as exc: 689 | logger.exception("Gemini output parsing failed (IndexError).") 690 | raise HTTPException( 691 | status_code=status.HTTP_502_BAD_GATEWAY, 692 | detail="Gemini returned malformed response content.", 693 | ) from exc 694 | except Exception as exc: 695 | logger.exception("Gemini output parsing failed unexpectedly.") 696 | raise HTTPException( 697 | status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, 698 | detail="Gemini output parsing failed unexpectedly.", 699 | ) from exc 700 | 701 | visible_output, tool_calls = _extract_tool_calls(raw_output_with_think) 702 | storage_output = _remove_tool_call_blocks(raw_output_clean).strip() 703 | tool_calls_payload = [call.model_dump(mode="json") for call in tool_calls] 704 | 705 | if structured_requirement: 706 | cleaned_visible = _strip_code_fence(visible_output or "") 707 | if not cleaned_visible: 708 | raise HTTPException( 709 | status_code=status.HTTP_502_BAD_GATEWAY, 710 | detail="LLM returned an empty response while JSON schema output was requested.", 711 | ) 712 | try: 713 | structured_payload = json.loads(cleaned_visible) 714 | except json.JSONDecodeError as exc: 715 | logger.warning( 716 | f"Failed to decode JSON for structured response (schema={structured_requirement.schema_name}): " 717 | f"{cleaned_visible}" 718 | ) 719 | raise HTTPException( 720 | status_code=status.HTTP_502_BAD_GATEWAY, 721 | detail="LLM returned invalid JSON for the requested response_format.", 722 | ) from exc 723 | 724 | canonical_output = json.dumps(structured_payload, ensure_ascii=False) 725 | visible_output = canonical_output 726 | storage_output = canonical_output 727 | 728 | if tool_calls_payload: 729 | logger.debug(f"Detected tool calls: {tool_calls_payload}") 730 | 731 | # After formatting, persist the conversation to LMDB 732 | try: 733 | last_message = Message( 734 | role="assistant", 735 | content=storage_output or None, 736 | tool_calls=tool_calls or None, 737 | ) 738 | cleaned_history = db.sanitize_assistant_messages(request.messages) 739 | conv = ConversationInStore( 740 | model=model.model_name, 741 | client_id=client.id, 742 | metadata=session.metadata, 743 | messages=[*cleaned_history, last_message], 744 | ) 745 | key = db.store(conv) 746 | logger.debug(f"Conversation saved to LMDB with key: {key}") 747 | except Exception as e: 748 | # We can still return the response even if saving fails 749 | logger.warning(f"Failed to save conversation to LMDB: {e}") 750 | 751 | # Return with streaming or standard response 752 | completion_id = f"chatcmpl-{uuid.uuid4()}" 753 | timestamp = int(datetime.now(tz=timezone.utc).timestamp()) 754 | if request.stream: 755 | return _create_streaming_response( 756 | visible_output, 757 | tool_calls_payload, 758 | completion_id, 759 | timestamp, 760 | request.model, 761 | request.messages, 762 | ) 763 | else: 764 | return _create_standard_response( 765 | visible_output, 766 | tool_calls_payload, 767 | completion_id, 768 | timestamp, 769 | request.model, 770 | request.messages, 771 | ) 772 | 773 | 774 | @router.post("/v1/responses") 775 | async def create_response( 776 | request_data: ResponseCreateRequest, 777 | request: Request, 778 | api_key: str = Depends(verify_api_key), 779 | tmp_dir: Path = Depends(get_temp_dir), 780 | image_store: Path = Depends(get_image_store_dir), 781 | ): 782 | base_messages, normalized_input = _response_items_to_messages(request_data.input) 783 | structured_requirement = _build_structured_requirement(request_data.response_format) 784 | if structured_requirement and request_data.stream: 785 | logger.debug( 786 | "Structured response requested with streaming enabled; streaming not supported for Responses." 787 | ) 788 | 789 | extra_instructions: list[str] = [] 790 | if structured_requirement: 791 | extra_instructions.append(structured_requirement.instruction) 792 | logger.debug( 793 | f"Structured response requested for /v1/responses (schema={structured_requirement.schema_name})." 794 | ) 795 | 796 | # Separate standard tools from image generation tools 797 | standard_tools: list[Tool] = [] 798 | image_tools: list[ResponseImageTool] = [] 799 | 800 | if request_data.tools: 801 | for t in request_data.tools: 802 | if isinstance(t, Tool): 803 | standard_tools.append(t) 804 | elif isinstance(t, ResponseImageTool): 805 | image_tools.append(t) 806 | # Handle dicts if Pydantic didn't convert them fully (fallback) 807 | elif isinstance(t, dict): 808 | t_type = t.get("type") 809 | if t_type == "function": 810 | standard_tools.append(Tool.model_validate(t)) 811 | elif t_type == "image_generation": 812 | image_tools.append(ResponseImageTool.model_validate(t)) 813 | 814 | image_instruction = _build_image_generation_instruction( 815 | image_tools, 816 | request_data.tool_choice 817 | if isinstance(request_data.tool_choice, ResponseToolChoice) 818 | else None, 819 | ) 820 | if image_instruction: 821 | extra_instructions.append(image_instruction) 822 | logger.debug("Image generation support enabled for /v1/responses request.") 823 | 824 | preface_messages = _instructions_to_messages(request_data.instructions) 825 | conversation_messages = base_messages 826 | if preface_messages: 827 | conversation_messages = [*preface_messages, *base_messages] 828 | logger.debug( 829 | f"Injected {len(preface_messages)} instruction messages before sending to Gemini." 830 | ) 831 | 832 | # Pass standard tools to the prompt builder 833 | # Determine tool_choice for standard tools (ignore image_generation choice here as it is handled via instruction) 834 | model_tool_choice = None 835 | if isinstance(request_data.tool_choice, str): 836 | model_tool_choice = request_data.tool_choice 837 | elif isinstance(request_data.tool_choice, ToolChoiceFunction): 838 | model_tool_choice = request_data.tool_choice 839 | # If tool_choice is ResponseToolChoice (image_generation), we don't pass it as a function tool choice. 840 | 841 | messages = _prepare_messages_for_model( 842 | conversation_messages, 843 | tools=standard_tools or None, 844 | tool_choice=model_tool_choice, 845 | extra_instructions=extra_instructions or None, 846 | ) 847 | 848 | pool = GeminiClientPool() 849 | db = LMDBConversationStore() 850 | 851 | try: 852 | model = Model.from_name(request_data.model) 853 | except ValueError as exc: 854 | raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc 855 | 856 | session, client, remaining_messages = await _find_reusable_session(db, pool, model, messages) 857 | 858 | async def _build_payload( 859 | _payload_messages: list[Message], _reuse_session: bool 860 | ) -> tuple[str, list[Path | str]]: 861 | if _reuse_session and len(_payload_messages) == 1: 862 | return await GeminiClientWrapper.process_message( 863 | _payload_messages[0], tmp_dir, tagged=False 864 | ) 865 | return await GeminiClientWrapper.process_conversation(_payload_messages, tmp_dir) 866 | 867 | reuse_session = session is not None 868 | if reuse_session: 869 | messages_to_send = _prepare_messages_for_model( 870 | remaining_messages, 871 | tools=None, 872 | tool_choice=None, 873 | extra_instructions=extra_instructions or None, 874 | ) 875 | if not messages_to_send: 876 | raise HTTPException( 877 | status_code=status.HTTP_400_BAD_REQUEST, 878 | detail="No new messages to send for the existing session.", 879 | ) 880 | payload_messages = messages_to_send 881 | model_input, files = await _build_payload(payload_messages, _reuse_session=True) 882 | logger.debug( 883 | f"Reused session {session.metadata} - sending {len(payload_messages)} prepared messages." 884 | ) 885 | else: 886 | try: 887 | client = await pool.acquire() 888 | session = client.start_chat(model=model) 889 | payload_messages = messages 890 | model_input, files = await _build_payload(payload_messages, _reuse_session=False) 891 | except ValueError as e: 892 | raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) 893 | except RuntimeError as e: 894 | raise HTTPException(status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail=str(e)) 895 | except Exception as e: 896 | logger.exception(f"Error in preparing conversation for responses API: {e}") 897 | raise 898 | logger.debug("New session started for /v1/responses request.") 899 | 900 | try: 901 | assert session and client, "Session and client not available" 902 | client_id = client.id 903 | logger.debug( 904 | f"Client ID: {client_id}, Input length: {len(model_input)}, files count: {len(files)}" 905 | ) 906 | model_output = await _send_with_split(session, model_input, files=files) 907 | except APIError as exc: 908 | client_id = client.id if client else "unknown" 909 | logger.warning(f"Gemini API returned invalid response for client {client_id}: {exc}") 910 | raise HTTPException( 911 | status_code=status.HTTP_503_SERVICE_UNAVAILABLE, 912 | detail="Gemini temporarily returned an invalid response. Please retry.", 913 | ) from exc 914 | except HTTPException: 915 | raise 916 | except Exception as e: 917 | logger.exception(f"Unexpected error generating content from Gemini API for responses: {e}") 918 | raise HTTPException( 919 | status_code=status.HTTP_502_BAD_GATEWAY, 920 | detail="Gemini returned an unexpected error.", 921 | ) from e 922 | 923 | try: 924 | text_with_think = GeminiClientWrapper.extract_output(model_output, include_thoughts=True) 925 | text_without_think = GeminiClientWrapper.extract_output( 926 | model_output, include_thoughts=False 927 | ) 928 | except IndexError as exc: 929 | logger.exception("Gemini output parsing failed (IndexError).") 930 | raise HTTPException( 931 | status_code=status.HTTP_502_BAD_GATEWAY, 932 | detail="Gemini returned malformed response content.", 933 | ) from exc 934 | except Exception as exc: 935 | logger.exception("Gemini output parsing failed unexpectedly.") 936 | raise HTTPException( 937 | status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, 938 | detail="Gemini output parsing failed unexpectedly.", 939 | ) from exc 940 | 941 | visible_text, detected_tool_calls = _extract_tool_calls(text_with_think) 942 | storage_output = _remove_tool_call_blocks(text_without_think).strip() 943 | assistant_text = LMDBConversationStore.remove_think_tags(visible_text.strip()) 944 | 945 | if structured_requirement: 946 | cleaned_visible = _strip_code_fence(assistant_text or "") 947 | if not cleaned_visible: 948 | raise HTTPException( 949 | status_code=status.HTTP_502_BAD_GATEWAY, 950 | detail="LLM returned an empty response while JSON schema output was requested.", 951 | ) 952 | try: 953 | structured_payload = json.loads(cleaned_visible) 954 | except json.JSONDecodeError as exc: 955 | logger.warning( 956 | f"Failed to decode JSON for structured response (schema={structured_requirement.schema_name}): " 957 | f"{cleaned_visible}" 958 | ) 959 | raise HTTPException( 960 | status_code=status.HTTP_502_BAD_GATEWAY, 961 | detail="LLM returned invalid JSON for the requested response_format.", 962 | ) from exc 963 | 964 | canonical_output = json.dumps(structured_payload, ensure_ascii=False) 965 | assistant_text = canonical_output 966 | storage_output = canonical_output 967 | logger.debug( 968 | f"Structured response fulfilled for /v1/responses (schema={structured_requirement.schema_name})." 969 | ) 970 | 971 | expects_image = ( 972 | request_data.tool_choice is not None and request_data.tool_choice.type == "image_generation" 973 | ) 974 | images = model_output.images or [] 975 | logger.debug( 976 | f"Gemini returned {len(images)} image(s) for /v1/responses " 977 | f"(expects_image={expects_image}, instruction_applied={bool(image_instruction)})." 978 | ) 979 | if expects_image and not images: 980 | summary = assistant_text.strip() if assistant_text else "" 981 | if summary: 982 | summary = re.sub(r"\s+", " ", summary) 983 | if len(summary) > 200: 984 | summary = f"{summary[:197]}..." 985 | logger.warning( 986 | "Image generation requested but Gemini produced no images. " 987 | f"client_id={client_id}, forced_tool_choice={request_data.tool_choice is not None}, " 988 | f"instruction_applied={bool(image_instruction)}, assistant_preview='{summary}'" 989 | ) 990 | detail = "LLM returned no images for the requested image_generation tool." 991 | if summary: 992 | detail = f"{detail} Assistant response: {summary}" 993 | raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail=detail) 994 | 995 | response_contents: list[ResponseOutputContent] = [] 996 | image_call_items: list[ResponseImageGenerationCall] = [] 997 | for image in images: 998 | try: 999 | image_base64, width, height, filename = await _image_to_base64(image, image_store) 1000 | except Exception as exc: 1001 | logger.warning(f"Failed to download generated image: {exc}") 1002 | continue 1003 | 1004 | img_format = "png" if isinstance(image, GeneratedImage) else "jpeg" 1005 | 1006 | # Use static URL for compatibility 1007 | image_url = ( 1008 | f"![{filename}]({request.base_url}images/{filename}?token={get_image_token(filename)})" 1009 | ) 1010 | 1011 | image_call_items.append( 1012 | ResponseImageGenerationCall( 1013 | id=f"img_{uuid.uuid4().hex}", 1014 | status="completed", 1015 | result=image_base64, 1016 | output_format=img_format, 1017 | size=f"{width}x{height}" if width and height else None, 1018 | ) 1019 | ) 1020 | # Add as output_text content for compatibility 1021 | response_contents.append( 1022 | ResponseOutputContent(type="output_text", text=image_url, annotations=[]) 1023 | ) 1024 | 1025 | tool_call_items: list[ResponseToolCall] = [] 1026 | if detected_tool_calls: 1027 | tool_call_items = [ 1028 | ResponseToolCall( 1029 | id=call.id, 1030 | status="completed", 1031 | function=call.function, 1032 | ) 1033 | for call in detected_tool_calls 1034 | ] 1035 | 1036 | if assistant_text: 1037 | response_contents.append( 1038 | ResponseOutputContent(type="output_text", text=assistant_text, annotations=[]) 1039 | ) 1040 | 1041 | if not response_contents: 1042 | response_contents.append(ResponseOutputContent(type="output_text", text="", annotations=[])) 1043 | 1044 | created_time = int(datetime.now(tz=timezone.utc).timestamp()) 1045 | response_id = f"resp_{uuid.uuid4().hex}" 1046 | message_id = f"msg_{uuid.uuid4().hex}" 1047 | 1048 | input_tokens = sum(estimate_tokens(_text_from_message(msg)) for msg in messages) 1049 | tool_arg_text = "".join(call.function.arguments or "" for call in detected_tool_calls) 1050 | completion_basis = assistant_text or "" 1051 | if tool_arg_text: 1052 | completion_basis = ( 1053 | f"{completion_basis}\n{tool_arg_text}" if completion_basis else tool_arg_text 1054 | ) 1055 | output_tokens = estimate_tokens(completion_basis) 1056 | usage = ResponseUsage( 1057 | input_tokens=input_tokens, 1058 | output_tokens=output_tokens, 1059 | total_tokens=input_tokens + output_tokens, 1060 | ) 1061 | 1062 | response_payload = ResponseCreateResponse( 1063 | id=response_id, 1064 | created_at=created_time, 1065 | model=request_data.model, 1066 | output=[ 1067 | ResponseOutputMessage( 1068 | id=message_id, 1069 | type="message", 1070 | role="assistant", 1071 | content=response_contents, 1072 | ), 1073 | *tool_call_items, 1074 | *image_call_items, 1075 | ], 1076 | status="completed", 1077 | usage=usage, 1078 | input=normalized_input or None, 1079 | metadata=request_data.metadata or None, 1080 | tools=request_data.tools, 1081 | tool_choice=request_data.tool_choice, 1082 | ) 1083 | 1084 | try: 1085 | last_message = Message( 1086 | role="assistant", 1087 | content=storage_output or None, 1088 | tool_calls=detected_tool_calls or None, 1089 | ) 1090 | cleaned_history = db.sanitize_assistant_messages(messages) 1091 | conv = ConversationInStore( 1092 | model=model.model_name, 1093 | client_id=client.id, 1094 | metadata=session.metadata, 1095 | messages=[*cleaned_history, last_message], 1096 | ) 1097 | key = db.store(conv) 1098 | logger.debug(f"Conversation saved to LMDB with key: {key}") 1099 | except Exception as exc: 1100 | logger.warning(f"Failed to save Responses conversation to LMDB: {exc}") 1101 | 1102 | if request_data.stream: 1103 | logger.debug( 1104 | f"Streaming Responses API payload (response_id={response_payload.id}, text_chunks={bool(assistant_text)})." 1105 | ) 1106 | return _create_responses_streaming_response(response_payload, assistant_text or "") 1107 | 1108 | return response_payload 1109 | 1110 | 1111 | def _text_from_message(message: Message) -> str: 1112 | """Return text content from a message for token estimation.""" 1113 | base_text = "" 1114 | if isinstance(message.content, str): 1115 | base_text = message.content 1116 | elif isinstance(message.content, list): 1117 | base_text = "\n".join( 1118 | item.text or "" for item in message.content if getattr(item, "type", "") == "text" 1119 | ) 1120 | elif message.content is None: 1121 | base_text = "" 1122 | 1123 | if message.tool_calls: 1124 | tool_arg_text = "".join(call.function.arguments or "" for call in message.tool_calls) 1125 | base_text = f"{base_text}\n{tool_arg_text}" if base_text else tool_arg_text 1126 | 1127 | return base_text 1128 | 1129 | 1130 | async def _find_reusable_session( 1131 | db: LMDBConversationStore, 1132 | pool: GeminiClientPool, 1133 | model: Model, 1134 | messages: list[Message], 1135 | ) -> tuple[ChatSession | None, GeminiClientWrapper | None, list[Message]]: 1136 | """Find an existing chat session that matches the *longest* prefix of 1137 | ``messages`` **whose last element is an assistant/system reply**. 1138 | 1139 | Rationale 1140 | --------- 1141 | When a reply was generated by *another* server instance, the local LMDB may 1142 | only contain an older part of the conversation. However, as long as we can 1143 | line up **any** earlier assistant/system response, we can restore the 1144 | corresponding Gemini session and replay the *remaining* turns locally 1145 | (including that missing assistant reply and the subsequent user prompts). 1146 | 1147 | The algorithm therefore walks backwards through the history **one message at 1148 | a time**, each time requiring the current tail to be assistant/system before 1149 | querying LMDB. As soon as a match is found we recreate the session and 1150 | return the untouched suffix as ``remaining_messages``. 1151 | """ 1152 | 1153 | if len(messages) < 2: 1154 | return None, None, messages 1155 | 1156 | # Start with the full history and iteratively trim from the end. 1157 | search_end = len(messages) 1158 | while search_end >= 2: 1159 | search_history = messages[:search_end] 1160 | 1161 | # Only try to match if the last stored message would be assistant/system. 1162 | if search_history[-1].role in {"assistant", "system"}: 1163 | try: 1164 | if conv := db.find(model.model_name, search_history): 1165 | client = await pool.acquire(conv.client_id) 1166 | session = client.start_chat(metadata=conv.metadata, model=model) 1167 | remain = messages[search_end:] 1168 | return session, client, remain 1169 | except Exception as e: 1170 | logger.warning(f"Error checking LMDB for reusable session: {e}") 1171 | break 1172 | 1173 | # Trim one message and try again. 1174 | search_end -= 1 1175 | 1176 | return None, None, messages 1177 | 1178 | 1179 | async def _send_with_split(session: ChatSession, text: str, files: list[Path | str] | None = None): 1180 | """Send text to Gemini, automatically splitting into multiple batches if it is 1181 | longer than ``MAX_CHARS_PER_REQUEST``. 1182 | 1183 | Every intermediate batch (that is **not** the last one) is suffixed with a hint 1184 | telling Gemini that more content will come, and it should simply reply with 1185 | "ok". The final batch carries any file uploads and the real user prompt so 1186 | that Gemini can produce the actual answer. 1187 | """ 1188 | if len(text) <= MAX_CHARS_PER_REQUEST: 1189 | # No need to split - a single request is fine. 1190 | try: 1191 | return await session.send_message(text, files=files) 1192 | except Exception as e: 1193 | logger.exception(f"Error sending message to Gemini: {e}") 1194 | raise 1195 | hint_len = len(CONTINUATION_HINT) 1196 | chunk_size = MAX_CHARS_PER_REQUEST - hint_len 1197 | 1198 | chunks: list[str] = [] 1199 | pos = 0 1200 | total = len(text) 1201 | while pos < total: 1202 | end = min(pos + chunk_size, total) 1203 | chunk = text[pos:end] 1204 | pos = end 1205 | 1206 | # If this is NOT the last chunk, add the continuation hint. 1207 | if end < total: 1208 | chunk += CONTINUATION_HINT 1209 | chunks.append(chunk) 1210 | 1211 | # Fire off all but the last chunk, discarding the interim "ok" replies. 1212 | for chk in chunks[:-1]: 1213 | try: 1214 | await session.send_message(chk) 1215 | except Exception as e: 1216 | logger.exception(f"Error sending chunk to Gemini: {e}") 1217 | raise 1218 | 1219 | # The last chunk carries the files (if any) and we return its response. 1220 | try: 1221 | return await session.send_message(chunks[-1], files=files) 1222 | except Exception as e: 1223 | logger.exception(f"Error sending final chunk to Gemini: {e}") 1224 | raise 1225 | 1226 | 1227 | def _iter_stream_segments(model_output: str, chunk_size: int = 64): 1228 | """Yield stream segments while keeping markers and words intact.""" 1229 | if not model_output: 1230 | return 1231 | 1232 | token_pattern = re.compile(r"\s+|\S+\s*") 1233 | pending = "" 1234 | 1235 | def _flush_pending() -> Iterator[str]: 1236 | nonlocal pending 1237 | if pending: 1238 | yield pending 1239 | pending = "" 1240 | 1241 | # Split on boundaries so the markers are never fragmented. 1242 | parts = re.split(r"()", model_output) 1243 | for part in parts: 1244 | if not part: 1245 | continue 1246 | if part in {"", ""}: 1247 | yield from _flush_pending() 1248 | yield part 1249 | continue 1250 | 1251 | for match in token_pattern.finditer(part): 1252 | token = match.group(0) 1253 | 1254 | if len(token) > chunk_size: 1255 | yield from _flush_pending() 1256 | for idx in range(0, len(token), chunk_size): 1257 | yield token[idx : idx + chunk_size] 1258 | continue 1259 | 1260 | if pending and len(pending) + len(token) > chunk_size: 1261 | yield from _flush_pending() 1262 | 1263 | pending += token 1264 | 1265 | yield from _flush_pending() 1266 | 1267 | 1268 | def _create_streaming_response( 1269 | model_output: str, 1270 | tool_calls: list[dict], 1271 | completion_id: str, 1272 | created_time: int, 1273 | model: str, 1274 | messages: list[Message], 1275 | ) -> StreamingResponse: 1276 | """Create streaming response with `usage` calculation included in the final chunk.""" 1277 | 1278 | # Calculate token usage 1279 | prompt_tokens = sum(estimate_tokens(_text_from_message(msg)) for msg in messages) 1280 | tool_args = "".join(call.get("function", {}).get("arguments", "") for call in tool_calls or []) 1281 | completion_tokens = estimate_tokens(model_output + tool_args) 1282 | total_tokens = prompt_tokens + completion_tokens 1283 | finish_reason = "tool_calls" if tool_calls else "stop" 1284 | 1285 | async def generate_stream(): 1286 | # Send start event 1287 | data = { 1288 | "id": completion_id, 1289 | "object": "chat.completion.chunk", 1290 | "created": created_time, 1291 | "model": model, 1292 | "choices": [{"index": 0, "delta": {"role": "assistant"}, "finish_reason": None}], 1293 | } 1294 | yield f"data: {orjson.dumps(data).decode('utf-8')}\n\n" 1295 | 1296 | # Stream output text in chunks for efficiency 1297 | for chunk in _iter_stream_segments(model_output): 1298 | data = { 1299 | "id": completion_id, 1300 | "object": "chat.completion.chunk", 1301 | "created": created_time, 1302 | "model": model, 1303 | "choices": [{"index": 0, "delta": {"content": chunk}, "finish_reason": None}], 1304 | } 1305 | yield f"data: {orjson.dumps(data).decode('utf-8')}\n\n" 1306 | 1307 | if tool_calls: 1308 | tool_calls_delta = [{**call, "index": idx} for idx, call in enumerate(tool_calls)] 1309 | data = { 1310 | "id": completion_id, 1311 | "object": "chat.completion.chunk", 1312 | "created": created_time, 1313 | "model": model, 1314 | "choices": [ 1315 | { 1316 | "index": 0, 1317 | "delta": {"tool_calls": tool_calls_delta}, 1318 | "finish_reason": None, 1319 | } 1320 | ], 1321 | } 1322 | yield f"data: {orjson.dumps(data).decode('utf-8')}\n\n" 1323 | 1324 | # Send end event 1325 | data = { 1326 | "id": completion_id, 1327 | "object": "chat.completion.chunk", 1328 | "created": created_time, 1329 | "model": model, 1330 | "choices": [{"index": 0, "delta": {}, "finish_reason": finish_reason}], 1331 | "usage": { 1332 | "prompt_tokens": prompt_tokens, 1333 | "completion_tokens": completion_tokens, 1334 | "total_tokens": total_tokens, 1335 | }, 1336 | } 1337 | yield f"data: {orjson.dumps(data).decode('utf-8')}\n\n" 1338 | yield "data: [DONE]\n\n" 1339 | 1340 | return StreamingResponse(generate_stream(), media_type="text/event-stream") 1341 | 1342 | 1343 | def _create_responses_streaming_response( 1344 | response_payload: ResponseCreateResponse, 1345 | assistant_text: str | None, 1346 | ) -> StreamingResponse: 1347 | """Create streaming response for Responses API using event types defined by OpenAI.""" 1348 | 1349 | response_dict = response_payload.model_dump(mode="json") 1350 | response_id = response_payload.id 1351 | created_time = response_payload.created_at 1352 | model = response_payload.model 1353 | 1354 | logger.debug( 1355 | f"Preparing streaming envelope for /v1/responses (response_id={response_id}, model={model})." 1356 | ) 1357 | 1358 | base_event = { 1359 | "id": response_id, 1360 | "object": "response", 1361 | "created_at": created_time, 1362 | "model": model, 1363 | } 1364 | 1365 | created_snapshot: dict[str, Any] = { 1366 | "id": response_id, 1367 | "object": "response", 1368 | "created_at": created_time, 1369 | "model": model, 1370 | "status": "in_progress", 1371 | } 1372 | if response_dict.get("metadata") is not None: 1373 | created_snapshot["metadata"] = response_dict["metadata"] 1374 | if response_dict.get("input") is not None: 1375 | created_snapshot["input"] = response_dict["input"] 1376 | if response_dict.get("tools") is not None: 1377 | created_snapshot["tools"] = response_dict["tools"] 1378 | if response_dict.get("tool_choice") is not None: 1379 | created_snapshot["tool_choice"] = response_dict["tool_choice"] 1380 | 1381 | async def generate_stream(): 1382 | # Emit creation event 1383 | data = { 1384 | **base_event, 1385 | "type": "response.created", 1386 | "response": created_snapshot, 1387 | } 1388 | yield f"data: {orjson.dumps(data).decode('utf-8')}\n\n" 1389 | 1390 | # Stream output items (Message/Text, Tool Calls, Images) 1391 | for i, item in enumerate(response_payload.output): 1392 | item_json = item.model_dump(mode="json", exclude_none=True) 1393 | 1394 | added_event = { 1395 | **base_event, 1396 | "type": "response.output_item.added", 1397 | "output_index": i, 1398 | "item": item_json, 1399 | } 1400 | yield f"data: {orjson.dumps(added_event).decode('utf-8')}\n\n" 1401 | 1402 | # 2. Stream content if it's a message (text) 1403 | if item.type == "message": 1404 | content_text = "" 1405 | # Aggregate text content to stream 1406 | for c in item.content: 1407 | if c.type == "output_text" and c.text: 1408 | content_text += c.text 1409 | 1410 | if content_text: 1411 | for chunk in _iter_stream_segments(content_text): 1412 | delta_event = { 1413 | **base_event, 1414 | "type": "response.output_text.delta", 1415 | "output_index": i, 1416 | "delta": chunk, 1417 | } 1418 | yield f"data: {orjson.dumps(delta_event).decode('utf-8')}\n\n" 1419 | 1420 | # Text done 1421 | done_event = { 1422 | **base_event, 1423 | "type": "response.output_text.done", 1424 | "output_index": i, 1425 | } 1426 | yield f"data: {orjson.dumps(done_event).decode('utf-8')}\n\n" 1427 | 1428 | # 3. Emit output_item.done for all types 1429 | # This confirms the item is fully transferred. 1430 | item_done_event = { 1431 | **base_event, 1432 | "type": "response.output_item.done", 1433 | "output_index": i, 1434 | "item": item_json, 1435 | } 1436 | yield f"data: {orjson.dumps(item_done_event).decode('utf-8')}\n\n" 1437 | 1438 | # Emit completed event with full payload 1439 | completed_event = { 1440 | **base_event, 1441 | "type": "response.completed", 1442 | "response": response_dict, 1443 | } 1444 | yield f"data: {orjson.dumps(completed_event).decode('utf-8')}\n\n" 1445 | yield "data: [DONE]\n\n" 1446 | 1447 | return StreamingResponse(generate_stream(), media_type="text/event-stream") 1448 | 1449 | 1450 | def _create_standard_response( 1451 | model_output: str, 1452 | tool_calls: list[dict], 1453 | completion_id: str, 1454 | created_time: int, 1455 | model: str, 1456 | messages: list[Message], 1457 | ) -> dict: 1458 | """Create standard response""" 1459 | # Calculate token usage 1460 | prompt_tokens = sum(estimate_tokens(_text_from_message(msg)) for msg in messages) 1461 | tool_args = "".join(call.get("function", {}).get("arguments", "") for call in tool_calls or []) 1462 | completion_tokens = estimate_tokens(model_output + tool_args) 1463 | total_tokens = prompt_tokens + completion_tokens 1464 | finish_reason = "tool_calls" if tool_calls else "stop" 1465 | 1466 | message_payload: dict = {"role": "assistant", "content": model_output or None} 1467 | if tool_calls: 1468 | message_payload["tool_calls"] = tool_calls 1469 | 1470 | result = { 1471 | "id": completion_id, 1472 | "object": "chat.completion", 1473 | "created": created_time, 1474 | "model": model, 1475 | "choices": [ 1476 | { 1477 | "index": 0, 1478 | "message": message_payload, 1479 | "finish_reason": finish_reason, 1480 | } 1481 | ], 1482 | "usage": { 1483 | "prompt_tokens": prompt_tokens, 1484 | "completion_tokens": completion_tokens, 1485 | "total_tokens": total_tokens, 1486 | }, 1487 | } 1488 | 1489 | logger.debug(f"Response created with {total_tokens} total tokens") 1490 | return result 1491 | 1492 | 1493 | def _extract_image_dimensions(data: bytes) -> tuple[int | None, int | None]: 1494 | """Return image dimensions (width, height) if PNG or JPEG headers are present.""" 1495 | # PNG: dimensions stored in bytes 16..24 of the IHDR chunk 1496 | if len(data) >= 24 and data.startswith(b"\x89PNG\r\n\x1a\n"): 1497 | try: 1498 | width, height = struct.unpack(">II", data[16:24]) 1499 | return int(width), int(height) 1500 | except struct.error: 1501 | return None, None 1502 | 1503 | # JPEG: dimensions stored in SOF segment; iterate through markers to locate it 1504 | if len(data) >= 4 and data[0:2] == b"\xff\xd8": 1505 | idx = 2 1506 | length = len(data) 1507 | sof_markers = { 1508 | 0xC0, 1509 | 0xC1, 1510 | 0xC2, 1511 | 0xC3, 1512 | 0xC5, 1513 | 0xC6, 1514 | 0xC7, 1515 | 0xC9, 1516 | 0xCA, 1517 | 0xCB, 1518 | 0xCD, 1519 | 0xCE, 1520 | 0xCF, 1521 | } 1522 | while idx < length: 1523 | # Find marker alignment (markers are prefixed with 0xFF bytes) 1524 | if data[idx] != 0xFF: 1525 | idx += 1 1526 | continue 1527 | while idx < length and data[idx] == 0xFF: 1528 | idx += 1 1529 | if idx >= length: 1530 | break 1531 | marker = data[idx] 1532 | idx += 1 1533 | 1534 | if marker in (0xD8, 0xD9, 0x01) or 0xD0 <= marker <= 0xD7: 1535 | continue 1536 | 1537 | if idx + 1 >= length: 1538 | break 1539 | segment_length = (data[idx] << 8) + data[idx + 1] 1540 | idx += 2 1541 | if segment_length < 2: 1542 | break 1543 | 1544 | if marker in sof_markers: 1545 | if idx + 4 < length: 1546 | # Skip precision byte at idx, then read height/width (big-endian) 1547 | height = (data[idx + 1] << 8) + data[idx + 2] 1548 | width = (data[idx + 3] << 8) + data[idx + 4] 1549 | return int(width), int(height) 1550 | break 1551 | 1552 | idx += segment_length - 2 1553 | 1554 | return None, None 1555 | 1556 | 1557 | async def _image_to_base64(image: Image, temp_dir: Path) -> tuple[str, int | None, int | None, str]: 1558 | """Persist an image provided by gemini_webapi and return base64 plus dimensions and filename.""" 1559 | if isinstance(image, GeneratedImage): 1560 | saved_path = await image.save(path=str(temp_dir), full_size=True) 1561 | else: 1562 | saved_path = await image.save(path=str(temp_dir)) 1563 | 1564 | if not saved_path: 1565 | raise ValueError("Failed to save generated image") 1566 | 1567 | # Rename file to a random UUID to ensure uniqueness and unpredictability 1568 | original_path = Path(saved_path) 1569 | random_name = f"img_{uuid.uuid4().hex}{original_path.suffix}" 1570 | new_path = temp_dir / random_name 1571 | original_path.rename(new_path) 1572 | 1573 | data = new_path.read_bytes() 1574 | width, height = _extract_image_dimensions(data) 1575 | filename = random_name 1576 | return base64.b64encode(data).decode("ascii"), width, height, filename 1577 | --------------------------------------------------------------------------------