├── 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 | [](https://www.python.org/downloads/)
4 | [](https://fastapi.tiangolo.com/)
5 | [](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 | [](https://www.python.org/downloads/)
4 | [](https://fastapi.tiangolo.com/)
5 | [](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"})"
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"(?think>)", 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 |
--------------------------------------------------------------------------------