diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..fcadb2c --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +* text eol=lf diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml new file mode 100644 index 0000000..32dfee7 --- /dev/null +++ b/.github/workflows/ci.yaml @@ -0,0 +1,51 @@ +name: CI + +on: + push: &ci_trigger + branches: + - main + paths: + - "**.py" + - "pyproject.toml" + - "uv.lock" + - ".github/workflows/ci.yaml" + pull_request: *ci_trigger + +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + static-checks: + runs-on: ubuntu-latest + name: "Static Checks (Synced with Local Environment)" + + steps: + - name: Checkout repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Install uv + uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0 + + - name: Install dependencies + run: uv sync --locked --all-groups + + - name: Run Ruff + run: uv run ruff check + + - name: Run Ruff Format + run: uv run ruff format --check + + - name: Run Ty Check + run: uv run ty check + + - name: Run Pyright + run: uv run pyright + + - name: Run Pytest + run: uv run pytest diff --git a/.github/workflows/docker.yaml b/.github/workflows/docker.yaml index 70481b4..83cd36d 100644 --- a/.github/workflows/docker.yaml +++ b/.github/workflows/docker.yaml @@ -4,37 +4,112 @@ on: push: branches: - main - tags: - - "v*" - paths-ignore: - - "**/*.md" - - ".github/*" - - "LICENSE" - - ".gitignore" + paths: + - "app/**" + - "config/**" + - "pyproject.toml" + - "uv.lock" + - "Dockerfile" + - "run.py" + - ".github/workflows/docker.yaml" + workflow_dispatch: env: REGISTRY: ghcr.io - IMAGE_NAME: ${{ github.repository }} + +permissions: + contents: read + packages: write + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true jobs: - build-and-push: - runs-on: ubuntu-latest - permissions: - contents: read - packages: write + build: + strategy: + fail-fast: false + matrix: + include: + - platform: linux/amd64 + runner: ubuntu-26.04 + - platform: linux/arm64 + runner: ubuntu-26.04-arm + runs-on: ${{ matrix.runner }} steps: - name: Checkout repository - uses: actions/checkout@v7 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Prepare platform pair and image name + run: | + platform=${{ matrix.platform }} + echo "PLATFORM_PAIR=${platform//\//-}" >> "$GITHUB_ENV" + echo "IMAGE_NAME=${GITHUB_REPOSITORY,,}" >> "$GITHUB_ENV" + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0 + + - name: Log in to Container Registry + uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Extract metadata + id: meta + uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6.2.0 + with: + images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} + + - name: Build and push image by digest + id: build + uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0 + with: + context: . + platforms: ${{ matrix.platform }} + labels: ${{ steps.meta.outputs.labels }} + outputs: type=image,name=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }},push-by-digest=true,name-canonical=true,push=true + cache-from: type=gha,scope=build-${{ env.PLATFORM_PAIR }} + cache-to: type=gha,mode=max,scope=build-${{ env.PLATFORM_PAIR }} + + - name: Export digest + run: | + mkdir -p "${{ runner.temp }}/digests" + digest="${{ steps.build.outputs.digest }}" + touch "${{ runner.temp }}/digests/${digest#sha256:}" + + - name: Upload digest + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: digests-${{ env.PLATFORM_PAIR }} + path: ${{ runner.temp }}/digests/* + if-no-files-found: error + retention-days: 1 - - name: Set up QEMU - uses: docker/setup-qemu-action@v4 + merge: + needs: build + runs-on: ubuntu-26.04 + + steps: + - name: Prepare image name + run: echo "IMAGE_NAME=${GITHUB_REPOSITORY,,}" >> "$GITHUB_ENV" + + - name: Download digests + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + path: ${{ runner.temp }}/digests + pattern: digests-* + merge-multiple: true - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v4 + uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0 - name: Log in to Container Registry - uses: docker/login-action@v4 + uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0 with: registry: ${{ env.REGISTRY }} username: ${{ github.actor }} @@ -42,7 +117,7 @@ jobs: - name: Extract metadata id: meta - uses: docker/metadata-action@v6 + uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6.2.0 with: images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} tags: | @@ -53,13 +128,12 @@ jobs: type=raw,value={{date 'YYYYMMDD'}}-{{sha}} type=raw,value=latest,enable={{is_default_branch}} - - name: Build and push Docker image - uses: docker/build-push-action@v7 - with: - context: . - push: true - tags: ${{ steps.meta.outputs.tags }} - labels: ${{ steps.meta.outputs.labels }} - platforms: linux/amd64,linux/arm64 - cache-from: type=gha - cache-to: type=gha,mode=max + - name: Create manifest list and push + working-directory: ${{ runner.temp }}/digests + run: | + docker buildx imagetools create \ + $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \ + $(printf '${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}@sha256:%s ' *) + + - name: Inspect image + run: docker buildx imagetools inspect ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.meta.outputs.version }} diff --git a/.github/workflows/ruff.yaml b/.github/workflows/ruff.yaml deleted file mode 100644 index f97f1e8..0000000 --- a/.github/workflows/ruff.yaml +++ /dev/null @@ -1,30 +0,0 @@ -name: Ruff Lint - -on: - push: - branches: - - main - pull_request: - types: - - opened - -jobs: - lint: - runs-on: ubuntu-latest - - steps: - - name: Checkout repository - uses: actions/checkout@v7 - - - name: Set up Python - uses: actions/setup-python@v6 - with: - python-version: "3.13" - - - name: Install Ruff - run: | - python -m pip install --upgrade pip - pip install ruff - - - name: Run Ruff - run: ruff check . diff --git a/.github/workflows/track.yml b/.github/workflows/track.yml index 07618e7..41f835e 100644 --- a/.github/workflows/track.yml +++ b/.github/workflows/track.yml @@ -2,70 +2,79 @@ name: Update gemini-webapi on: schedule: - - cron: "0 0 * * *" # Runs every day at midnight + - cron: "0 0 * * *" workflow_dispatch: +concurrency: + group: ${{ github.workflow }}-gemini-webapi + cancel-in-progress: true + jobs: update-dep: runs-on: ubuntu-latest + timeout-minutes: 10 permissions: contents: write pull-requests: write + steps: - - uses: actions/checkout@v7 + - name: Checkout repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + ref: ${{ github.event.repository.default_branch }} - name: Install uv - uses: astral-sh/setup-uv@v7 - with: - version: "latest" + uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0 - name: Update gemini-webapi id: update + shell: bash run: | - # Install dependencies first to enable uv pip show - uv sync + set -euo pipefail - # Get current version of gemini-webapi before upgrade - OLD_VERSION=$(uv pip show gemini-webapi 2>/dev/null | grep ^Version: | awk '{print $2}') - if [ -z "$OLD_VERSION" ]; then - echo "Error: Could not extract current gemini-webapi version" >&2 + CURRENT_RESOLUTION=$(uv tree --locked --package gemini-webapi --depth 0 | head -n 1) + if [ -z "$CURRENT_RESOLUTION" ]; then + echo "Error: Could not read the current gemini-webapi resolution" >&2 exit 1 fi - echo "Current gemini-webapi version: $OLD_VERSION" + echo "Current resolution: $CURRENT_RESOLUTION" - # Update the package using uv, which handles pyproject.toml and uv.lock - uv add --upgrade gemini-webapi + uv lock --upgrade-package gemini-webapi - # Get new version of gemini-webapi after upgrade - NEW_VERSION=$(uv pip show gemini-webapi | grep ^Version: | awk '{print $2}') - if [ -z "$NEW_VERSION" ]; then - echo "Error: Could not extract new gemini-webapi version" >&2 + UPDATED_RESOLUTION=$(uv tree --locked --package gemini-webapi --depth 0 | head -n 1) + if [ -z "$UPDATED_RESOLUTION" ]; then + echo "Error: Could not read the updated gemini-webapi resolution" >&2 exit 1 fi - echo "New gemini-webapi version: $NEW_VERSION" + echo "Updated resolution: $UPDATED_RESOLUTION" - # Only proceed if gemini-webapi version has changed - if [ "$OLD_VERSION" != "$NEW_VERSION" ]; then - echo "gemini-webapi has been updated from $OLD_VERSION to $NEW_VERSION" - echo "updated=true" >> $GITHUB_OUTPUT - echo "version=$NEW_VERSION" >> $GITHUB_OUTPUT + if git diff --quiet -- uv.lock; then + echo "No gemini-webapi version or Git revision updates are available" + echo "updated=false" >> "$GITHUB_OUTPUT" else - echo "No updates available for gemini-webapi (version $OLD_VERSION unchanged)" - echo "updated=false" >> $GITHUB_OUTPUT + echo "The gemini-webapi locked resolution has changed" + echo "updated=true" >> "$GITHUB_OUTPUT" + echo "resolution=$UPDATED_RESOLUTION" >> "$GITHUB_OUTPUT" fi - name: Create Pull Request if: steps.update.outputs.updated == 'true' - uses: peter-evans/create-pull-request@v8 + uses: peter-evans/create-pull-request@5f6978faf089d4d20b00c7766989d076bb2fc7f1 # v8.1.1 with: token: ${{ secrets.GITHUB_TOKEN }} - commit-message: ":arrow_up: update gemini-webapi to ${{ steps.update.outputs.version }}" - title: ":arrow_up: update gemini-webapi to ${{ steps.update.outputs.version }}" + commit-message: ":arrow_up: update gemini-webapi dependency" + title: ":arrow_up: update gemini-webapi dependency" body: | - Update `gemini-webapi` to version `${{ steps.update.outputs.version }}`. + Refresh the locked `gemini-webapi` dependency. + + Resolved package: `${{ steps.update.outputs.resolution }}`. + + This tracks both compatible package releases and new commits on a configured Git branch. Auto-generated by GitHub Actions using `uv`. branch: update-gemini-webapi - base: main + base: ${{ github.event.repository.default_branch }} delete-branch: true labels: dependency, automated + add-paths: uv.lock diff --git a/.gitignore b/.gitignore index bea920f..6983284 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ .python-version +.DS_Store .vscode .cursor .idea @@ -10,4 +11,4 @@ __pycache__ .env config.debug.yaml -data/ \ No newline at end of file +data/ diff --git a/Dockerfile b/Dockerfile index 62ce9d1..1f225fe 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,27 +1,54 @@ -FROM ghcr.io/astral-sh/uv:python3.13-trixie-slim +FROM ghcr.io/astral-sh/uv:python3.13-trixie-slim AS builder + +WORKDIR /app + +ENV UV_COMPILE_BYTECODE=1 \ + UV_LINK_MODE=copy \ + UV_PYTHON_DOWNLOADS=0 + +COPY pyproject.toml uv.lock ./ + +RUN --mount=type=cache,target=/root/.cache/uv <= 3.13 +- Google account with Gemini access on web (Enable **[Gemini Apps activity](https://myactivity.google.com/product/gemini)** for best conversation persistence) - `secure_1psid` and `secure_1psidts` cookies from Gemini web interface ### Installation @@ -46,7 +46,7 @@ cd Gemini-FastAPI pip install -e . ``` -### Configuration +### Basic Configuration Edit `config/config.yaml` and provide at least one credential pair: @@ -57,10 +57,11 @@ gemini: secure_1psid: "YOUR_SECURE_1PSID_HERE" secure_1psidts: "YOUR_SECURE_1PSIDTS_HERE" proxy: null # Optional proxy URL (null/empty keeps direct connection) + impersonate: null # Optional browser impersonation target (null uses library default) ``` > [!NOTE] -> For details, refer to the [Configuration](#configuration-1) section below. +> For details, refer to the [Configuration](#configuration) section below. ### Running the Server @@ -80,23 +81,66 @@ The server provides several endpoints, including OpenAI-compatible ones. ### OpenAI-Compatible Endpoints -These endpoints are designed to be compatible with OpenAI's API structure, allowing you to use Gemini as a drop-in replacement. +These endpoints use OpenAI-compatible wire formats while translating requests to Gemini Web. +Compatibility is intentionally broader than the controls exposed by the Gemini Web client. Valid +but unforwardable options are accepted for client compatibility, ignored, and recorded at debug +level so they do not prevent an otherwise representable request from running. - **`GET /v1/models`**: Lists all supported Gemini models. - **`POST /v1/chat/completions`**: Unified chat interface. - **Streaming**: Set `stream: true` to receive real-time delta chunks. - **Multi-modal**: Supports text, images, and file uploads. - **Tool Calling**: Supports function calling via the `tools` parameter. - - **Structured Output**: Supports `response_format` for JSON schema enforcement. + - **Structured Output**: Supports every `response_format` mode. `json_schema` is validated + server-side against the supplied schema; `json_object` (JSON mode) only requires that the + reply parses as JSON; `text` is the default and imposes nothing. ### Advanced Endpoints -- **`POST /v1/responses`**: An alternative endpoint for complex interaction patterns, supporting rich output items including generated images and tool calls. +- **`POST /v1/responses`**: Supports current `text.format` structured output (`text`, + `json_object` and `json_schema`), external/inline file inputs, generated images, and tool + calls. Files API `file_id` references are rejected because this wrapper does not expose an + OpenAI Files API. + +Schema enforcement follows OpenAI's own guarantee. With `strict: true`, a reply that does not +match the schema is an error. With `strict: false` or JSON mode, only a best effort is promised, +so a non-conforming reply is returned as text instead of failing the request. A turn that returns +a tool call is never judged against the schema, which constrains the final answer only. + +`strict` defaults to `false` on both OpenAI surfaces, matching OpenAI itself, so one schema +behaves the same whichever endpoint it is sent to. The flag matters more here than upstream, +because Gemini Web has no constrained decoding — the schema is asked for in the prompt, so a +strict requirement the model narrowly misses costs the caller the whole reply. The Gemini-native +`generationConfig.responseSchema` / `responseJsonSchema` is always best-effort for the same +reason: it has no `strict` flag to turn off. + +Only the model's own failures are enforced. A schema this wrapper cannot evaluate — one that is +not valid JSON Schema, or whose `$ref`s do not resolve — is still shown to the model but is not +used to judge the reply, and a schema whose regex keywords exhaust +`server.schema_validation_budget_seconds` leaves the reply unverified. None of these fail the +request, even under `strict`: they are gaps on this side, not violations by the model. + +Because a JSON document can only be validated once it is complete, a streamed response carrying +a structured requirement is delivered as a single chunk after validation rather than +incrementally. This applies to Gemini-native `responseMimeType: application/json` as well. + +Generation controls that Gemini Web does not expose—such as `temperature`, `top_p`, maximum +output-token limits, `parallel_tool_calls`, Gemini `generationConfig` fields, and safety settings—are +accepted but cannot affect upstream generation. They are ignored with a debug log. Only malformed +input, or content this wrapper cannot resolve at all (an unresolved Files API ID, a `cachedContent` +handle), is rejected—dropping those silently would change what the model is answering. + +On the Gemini surface, `generationConfig.responseSchema` is the OpenAPI 3.0 subset (uppercase +type names, `nullable`) and is translated to JSON Schema before use; `responseJsonSchema` is +already JSON Schema and is validated as such. `toolConfig.functionCallingConfig.allowedFunctionNames` +narrows the tool list only in the `ANY` and `VALIDATED` modes that act on it. ### Utility Endpoints -- **`GET /health`**: Health check endpoint. Returns the status of the server, configured Gemini clients, and conversation storage. -- **`GET /images/{filename}`**: Internal endpoint to serve generated images. Requires a valid token (automatically included in image URLs returned by the API). +- **`GET /health`**: Readiness endpoint. Conversation storage failures always return HTTP 503. + Client failures follow the configured `gemini.guest_mode` health policy; the default + `adaptive` policy returns 503 only when every Gemini client is unhealthy. +- **`GET /media/{filename}`**: Internal endpoint to serve generated media. Requires a valid token (automatically included in image URLs returned by the API). ## Docker Deployment @@ -180,10 +224,24 @@ export CONFIG_GEMINI__CLIENTS__0__SECURE_1PSIDTS="your-secure-1psidts" # Override optional proxy settings for client 0 export CONFIG_GEMINI__CLIENTS__0__PROXY="socks5://127.0.0.1:1080" +# Override browser impersonation for client 0 +export CONFIG_GEMINI__CLIENTS__0__IMPERSONATE="chrome" + + # Override conversation storage size limit export CONFIG_STORAGE__MAX_SIZE=268435456 # 256 MB + +# Override the local HTTP-body resource guard (0 disables it) +export CONFIG_SERVER__MAX_REQUEST_BODY_BYTES=268435456 + +# Override the JSON Schema regex evaluation budget, in seconds +export CONFIG_SERVER__SCHEMA_VALIDATION_BUDGET_SECONDS=1.0 ``` +`max_request_body_bytes` is only a wrapper-side memory/resource safety ceiling. It is not an +OpenAI or Gemini API compatibility limit and does not claim to describe Gemini Web capacity. +Gemini Web remains authoritative for whether a request that passes this local guard is accepted. + ### Client IDs and Conversation Reuse Conversations are stored with the ID of the client that generated them. @@ -195,6 +253,11 @@ when you update the cookie list. > [!WARNING] > Keep these credentials secure and never commit them to version control. These cookies provide access to your Google account. + + +> [!WARNING] +> **Session Stability**: If cookies expire frequently, use Firefox to extract cookies. Recent versions of Chromium-based browsers use "Device Bound Session Credentials", which improves security but causes cookies to remain valid for only a few hours and prevents them from being renewed. + To use Gemini-FastAPI, you need to extract your Gemini session cookies: 1. Open [Gemini](https://gemini.google.com/) in a private/incognito browser window and sign in @@ -204,6 +267,13 @@ To use Gemini-FastAPI, you need to extract your Gemini session cookies: - `__Secure-1PSID` - `__Secure-1PSIDTS` +> [!IMPORTANT] +> **Enable [Gemini Apps activity](https://myactivity.google.com/product/gemini)** to ensure stable conversation persistence. +> +> While active chat turns may work temporarily without it, any transient error, TLS session restart, or server reboot can cause Google to expire the conversation metadata. If this setting is disabled, the model will **completely lose the context of your multi-turn conversation**, making old threads unreachable even if they are stored in your local LMDB. + + + > [!TIP] > For detailed instructions, refer to the [HanaokaYuzu/Gemini-API authentication guide](https://github.com/HanaokaYuzu/Gemini-API?tab=readme-ov-file#authentication). @@ -211,53 +281,128 @@ To use Gemini-FastAPI, you need to extract your Gemini session cookies: Each client entry can be configured with a different proxy to work around rate limits. Omit the `proxy` field or set it to `null` or an empty string to keep a direct connection. +### Browser Impersonation + +Each client can optionally set an `impersonate` value to control the TLS/HTTP fingerprint used by `curl_cffi`. + +- Set to `null` (default) to use the library's default. +- Set to any value supported by [`curl_cffi`'s `BrowserTypeLiteral`](https://github.com/lexiforest/curl_cffi). +- The value is validated at startup; an invalid value will prevent the server from starting. + +```yaml +gemini: + clients: + - id: "client-a" + impersonate: "chrome" # Use Chrome fingerprint + - id: "client-b" + impersonate: null # Use library default +``` + ### Chat Session Mode You can control whether requests use normal Google chats or Google's temporary chat mode: ```yaml gemini: - chat_mode: "normal" # "normal" (reuse metadata) or "temporary" (Google temporary chat, not saved to account) + chat_mode: "normal" # "normal" or "temporary" + guest_mode: "adaptive" # "strict", "adaptive", or "permissive" max_chars_per_request: 1000000 - oversized_context_strategy: "compaction" # "compaction" or "file" ``` -When `chat_mode` is set to `temporary`, the server applies an internal effective input limit of 90% of `max_chars_per_request`. -When context exceeds the effective budget, handling is controlled by `oversized_context_strategy`: -- `compaction`: summarize older turns and keep recent turns verbatim. -- `file`: attach oversized context as `message.txt` and process it from file. +With `temporary`, conversations are not saved to the Google account. A temporary chat is still +continuable for as long as Google keeps the window open, so session reuse and conversation +storage work exactly as they do in normal mode. + +When a stored chat can no longer be continued - after changing `chat_mode`, or once Google has +closed a temporary window - the server falls back to replaying the full conversation history +into a fresh chat, so the context is rebuilt rather than lost. + +Google keeps at most one temporary window open per account and closes the previous one as soon +as a new conversation is created, so only the most recently opened temporary chat is still +continuable. The server tracks that chat per client and reuses **only** it; any older temporary +conversation is replayed in full into a fresh chat instead. There is no timeout to tune - the +rule follows Google's actual behaviour rather than guessing at an expiry. + +That tracking is deliberately in-memory, so it is also cleared whenever the client session +restarts - an `auto_close` after inactivity, a server restart, or a redeploy. After any of +those, no window can be vouched for and every stored temporary conversation is replayed rather +than reused. + +The same rule applies to a client running as a guest, regardless of `chat_mode`. When every +cookie group fails, or an authenticated session is rejected mid-flight because its cookies +expired, the client keeps serving text prompts without an account - and a guest chat is never +written to any history, so it behaves exactly like a temporary one. Each stored conversation +records the session window it belongs to, so chats opened while authenticated are never replayed +into a guest session and chats opened as a guest are never replayed once cookies are restored; +either crossing falls back to a full history replay in a fresh chat. + +A guest session keeps the service up rather than taking it down, and requests degrade instead of +failing: + +- The pool prefers authenticated clients, so a downgraded one only receives traffic when no + authenticated client is left. +- Requests that need a file upload - attachments, or input long enough to be sent as + `message.txt` - are routed to an authenticated client, including when a stored session would + otherwise pin them to a guest one. If none exists, the request fails with an explicit message + instead of Google's `Permission denied`. +- Google gives a guest no model choice, so the requested model is replaced by the default one it + is allowed to use, logged as a warning. `/v1/models` advertises only models a client can + actually serve. + +`/health` reports a guest client as unhealthy - refresh its cookies to restore full capability. +The `guest_mode` setting controls how those unhealthy clients affect the readiness response: + +- `strict`: return HTTP 503 when any client is unhealthy. +- `adaptive` (default): return HTTP 503 only when all clients are unhealthy; otherwise log a + warning and remain ready. +- `permissive`: log a warning but do not change readiness, even when all clients are unhealthy. + +All three modes log unhealthy clients. Conversation storage failures still return HTTP 503 +regardless of `guest_mode`. + +Otherwise this applies **only** in temporary mode. A normal chat opened by an authenticated +client is kept by Google until you delete it, so its metadata stays reusable indefinitely and +across restarts. -Environment variable equivalents: +> [!WARNING] +> Google can close a temporary chat window at any time, without notice and mid-conversation. +> When that happens the reply may come back without the earlier context instead of raising an +> error, so the loss can be silent. The server replays the full history into a fresh chat when +> it can detect the chat is gone, but detection is not guaranteed. Prefer `normal` for long or +> context-sensitive conversations, and treat `temporary` as best-effort continuity. + +Because temporary chats accept a smaller payload, the server applies an additional 10% reduction +on top of the standard safety margin, so the effective input limit becomes 81% of +`max_chars_per_request` instead of 90%. Input exceeding the effective limit is still sent as a +`message.txt` attachment in both modes. + +Environment variable equivalent: ```bash export CONFIG_GEMINI__CHAT_MODE="temporary" -export CONFIG_GEMINI__MAX_CHARS_PER_REQUEST=1000000 -export CONFIG_GEMINI__OVERSIZED_CONTEXT_STRATEGY="compaction" +export CONFIG_GEMINI__GUEST_MODE="adaptive" ``` -### Custom Models - -You can define custom models in `config/config.yaml` or via environment variables. +### Models -#### YAML Configuration +Models are discovered from Google at startup - there is nothing to configure. Each client reads +the models its own account may use and builds the request headers for them at runtime, so a newly +launched model is served as soon as Google offers it. `GET /v1/models` lists what the running +clients can actually serve. -```yaml -gemini: - model_strategy: "append" # "append" (default + custom) or "overwrite" (custom only) - models: - - model_name: "gemini-3.0-pro" - model_header: - x-goog-ext-525001261-jspb: '[1,null,null,null,"9d8ca3786ebdfbea",null,null,0,[4],null,null,1]' -``` - -#### Environment Variables +Requests may name a model by its canonical name (currently `gemini-pro`, `gemini-flash` and +`gemini-flash-lite`), by an alias or display name (`pro`, `Flash Lite`) or by its internal id; +all forms resolve to the same conversation history. Call `GET /v1/models` for the list your own +accounts see - the names come from Google, not from this project. Only discovered models are +served: a name no client offers is rejected with `400` rather than quietly answered by a +different model. -You can supply models as a JSON string or list structure via `CONFIG_GEMINI__MODELS`. This provides a flexible way to override settings via the shell or in automated environments (e.g. Docker) without modifying the configuration file. - -```bash -export CONFIG_GEMINI__MODEL_STRATEGY="overwrite" -export CONFIG_GEMINI__MODELS='[{"model_name": "gemini-3.0-pro", "model_header": {"x-goog-ext-525001261-jspb": "[1,null,null,null,\"9d8ca3786ebdfbea\",null,null,0,[4],null,null,1]"}}]' -``` +> [!NOTE] +> The `models` and `model_strategy` settings are gone, as is any use of the library's removed +> static `Model` name lookups. They existed to hand-write model headers while the library lagged behind +> new releases, which dynamic discovery has made unnecessary - and hardcoded headers now risk +> pinning requests to a stale model. Both keys are simply ignored if left in a config file or +> environment. ## Acknowledgments diff --git a/README.zh.md b/README.zh.md index d012d32..5611644 100644 --- a/README.zh.md +++ b/README.zh.md @@ -1,22 +1,22 @@ # Gemini-FastAPI [![Python 3.13](https://img.shields.io/badge/python-3.13+-blue.svg)](https://www.python.org/downloads/) -[![FastAPI](https://img.shields.io/badge/FastAPI-0.115+-green.svg)](https://fastapi.tiangolo.com/) +[![FastAPI](https://img.shields.io/badge/FastAPI-green.svg)](https://fastapi.tiangolo.com/) [![License](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE) [ [English](README.md) | 中文 ] 将 Gemini 网页端模型封装为兼容 OpenAI API 的 API Server。基于 [HanaokaYuzu/Gemini-API](https://github.com/HanaokaYuzu/Gemini-API) 实现。 -**✅ 无需 API Key,免费通过 API 调用 Gemini 网页端模型!** +**无需 API Key,免费通过 API 调用 Gemini 网页端模型!** ## 功能特性 -- 🔐 **无需 Google API Key**:只需网页 Cookie,即可免费通过 API 调用 Gemini 模型。 -- 🔍 **内置 Google 搜索**:API 已内置 Gemini 网页端的搜索能力,模型响应更加准确。 -- 💾 **会话持久化**:基于 LMDB 存储,支持多轮对话历史记录。 -- 🖼️ **多模态支持**:可处理文本、图片及文件上传。 -- ⚖️ **多账户负载均衡**:支持多账户分发请求,可为每个账户单独配置代理。 +- **无需 Google API Key**:只需网页 Cookie,即可免费通过 API 调用 Gemini 模型。 +- **内置 Google 搜索**:API 已内置 Gemini 网页端的搜索能力,模型响应更加准确。 +- **会话持久化**:基于 LMDB 存储,支持多轮对话历史记录。 +- **多模态支持**:可处理文本、图片及文件上传。 +- **多账户负载均衡**:支持多账户分发请求,可为每个账户单独配置代理。 ## 快速开始 @@ -24,8 +24,8 @@ ### 前置条件 -- Python 3.13 -- 拥有网页版 Gemini 访问权限的 Google 账号 +- Python >= 3.13 +- 拥有网页版 Gemini 访问权限的 Google 账号 (开启 **[Gemini Apps 应用活动](https://myactivity.google.com/product/gemini)** 以获得最佳会话持久化体验) - 从 Gemini 网页获取的 `secure_1psid` 和 `secure_1psidts` Cookie ### 安装 @@ -56,7 +56,8 @@ gemini: - id: "client-a" secure_1psid: "YOUR_SECURE_1PSID_HERE" secure_1psidts: "YOUR_SECURE_1PSIDTS_HERE" - proxy: null # Optional proxy URL (null/empty keeps direct connection) + proxy: null # 可选代理 URL (null/空值则保持直连) + impersonate: null # 可选浏览器指纹模拟 (null 则使用库的默认值) ``` > [!NOTE] @@ -80,23 +81,58 @@ python run.py ### OpenAI 兼容接口 -这些接口遵循 OpenAI 的 API 规范,允许你将 Gemini 作为 **Drop-in 替代方案** 直接接入现有的 AI 应用。 +这些接口使用 OpenAI 兼容的传输格式,并将请求转换后发送给 Gemini 网页端。兼容范围有意 +覆盖 Gemini 网页端客户端实际暴露的控制项:对于客户端无法转发但请求模型已识别的有效选项, +服务会正常接受并忽略,同时在调试日志中记录选项名称,避免其阻止其他可表示的请求内容执行。 - **`GET /v1/models`**: 列出所有可用的 Gemini 模型。 - **`POST /v1/chat/completions`**: 统一聊天对话接口。 - **流式传输**: 设置 `stream: true` 即可实时接收增量响应 (Stream Delta)。 - **多模态支持**: 支持在消息中包含文本、图片以及文件上传。 - **工具调用**: 支持通过 `tools` 参数进行函数调用 (Function Calling)。 - - **结构化输出**: 支持 `response_format`,可严格遵循 JSON Schema。 + - **结构化输出**: 支持 `response_format` 的全部模式。`json_schema` 会在服务器端按所给 + Schema 验证;`json_object`(JSON 模式)只要求回复能解析为 JSON;`text` 为默认值,不作限制。 ### 高级接口 -- **`POST /v1/responses`**: 用于复杂交互模式的专用接口,支持分步输出、生成图片及工具调用等更丰富的响应项。 +- **`POST /v1/responses`**: 支持当前的 `text.format` 结构化输出(`text`、`json_object` + 与 `json_schema`)、外部或内联文件输入、图片生成及工具调用。由于本项目没有实现 + OpenAI Files API,因此会拒绝 `file_id` 引用。 -### 辅助与系统接口 +Schema 的强制程度与 OpenAI 自身的承诺保持一致:`strict: true` 时,回复不符合 Schema 即视为 +错误;`strict: false` 或 JSON 模式仅承诺尽力而为,因此不符合的回复会以文本形式返回而不会让 +请求失败。返回工具调用的轮次不受 Schema 约束——Schema 只约束最终答案。 -- **`GET /health`**: 健康检查接口。返回服务器运行状态、已配置的 Gemini 客户端健康度以及对话存储统计信息。 -- **`GET /images/{filename}`**: 用于访问生成的图片的内部接口。需携带有效 Token(API 返回的图片 URL 中已自动包含该 Token)。 +`strict` 在两个 OpenAI 接口上均默认为 `false`,与 OpenAI 自身一致,因此同一个 Schema 在任一 +接口上的行为相同。该开关在本项目中比上游更关键:Gemini 网页端没有受约束解码,Schema 只能 +通过提示词表达,因此一旦启用严格模式,模型的细微偏差就会让调用方彻底失去这次回复。出于同样 +的原因,Gemini 原生接口的 `generationConfig.responseSchema` / `responseJsonSchema` 始终按尽力 +而为处理——它没有可供关闭的 `strict` 开关。 + +只有模型自身的失败才会被追究。本服务无法求值的 Schema(不是合法的 JSON Schema,或 `$ref` +无法解析)仍会展示给模型,但不会用于判定回复;正则关键字耗尽 +`server.schema_validation_budget_seconds` 时,回复将保持未校验状态。即使在 `strict` 下,这些 +情况都不会让请求失败——它们是本服务的能力缺口,而非模型的违规。 + +由于 JSON 文档只有在完整后才能校验,带结构化要求的流式响应会在校验完成后作为单个分块返回, +而非逐步下发。Gemini 原生接口的 `responseMimeType: application/json` 同样适用。 + +Gemini 网页端未暴露的生成控制项,例如 `temperature`、`top_p`、最大输出 Token 数、 +`parallel_tool_calls`、Gemini `generationConfig` 字段及安全设置,仍会被接受,但无法影响上游生成。 +服务会忽略这些已识别选项并写入调试日志。只有格式错误的输入,或本服务完全无法解析的内容 +(例如无法解析的 Files API ID、`cachedContent` 句柄)才会被拒绝——静默丢弃这类内容会改变 +模型实际回答的问题。未知字段遵循 Pydantic 的默认忽略行为。 + +在 Gemini 接口上,`generationConfig.responseSchema` 属于 OpenAPI 3.0 子集(大写类型名、 +`nullable`),使用前会转换为 JSON Schema;`responseJsonSchema` 本身就是 JSON Schema,按其 +标准验证。`toolConfig.functionCallingConfig.allowedFunctionNames` 仅在真正生效的 `ANY` 与 +`VALIDATED` 模式下才会收窄工具列表。 + +### 实用工具接口 + +- **`GET /health`**: 就绪状态接口。对话存储不可用时始终返回 HTTP 503。客户端故障则遵循 + `gemini.guest_mode` 健康策略;默认的 `adaptive` 策略仅在所有 Gemini 客户端均不健康时返回 503。 +- **`GET /media/{filename}`**: 用于分发生成的媒体内容的内部接口。需要有效的 Token(API 返回的图片 URL 中已自动包含该 Token)。 ## Docker 部署 @@ -180,10 +216,24 @@ export CONFIG_GEMINI__CLIENTS__0__SECURE_1PSIDTS="your-secure-1psidts" # 覆盖 Client 0 的代理设置 export CONFIG_GEMINI__CLIENTS__0__PROXY="socks5://127.0.0.1:1080" +# 覆盖 Client 0 的浏览器指纹模拟 +export CONFIG_GEMINI__CLIENTS__0__IMPERSONATE="chrome" + + # 覆盖对话存储大小限制 export CONFIG_STORAGE__MAX_SIZE=268435456 # 256 MB + +# 覆盖本地 HTTP 请求体资源保护上限(设为 0 可禁用) +export CONFIG_SERVER__MAX_REQUEST_BODY_BYTES=268435456 + +# 覆盖 JSON Schema 正则求值预算(单位:秒) +export CONFIG_SERVER__SCHEMA_VALIDATION_BUDGET_SECONDS=1.0 ``` +`max_request_body_bytes` 仅是封装层用于保护内存和本地资源的可配置上限,并非 OpenAI 或 +Gemini API 的兼容性限制,也不代表 Gemini 网页端的容量。通过本地检查后,请求最终是否可被 +接受仍由 Gemini 网页端决定。 + ### 客户端 ID 与会话重用 会话在保存时会绑定创建它的客户端 ID。请在配置中保持这些 `id` 值稳定, @@ -194,6 +244,11 @@ export CONFIG_STORAGE__MAX_SIZE=268435456 # 256 MB > [!WARNING] > 请妥善保管这些凭据,切勿提交到版本控制。这些 Cookie 可访问你的 Google 账号。 + + +> [!WARNING] +> **会话稳定性**:如果 Cookie 频繁过期,请使用 Firefox 提取 Cookie。较新的 Chromium 内核浏览器版本使用“设备绑定会话凭据”(Device Bound Session Credentials),虽然提高了安全性,但会使 Cookie 仅在几小时内有效且无法续期。 + 使用 Gemini-FastAPI 需提取 Gemini 会话 Cookie: 1. 在无痕/隐私窗口打开 [Gemini](https://gemini.google.com/) 并登录 @@ -203,37 +258,122 @@ export CONFIG_STORAGE__MAX_SIZE=268435456 # 256 MB - `__Secure-1PSID` - `__Secure-1PSIDTS` -> [!TIP] -> 详细操作请参考 [HanaokaYuzu/Gemini-API 认证指南](https://github.com/HanaokaYuzu/Gemini-API?tab=readme-ov-file#authentication)。 +> [!IMPORTANT] +> **请开启 [Gemini Apps 应用活动](https://myactivity.google.com/product/gemini)** 以确保稳定的会话持久化。 +> +> 虽然在没有开启该设置的情况下,连续的聊天过程可能暂时正常,但任何瞬时错误、TLS 会话重启或服务器重启都可能导致 Google 端过期的会话元数据。如果该设置被禁用,模型将 **完全丢失多轮对话的上下文**,导致即使本地 LMDB 中存有历史记录,旧对话也将无法继续。 ### 代理设置 每个客户端条目可以配置不同的代理,从而规避速率限制。省略 `proxy` 字段或将其设置为 `null` 或空字符串以保持直连。 -### 自定义模型 +### 浏览器指纹模拟 -你可以在 `config/config.yaml` 中或通过环境变量定义自定义模型。 +每个客户端可以通过 `impersonate` 参数设置 `curl_cffi` 使用的 TLS/HTTP 指纹。 -#### YAML 配置 +- 设置为 `null`(默认)则使用库的默认值。 +- 可设为 [`curl_cffi` 的 `BrowserTypeLiteral`](https://github.com/lexiforest/curl_cffi) 支持的任意值。 +- 启动时会校验该值;无效值会阻止服务启动。 ```yaml gemini: - model_strategy: "append" # "append" (默认 + 自定义) 或 "overwrite" (仅限自定义) - models: - - model_name: "gemini-3.0-pro" - model_header: - x-goog-ext-525001261-jspb: '[1,null,null,null,"9d8ca3786ebdfbea",null,null,0,[4],null,null,1]' + clients: + - id: "client-a" + impersonate: "chrome" # 使用 Chrome 指纹 + - id: "client-b" + impersonate: null # 使用库默认值 ``` -#### 环境变量 +### 会话模式 -你可以通过 `CONFIG_GEMINI__MODELS` 以 JSON 字符串或列表结构的形式提供模型。这为通过 shell 或在自动化环境(例如 Docker)中覆盖设置提供了一种灵活的方式,而无需修改配置文件。 +你可以控制请求使用普通的 Google 会话,还是 Google 的临时会话模式: + +```yaml +gemini: + chat_mode: "normal" # "normal"(普通)或 "temporary"(临时) + guest_mode: "adaptive" # "strict"(严格)、"adaptive"(自适应)或 "permissive"(宽松) + max_chars_per_request: 1000000 +``` + +设置为 `temporary` 时,对话不会保存到 Google 账号中。只要 Google 尚未关闭该临时窗口, +临时会话仍然可以继续对话,因此会话重用与会话存储的行为与普通模式完全一致。 + +当已存储的会话无法再被延续时——例如切换了 `chat_mode`,或 Google 已关闭该临时窗口—— +服务会回退到将完整对话历史重放到一个全新的会话中,从而重建上下文,而不是丢失上下文。 + +每个账号在 Google 侧最多只保留一个处于开启状态的临时窗口:一旦创建新的会话,上一个临时 +会话就会被关闭。因此只有最近一次开启的临时会话仍可继续对话。服务会按客户端记录该会话, +并且**只**重用它;任何更早的临时会话都会以完整历史重放到全新会话中。这里没有需要调节的 +超时时间——该规则直接依据 Google 的实际行为,而不是靠猜测过期时长。 + +该记录刻意只保存在内存中,因此只要客户端会话被重新初始化——例如因闲置触发 `auto_close`、 +服务重启或重新部署——它同样会被清空。发生上述情况后,服务无法再确认任何窗口仍然有效, +所有已存储的临时会话都会改为重放,而不是重用。 + +同一规则也适用于以访客身份运行的客户端,且与 `chat_mode` 无关。当所有 Cookie 分组都失败, +或已认证的会话因 Cookie 过期而在使用过程中被拒绝时,客户端仍会以无账号状态继续处理纯文本 +请求——而访客会话的对话不会写入任何历史记录,因此其行为与临时会话完全一致。每条已存储的 +对话都会记录其所属的会话窗口,因此已认证状态下开启的会话不会被重放到访客会话中,访客状态 +下开启的会话也不会在 Cookie 恢复后被重放;无论哪个方向的跨越,都会回退为在全新会话中重放 +完整历史。 + +访客会话会维持服务不中断,而不是让服务失效;相关请求会降级处理,而不是直接失败: + +- 客户端池优先选择已认证的客户端,被降级为访客的客户端只有在没有任何已认证客户端可用时才会 + 承接流量。 +- 需要上传文件的请求——包括附件,以及需要以 `message.txt` 形式发送的超长输入——会被路由到 + 已认证的客户端,即使已存储的会话原本会将其绑定到访客客户端。若没有可用的已认证客户端, + 请求会返回明确的错误说明,而不是 Google 的 `Permission denied`。 +- Google 不为访客提供模型选择,因此所请求的模型会被替换为访客被允许使用的默认模型,并记录 + 一条警告。`/v1/models` 只会公布客户端确实能够提供服务的模型。 + +`/health` 会将访客客户端报告为不健康——请刷新其 Cookie 以恢复完整能力。 +`guest_mode` 设置决定这些不健康客户端如何影响就绪状态响应: + +- `strict`(严格):任一客户端不健康时返回 HTTP 503。 +- `adaptive`(自适应,默认):仅当所有客户端均不健康时返回 HTTP 503;若仍有健康客户端, + 则只记录警告并保持就绪。 +- `permissive`(宽松):只记录警告,不改变就绪状态,即使所有客户端均不健康也是如此。 + +三种模式都会记录不健康客户端。无论 `guest_mode` 为何,对话存储不可用时仍会返回 HTTP 503。 + +除此之外,以上规则**仅**在临时模式下生效:由已认证客户端开启的普通会话在用户手动删除之前 +会一直由 Google 保留,因此其元数据可以长期重用,并且不受重启影响。 + +> [!WARNING] +> Google 可能在任意时刻、且不作任何提示地关闭临时会话窗口,包括在对话进行到一半时。 +> 此时模型可能直接返回不含既有上下文的回复,而不会抛出错误,因此上下文丢失可能是静默的。 +> 只要服务能够识别出该会话已失效,就会将完整历史重放到新会话中,但这种识别并非总能成功。 +> 对于较长或对上下文较敏感的对话,建议使用 `normal`;`temporary` 的连续性应视为尽力而为。 + +由于临时会话可接受的负载更小,服务会在标准安全余量的基础上再收紧 10%, +因此有效输入上限为 `max_chars_per_request` 的 81%(而非 90%)。 +两种模式下,超出有效上限的输入仍会以 `message.txt` 附件的形式发送。 + +环境变量等价写法: ```bash -export CONFIG_GEMINI__MODEL_STRATEGY="overwrite" -export CONFIG_GEMINI__MODELS='[{"model_name": "gemini-3.0-pro", "model_header": {"x-goog-ext-525001261-jspb": "[1,null,null,null,\"9d8ca3786ebdfbea\",null,null,0,[4],null,null,1]"}}]' +export CONFIG_GEMINI__CHAT_MODE="temporary" +export CONFIG_GEMINI__GUEST_MODE="adaptive" ``` +### 模型 + +模型在启动时从 Google 动态获取,无需任何配置。每个客户端会读取自己账号可用的模型,并在运行时 +构建对应的请求头,因此只要 Google 提供了新发布的模型,服务就能立即使用。`GET /v1/models` +列出的是运行中的客户端确实能够提供服务的模型。 + +请求可以使用模型的规范名称(当前为 `gemini-pro`、`gemini-flash`、`gemini-flash-lite`)、别名或 +显示名称(`pro`、`Flash Lite`),也可以使用其内部 id;所有写法都会解析到同一份会话历史。模型 +名称来自 Google 而非本项目,可通过 `GET /v1/models` 查看你自己账号实际可用的列表。服务只提供 +动态获取到的模型——若没有任何客户端提供该模型,则返回 `400`,而不会悄悄改用其他模型作答。 + +> [!NOTE] +> `models` 与 `model_strategy` 配置项已移除,底层库中基于静态 `Model` 枚举的名称查找也已删除。 +> 它们的作用是在库尚未跟上新模型发布时手写模型请求头,而动态获取已让这一需求不再存在——如今 +> 硬编码的请求头反而有把请求固定在过时模型上的风险。若配置文件或环境变量中仍保留这两个键, +> 将被直接忽略。 + ## 鸣谢 - [HanaokaYuzu/Gemini-API](https://github.com/HanaokaYuzu/Gemini-API) - 底层 Gemini Web API 客户端 diff --git a/app/main.py b/app/main.py index 20d15b0..ce4c929 100644 --- a/app/main.py +++ b/app/main.py @@ -1,19 +1,38 @@ import asyncio +import contextlib +import mimetypes from contextlib import asynccontextmanager from fastapi import FastAPI from loguru import logger +from .server.chat import refresh_available_models_cache from .server.chat import router as chat_router +from .server.gemini import add_gemini_exception_handlers +from .server.gemini import router as gemini_router from .server.health import router as health_router -from .server.images import router as images_router +from .server.media import router as media_router from .server.middleware import ( add_cors_middleware, add_exception_handler, - cleanup_expired_images, + add_request_size_limit_middleware, + cleanup_expired_media, ) from .services import GeminiClientPool, LMDBConversationStore +# Canonical audio MIME types: Python's platform mapping yields legacy "x-" +# types (audio/x-wav, audio/x-aac, audio/x-flac) that Google's upload +# endpoint does not classify as audio, so the attached file never reaches +# the model as an audible attachment. Registered at import time, before any upload. +for _ext, _mime in ( + (".wav", "audio/wav"), + (".aac", "audio/aac"), + (".flac", "audio/flac"), + (".m4a", "audio/mp4"), +): + with contextlib.suppress(ValueError): + mimetypes.add_type(_mime, _ext) + RETENTION_CLEANUP_INTERVAL_SECONDS = 6 * 60 * 60 # Check every 6 hours @@ -33,7 +52,7 @@ async def _run_retention_cleanup(stop_event: asyncio.Event) -> None: while not stop_event.is_set(): try: store.cleanup_expired() - cleanup_expired_images(store.retention_days) + cleanup_expired_media(store.retention_days) except Exception: logger.exception("LMDB retention cleanup task failed.") @@ -55,19 +74,28 @@ async def lifespan(app: FastAPI): pool = GeminiClientPool() try: await pool.init() + await refresh_available_models_cache(pool) except Exception as e: logger.exception(f"Failed to initialize Gemini clients: {e}") raise + try: + LMDBConversationStore().prune_stale_indexes() + except Exception: + logger.exception("Failed to prune stale LMDB indexes; continuing with startup.") + cleanup_task = asyncio.create_task(_run_retention_cleanup(cleanup_stop_event)) - # Give the cleanup task a chance to start and surface immediate failures. + + # Give the tasks a chance to start and surface immediate failures. await asyncio.sleep(0) - if cleanup_task.done(): - try: - cleanup_task.result() - except Exception: - logger.exception("LMDB retention cleanup task failed to start.") - raise + + for task, name in [(cleanup_task, "LMDB retention cleanup")]: + if task.done(): + try: + task.result() + except Exception: + logger.exception(f"{name} task failed to start.") + raise logger.info(f"Gemini clients initialized: {[c.id for c in pool.clients]}.") logger.info("Gemini API Server ready to serve requests.") @@ -76,13 +104,18 @@ async def lifespan(app: FastAPI): yield finally: cleanup_stop_event.set() + try: + await pool.close() + except Exception: + logger.exception("Failed to close Gemini client pool gracefully.") + try: await cleanup_task except asyncio.CancelledError: - logger.debug("LMDB retention cleanup task cancelled during shutdown.") + logger.debug("Background tasks cancelled during shutdown.") except Exception: logger.exception( - "LMDB retention cleanup task terminated with an unexpected error during shutdown." + "One or more background tasks terminated with an unexpected error during shutdown." ) @@ -94,11 +127,16 @@ def create_app() -> FastAPI: lifespan=lifespan, ) + # Order matters: the last middleware added is the outermost, so the body limit has to be + # registered first for its 413 to still pass back out through CORS. + add_request_size_limit_middleware(app) add_cors_middleware(app) add_exception_handler(app) + add_gemini_exception_handlers(app) app.include_router(health_router, tags=["Health"]) app.include_router(chat_router, tags=["Chat"]) - app.include_router(images_router, tags=["Images"]) + app.include_router(media_router, tags=["Media"]) + app.include_router(gemini_router, tags=["Gemini"]) return app diff --git a/app/models/__init__.py b/app/models/__init__.py index 7f95131..d83841b 100644 --- a/app/models/__init__.py +++ b/app/models/__init__.py @@ -1,69 +1,5 @@ -from .models import ( - ChatCompletionRequest, - ChatCompletionResponse, - Choice, - ContentItem, - ConversationInStore, - FunctionCall, - HealthCheckResponse, - Message, - ModelData, - ModelListResponse, - ResponseCreateRequest, - ResponseCreateResponse, - ResponseImageGenerationCall, - ResponseImageTool, - ResponseInputContent, - ResponseInputItem, - ResponseOutputContent, - ResponseOutputMessage, - ResponseReasoning, - ResponseReasoningContentPart, - ResponseSummaryPart, - ResponseTextConfig, - ResponseTextFormat, - ResponseToolCall, - ResponseToolChoice, - ResponseUsage, - Tool, - ToolCall, - ToolChoiceFunction, - ToolChoiceFunctionDetail, - ToolFunctionDefinition, - Usage, -) +# ruff: noqa: F403 -__all__ = [ - "ChatCompletionRequest", - "ChatCompletionResponse", - "Choice", - "ContentItem", - "ConversationInStore", - "FunctionCall", - "HealthCheckResponse", - "Message", - "ModelData", - "ModelListResponse", - "ResponseCreateRequest", - "ResponseCreateResponse", - "ResponseImageGenerationCall", - "ResponseImageTool", - "ResponseInputContent", - "ResponseInputItem", - "ResponseOutputContent", - "ResponseOutputMessage", - "ResponseReasoning", - "ResponseReasoningContentPart", - "ResponseSummaryPart", - "ResponseTextConfig", - "ResponseTextFormat", - "ResponseToolCall", - "ResponseToolChoice", - "ResponseUsage", - "Tool", - "ToolCall", - "ToolChoiceFunction", - "ToolChoiceFunctionDetail", - "ToolFunctionDefinition", - "Usage", -] +from .core import * +from .gemini_models import * +from .models import * diff --git a/app/models/core.py b/app/models/core.py new file mode 100644 index 0000000..195d6d4 --- /dev/null +++ b/app/models/core.py @@ -0,0 +1,92 @@ +from __future__ import annotations + +import base64 +import hashlib +from datetime import datetime +from typing import Any, Literal + +from pydantic import BaseModel, Field, model_validator + + +class AppToolCallFunction(BaseModel): + name: str + arguments: str + + +class AppToolCall(BaseModel): + id: str + type: Literal["function"] = "function" + function: AppToolCallFunction + + +class AppContentItem(BaseModel): + type: str + text: str | None = None + url: str | None = None + file_data: str | bytes | None = Field(default=None, exclude=True) + filename: str | None = None + raw_data: dict[str, Any] | None = None + content_digest: str | None = None + + @model_validator(mode="after") + def populate_content_digest(self) -> AppContentItem: + """Persist a digest for inline media whose raw bytes are intentionally excluded. + + Only `file_data` and inline data URLs need one. Every other field survives + serialization, so it can be compared directly instead of through a fingerprint. + """ + if self.content_digest: + return self + + encoded = self.file_data + if encoded is None and self.url and self.url.startswith("data:"): + encoded = self.url.partition(",")[2] + if encoded is None: + return self + + # utf-8, not ascii: a data URL may carry non-Base64 text, and a codec error here + # would abort model construction. + raw = encoded.encode("utf-8", "surrogatepass") if isinstance(encoded, str) else encoded + if raw.startswith(b"data:"): + raw = raw.partition(b",")[2] + try: + digest_input = base64.b64decode(b"".join(raw.split()), validate=True) + except ValueError: + digest_input = raw + + self.content_digest = hashlib.sha256(digest_input).hexdigest() + return self + + +type AppMessageRole = Literal["system", "user", "assistant", "tool"] + + +class AppMessage(BaseModel): + role: AppMessageRole + name: str | None = None + content: str | list[AppContentItem] | None = None + tool_calls: list[AppToolCall] | None = None + tool_call_id: str | None = None + reasoning_content: str | None = None + + +class ConversationInStore(BaseModel): + """Persisted conversation record stored in LMDB.""" + + created_at: datetime | None = Field(default=None) + updated_at: datetime | None = Field(default=None) + model: str = Field(..., description="Model used for the conversation") + client_id: str = Field(..., description="Identifier of the Gemini client") + metadata: list[str | None] = Field( + ..., description="Metadata for Gemini API to locate the conversation" + ) + messages: list[AppMessage] = Field( + ..., description="Canonical message contents in the conversation" + ) + chat_scope: str | None = Field( + default=None, + description=( + "Identity of the ephemeral window this chat lives in, or None for a normal chat kept " + "in the account's history. Reusable only while the client still reports this scope" + ), + ) diff --git a/app/models/gemini_models.py b/app/models/gemini_models.py new file mode 100644 index 0000000..3e9d4f3 --- /dev/null +++ b/app/models/gemini_models.py @@ -0,0 +1,284 @@ +# ruff: noqa: N815 # camelCase field names are the Gemini REST wire format +"""Gemini REST API 原生格式的 Pydantic 数据模型。 + +覆盖 generateContent / streamGenerateContent / models.list / models.get 端点所需的 +请求体和响应体结构, 遵循 Google Gemini REST API v1beta 规范。 +(Adapted from fork fujunchao — unchanged, per C1 contract.) +""" + +from __future__ import annotations + +from typing import Any, Literal + +from pydantic import BaseModel, Field + +# --------------------------------------------------------------------------- +# 请求模型 — 内部 Part 类型 +# --------------------------------------------------------------------------- + + +class GeminiInlineData(BaseModel): + """内嵌二进制数据(图片等)。""" + + mimeType: str + data: str # base64 + + +class GeminiFileData(BaseModel): + """通过 Files API 上传的文件引用。""" + + mimeType: str + fileUri: str + + +class GeminiFunctionCall(BaseModel): + """模型发起的函数调用。""" + + name: str + args: dict[str, Any] = Field(default_factory=dict) + + +class GeminiFunctionResponse(BaseModel): + """用户提交的函数执行结果。""" + + name: str + response: dict[str, Any] = Field(default_factory=dict) + + +class GeminiPart(BaseModel): + """Content 中的一个 part, 可能是文本/思考、内嵌数据、函数调用或函数响应。""" + + text: str | None = None + thought: bool | None = None + inlineData: GeminiInlineData | None = None + functionCall: GeminiFunctionCall | None = None + functionResponse: GeminiFunctionResponse | None = None + fileData: GeminiFileData | None = None + + +# --------------------------------------------------------------------------- +# 请求模型 — 顶层结构 +# --------------------------------------------------------------------------- + + +class GeminiContent(BaseModel): + """一条消息内容(用户 / 模型 / 函数角色)。""" + + role: Literal["user", "model", "function"] | None = None + parts: list[GeminiPart] = Field(default_factory=list) + + +class GeminiSystemInstruction(BaseModel): + """系统指令(顶层, 不在 contents 中)。""" + + parts: list[GeminiPart] = Field(default_factory=list) + + +class GeminiFunctionDeclaration(BaseModel): + """函数声明。""" + + name: str + description: str | None = None + parameters: dict[str, Any] | None = None + + +class GeminiTool(BaseModel): + """工具集合。""" + + functionDeclarations: list[GeminiFunctionDeclaration] = Field(default_factory=list) + + +class GeminiFunctionCallingConfig(BaseModel): + """函数调用配置。""" + + mode: Literal["AUTO", "NONE", "ANY", "VALIDATED"] = "AUTO" + allowedFunctionNames: list[str] | None = None + + +class GeminiToolConfig(BaseModel): + """工具配置。""" + + functionCallingConfig: GeminiFunctionCallingConfig | None = None + + +class GeminiSafetySetting(BaseModel): + """安全设置。""" + + category: str + threshold: str + + +class GeminiThinkingConfig(BaseModel): + """思考配置(控制模型推理行为)。""" + + includeThoughts: bool | None = None + thinkingBudget: int | None = None + thinkingLevel: str | None = None # OFF | LOW | MEDIUM | HIGH + + +class GeminiGenerationConfig(BaseModel): + """生成参数。""" + + temperature: float | None = None + topP: float | None = None + topK: int | None = None + maxOutputTokens: int | None = None + stopSequences: list[str] | None = None + responseMimeType: str | None = None + responseSchema: dict[str, Any] | None = None + responseJsonSchema: dict[str, Any] | None = None + candidateCount: int | None = None + thinkingConfig: GeminiThinkingConfig | None = None + responseFormat: dict[str, Any] | None = None + + +class GeminiGenerateContentRequest(BaseModel): + """generateContent / streamGenerateContent 请求体。""" + + contents: list[GeminiContent] = Field(default_factory=list) + systemInstruction: GeminiSystemInstruction | None = None + tools: list[GeminiTool] | None = None + toolConfig: GeminiToolConfig | None = None + safetySettings: list[GeminiSafetySetting] | None = None + generationConfig: GeminiGenerationConfig | None = None + cachedContent: str | None = None + + +# --------------------------------------------------------------------------- +# 响应模型 +# --------------------------------------------------------------------------- + + +class GeminiSafetyRating(BaseModel): + """安全评级。""" + + category: str + probability: str + + +class GeminiCitationSource(BaseModel): + """单条引用来源。""" + + startIndex: int | None = None + endIndex: int | None = None + uri: str | None = None + license: str | None = None + + +class GeminiCitationMetadata(BaseModel): + """引用元数据。""" + + citationSources: list[GeminiCitationSource] = Field(default_factory=list) + + +class GeminiGroundingChunkWeb(BaseModel): + """溯源来源网页信息。""" + + uri: str | None = None + title: str | None = None + + +class GeminiGroundingChunk(BaseModel): + """溯源来源块。""" + + web: GeminiGroundingChunkWeb | None = None + + +class GeminiSearchEntryPoint(BaseModel): + """搜索入口点。""" + + renderedContent: str | None = None + sdkBlob: str | None = None + + +class GeminiGroundingSupport(BaseModel): + """溯源支撑片段。""" + + segment: dict[str, Any] | None = None + groundingChunkIndices: list[int] = Field(default_factory=list) + confidenceScores: list[float] = Field(default_factory=list) + + +class GeminiGroundingMetadata(BaseModel): + """溯源元数据(Google Search 等)。""" + + webSearchQueries: list[str] = Field(default_factory=list) + groundingChunks: list[GeminiGroundingChunk] = Field(default_factory=list) + searchEntryPoint: GeminiSearchEntryPoint | None = None + groundingSupports: list[GeminiGroundingSupport] = Field(default_factory=list) + + +class GeminiCandidate(BaseModel): + """生成候选项。""" + + content: GeminiContent | None = None + finishReason: str | None = None + index: int = 0 + safetyRatings: list[GeminiSafetyRating] = Field(default_factory=list) + citationMetadata: GeminiCitationMetadata | None = None + groundingMetadata: GeminiGroundingMetadata | None = None + tokenCount: int | None = None + avgLogprobs: float | None = None + + +class GeminiUsageMetadata(BaseModel): + """用量统计。""" + + promptTokenCount: int = 0 + candidatesTokenCount: int = 0 + totalTokenCount: int = 0 + thoughtsTokenCount: int | None = None + cachedContentTokenCount: int | None = None + toolUsePromptTokenCount: int | None = None + + +class GeminiGenerateContentResponse(BaseModel): + """generateContent / streamGenerateContent 响应体。""" + + candidates: list[GeminiCandidate] = Field(default_factory=list) + usageMetadata: GeminiUsageMetadata | None = None + modelVersion: str | None = None + + +# --------------------------------------------------------------------------- +# models.list / models.get 响应 +# --------------------------------------------------------------------------- + + +class GeminiModelInfo(BaseModel): + """单个模型信息。""" + + name: str + version: str | None = None + displayName: str | None = None + description: str | None = None + inputTokenLimit: int | None = None + outputTokenLimit: int | None = None + supportedGenerationMethods: list[str] = Field(default_factory=list) + + +class GeminiModelListResponse(BaseModel): + """models.list 响应体。""" + + models: list[GeminiModelInfo] = Field(default_factory=list) + nextPageToken: str | None = None + + +# --------------------------------------------------------------------------- +# 错误响应 +# --------------------------------------------------------------------------- + + +class GeminiErrorDetail(BaseModel): + """Google API 标准错误详情。""" + + code: int + message: str + status: str + details: list[dict[str, Any]] = Field(default_factory=list) + + +class GeminiErrorResponse(BaseModel): + """Google API 标准错误包装。""" + + error: GeminiErrorDetail diff --git a/app/models/models.py b/app/models/models.py index fccfba1..adc73f8 100644 --- a/app/models/models.py +++ b/app/models/models.py @@ -1,97 +1,137 @@ from __future__ import annotations -from datetime import datetime +from collections.abc import Mapping +from dataclasses import dataclass from typing import Any, Literal -from pydantic import BaseModel, Field, model_validator +from pydantic import BaseModel, Field, StrictBool, model_validator -class ContentItem(BaseModel): - """Individual content item (text, image, or file) within a message.""" +@dataclass +class StructuredOutputRequirement: + """Represents a structured response request from the client.""" + + schema_name: str + schema: dict[str, Any] + instruction: str + raw_format: dict[str, Any] + strict: bool = True + """Whether schema adherence is guaranteed to the client. + + Mirrors OpenAI's `strict` flag: Structured Outputs (`strict: true`) promise the response + matches the schema, so a violation has to surface as an error. JSON mode and `strict: false` + only promise a best effort, so a violation degrades to the raw text instead. + """ + + +class FunctionCall(BaseModel): + """Executed function call payload.""" + + name: str + arguments: str + + +class FunctionDefinition(BaseModel): + """Schema of a callable function exposed to the model.""" + + name: str + description: str | None = Field(default=None) + parameters: dict[str, Any] | None = Field(default=None) + + +class ChatCompletionRequestContentItem(BaseModel): + """Content item for user / system / tool messages.""" type: Literal["text", "image_url", "file", "input_audio"] text: str | None = Field(default=None) image_url: dict[str, Any] | None = Field(default=None) input_audio: dict[str, Any] | None = Field(default=None) file: dict[str, Any] | None = Field(default=None) - annotations: list[dict[str, Any]] = Field(default_factory=list) -class Message(BaseModel): - """Message model""" +class ChatCompletionAssistantContentItem(BaseModel): + """Content item for assistant messages. - role: str - content: str | list[ContentItem] | None = Field(default=None) - name: str | None = Field(default=None) - tool_calls: list[ToolCall] | None = Field(default=None) - tool_call_id: str | None = Field(default=None) + ``refusal`` is an official OpenAI content part. + ``reasoning`` is a community extension used to persist chain-of-thought + text for reusable-session matching. + """ + + type: Literal["text", "refusal", "reasoning"] + text: str | None = Field(default=None) refusal: str | None = Field(default=None) - reasoning_content: str | None = Field(default=None) - audio: dict[str, Any] | None = Field(default=None) annotations: list[dict[str, Any]] = Field(default_factory=list) - @model_validator(mode="after") - def normalize_role(self) -> Message: - """Normalize 'developer' role to 'system' for Gemini compatibility.""" - if self.role == "developer": - self.role = "system" - return self - - -class Choice(BaseModel): - """Choice model""" - - index: int - message: Message - finish_reason: str - logprobs: dict[str, Any] | None = Field(default=None) - -class FunctionCall(BaseModel): - """Function call payload""" - - name: str - arguments: str +ChatCompletionContentItem = ChatCompletionRequestContentItem | ChatCompletionAssistantContentItem -class ToolCall(BaseModel): - """Tool call item""" +class ChatCompletionMessageToolCall(BaseModel): + """A single tool call emitted by the assistant.""" id: str type: Literal["function"] function: FunctionCall -class ToolFunctionDefinition(BaseModel): - """Function definition for tool.""" +class ChatCompletionMessage(BaseModel): + """A single message in a Chat Completions conversation.""" - name: str - description: str | None = Field(default=None) - parameters: dict[str, Any] | None = Field(default=None) - - -class Tool(BaseModel): - """Tool specification.""" + role: Literal["developer", "system", "user", "assistant", "tool", "function"] + content: ( + str | list[ChatCompletionRequestContentItem | ChatCompletionAssistantContentItem] | None + ) = Field(default=None) + name: str | None = Field(default=None) + tool_calls: list[ChatCompletionMessageToolCall] | None = Field(default=None) + tool_call_id: str | None = Field(default=None) + refusal: str | None = Field(default=None) + reasoning_content: str | None = Field(default=None) + audio: dict[str, Any] | None = Field(default=None) + annotations: list[dict[str, Any]] = Field(default_factory=list) - type: Literal["function"] - function: ToolFunctionDefinition + @model_validator(mode="after") + def normalize_role(self) -> ChatCompletionMessage: + """Normalize ``developer`` role to ``system`` for Gemini compatibility.""" + if self.role == "developer": + self.role = "system" + return self -class ToolChoiceFunctionDetail(BaseModel): - """Detail of a tool choice function.""" +class ChatCompletionFunctionTool(BaseModel): + """A function tool for the Chat Completions API.""" + type: Literal["function"] + function: FunctionDefinition + + @model_validator(mode="before") + @classmethod + def _nest_flat_function(cls, data: Any) -> Any: + if isinstance(data, dict) and "function" not in data and "name" in data: + return { + "type": data.get("type", "function"), + "function": { + "name": data.get("name"), + "description": data.get("description"), + "parameters": data.get("parameters"), + "strict": data.get("strict"), + }, + } + return data + + +class ChatCompletionNamedToolChoiceFunction(BaseModel): name: str -class ToolChoiceFunction(BaseModel): - """Tool choice forcing a specific function.""" +class ChatCompletionNamedToolChoice(BaseModel): + """Forces the model to call a specific named function.""" type: Literal["function"] - function: ToolChoiceFunctionDetail + function: ChatCompletionNamedToolChoiceFunction -class Usage(BaseModel): - """Usage statistics model""" +class CompletionUsage(BaseModel): + """Token-usage statistics for a Chat Completions response.""" prompt_tokens: int completion_tokens: int @@ -100,130 +140,170 @@ class Usage(BaseModel): completion_tokens_details: dict[str, int] | None = Field(default=None) -class ModelData(BaseModel): - """Model data model""" +class ChatCompletionChoice(BaseModel): + """A single completion choice.""" - id: str - object: str = "model" - created: int - owned_by: str = "google" + index: int + message: ChatCompletionMessage + finish_reason: Literal["stop", "length", "tool_calls", "content_filter"] + logprobs: dict[str, Any] | None = Field(default=None) class ChatCompletionRequest(BaseModel): - """Chat completion request model""" + """Request body for POST /v1/chat/completions.""" model: str - messages: list[Message] + messages: list[ChatCompletionMessage] stream: bool | None = Field(default=False) - user: str | None = Field(default=None) - temperature: float | None = Field(default=0.7) - top_p: float | None = Field(default=1.0) - max_tokens: int | None = Field(default=None) - tools: list[Tool] | None = Field(default=None) - tool_choice: ( - Literal["none"] | Literal["auto"] | Literal["required"] | ToolChoiceFunction | None - ) = Field(default=None) + stream_options: dict[str, Any] | None = Field(default=None) + prompt_cache_key: str | None = Field(default=None) + temperature: float | None = Field(default=1, ge=0, le=2) + top_p: float | None = Field(default=1, ge=0, le=1) + max_completion_tokens: int | None = Field(default=None) + tools: list[ChatCompletionFunctionTool] | None = Field(default=None) + tool_choice: Literal["none", "auto", "required"] | ChatCompletionNamedToolChoice | None = Field( + default=None + ) response_format: dict[str, Any] | None = Field(default=None) + parallel_tool_calls: bool | None = Field(default=True) class ChatCompletionResponse(BaseModel): - """Chat completion response model""" + """Response body for POST /v1/chat/completions.""" id: str object: str = "chat.completion" created: int model: str - choices: list[Choice] - usage: Usage + choices: list[ChatCompletionChoice] + usage: CompletionUsage + system_fingerprint: str | None = Field(default=None) -class ModelListResponse(BaseModel): - """Model list model""" +class ResponseInputText(BaseModel): + """Text content item in a Responses API input message.""" - object: str = "list" - data: list[ModelData] + type: Literal["input_text"] | None = Field(default="input_text") + text: str | None = Field(default=None) -class HealthCheckResponse(BaseModel): - """Health check response model""" +class ResponseInputImage(BaseModel): + """Image content item in a Responses API input message.""" - ok: bool - storage: dict[str, Any] | None = Field(default=None) - clients: dict[str, bool] | None = Field(default=None) - error: str | None = Field(default=None) + type: Literal["input_image"] | None = Field(default="input_image") + detail: Literal["auto", "low", "high"] | None = Field(default=None) + file_id: str | None = Field(default=None) + image_url: str | None = Field(default=None) -class ConversationInStore(BaseModel): - """Conversation model for storing in the database.""" +class ResponseInputFile(BaseModel): + """File content item in a Responses API input message.""" - created_at: datetime | None = Field(default=None) - updated_at: datetime | None = Field(default=None) + type: Literal["input_file"] | None = Field(default="input_file") + file_id: str | None = Field(default=None) + file_url: str | None = Field(default=None) + file_data: str | None = Field(default=None) + filename: str | None = Field(default=None) - # Gemini Web API does not support changing models once a conversation is created. - model: str = Field(..., description="Model used for the conversation") - client_id: str = Field(..., description="Identifier of the Gemini client") - metadata: list[str | None] = Field( - ..., description="Metadata for Gemini API to locate the conversation" - ) - messages: list[Message] = Field(..., description="Message contents in the conversation") +class ResponseInputMessageContentList(BaseModel): + """Normalised content item stored on ``ResponseInputMessage`` server-side. -class ResponseInputContent(BaseModel): - """Content item for Responses API input.""" + Superset of all input content types (text, image, file, reasoning) so they + can be represented in a single model after round-tripping through the server. + """ type: Literal["input_text", "output_text", "reasoning_text", "input_image", "input_file"] text: str | None = Field(default=None) image_url: str | None = Field(default=None) detail: Literal["auto", "low", "high"] | None = Field(default=None) + file_id: str | None = Field(default=None) file_url: str | None = Field(default=None) file_data: str | None = Field(default=None) filename: str | None = Field(default=None) - annotations: list[dict[str, Any]] = Field(default_factory=list) -class ResponseInputItem(BaseModel): - """Single input item for Responses API.""" +class ResponseInputMessage(BaseModel): + """A single conversation turn in a Responses API input list.""" type: Literal["message"] | None = Field(default="message") - role: Literal["user", "assistant", "system", "developer"] - content: str | list[ResponseInputContent] + role: Literal["user", "system", "developer", "assistant"] + content: str | list[ResponseInputText | ResponseInputImage | ResponseInputFile] + status: Literal["in_progress", "completed", "incomplete"] = Field(default="completed") + + +class ResponseFunctionToolCall(BaseModel): + """An assistant function-call item replayed as part of the input history.""" + + type: Literal["function_call"] | None = Field(default="function_call") + id: str | None = Field(default=None) + call_id: str | None = Field(default=None) + name: str | None = Field(default=None) + arguments: str | None = Field(default=None) + status: Literal["in_progress", "completed", "incomplete"] | None = Field(default="completed") + + +class FunctionCallOutput(BaseModel): + """A tool-result item providing function output back to the model.""" + + type: Literal["function_call_output"] | None = Field(default="function_call_output") + id: str | None = Field(default=None) + call_id: str | None = Field(default=None) + output: str | list[ResponseInputText | ResponseInputImage | ResponseInputFile] | None = Field( + default=None + ) + status: Literal["in_progress", "completed", "incomplete"] | None = Field(default="completed") + +class FunctionTool(BaseModel): + """A function tool for the Responses API (flat schema).""" -class ResponseToolChoice(BaseModel): - """Tool choice enforcing a specific tool in Responses API.""" + type: Literal["function"] + name: str + description: str | None = Field(default=None) + parameters: dict[str, Any] | None = Field(default=None) + strict: bool | None = Field(default=None) - type: Literal["function", "image_generation"] - function: ToolChoiceFunctionDetail | None = Field(default=None) + @model_validator(mode="before") + @classmethod + def _flatten_nested_function(cls, data: Any) -> Any: + if isinstance(data, dict) and "function" in data and isinstance(data["function"], dict): + fn = data["function"] + res = dict(data) + res.setdefault("name", fn.get("name")) + res.setdefault("description", fn.get("description")) + res.setdefault("parameters", fn.get("parameters")) + res.setdefault("strict", fn.get("strict")) + return res + return data -class ResponseImageTool(BaseModel): - """Image generation tool specification for Responses API.""" +class ImageGeneration(BaseModel): + """Image-generation built-in tool for the Responses API.""" type: Literal["image_generation"] + action: Literal["generate", "edit", "auto"] = Field(default="auto") model: str | None = Field(default=None) - output_format: str | None = Field(default=None) + output_format: Literal["png", "webp", "jpeg"] = Field(default="png") + quality: Literal["low", "medium", "high", "auto"] = Field(default="auto") + size: str = Field(default="auto") -class ResponseCreateRequest(BaseModel): - """Responses API request payload.""" +class ToolChoiceFunction(BaseModel): + """Forces the model to call a specific named function (Responses API).""" - model: str - input: str | list[ResponseInputItem] - instructions: str | list[ResponseInputItem] | None = Field(default=None) - temperature: float | None = Field(default=0.7) - top_p: float | None = Field(default=1.0) - max_output_tokens: int | None = Field(default=None) - stream: bool | None = Field(default=False) - tool_choice: str | ResponseToolChoice | None = Field(default=None) - tools: list[Tool | ResponseImageTool] | None = Field(default=None) - store: bool | None = Field(default=None) - user: str | None = Field(default=None) - response_format: dict[str, Any] | None = Field(default=None) - metadata: dict[str, Any] | None = Field(default=None) + type: Literal["function"] + name: str + + +class ToolChoiceTypes(BaseModel): + """Forces the model to use a specific built-in tool type.""" + + type: Literal["image_generation"] class ResponseUsage(BaseModel): - """Usage statistics for Responses API.""" + """Token-usage statistics for a Responses API response.""" input_tokens: int output_tokens: int @@ -232,54 +312,76 @@ class ResponseUsage(BaseModel): output_tokens_details: dict[str, Any] = Field(default_factory=lambda: {"reasoning_tokens": 0}) -class ResponseOutputContent(BaseModel): - """Content item for Responses API output.""" +class ResponseOutputText(BaseModel): + """Text content part inside a Responses API output message.""" - type: Literal["output_text"] - text: str | None = Field(default="") + type: Literal["output_text"] | None = Field(default="output_text") + text: str | None = Field(default=None) annotations: list[dict[str, Any]] = Field(default_factory=list) logprobs: list[dict[str, Any]] | None = Field(default=None) +class ResponseOutputRefusal(BaseModel): + """Refusal content part inside a Responses API output message.""" + + type: Literal["refusal"] | None = Field(default="refusal") + refusal: str | None = Field(default=None) + + +ResponseOutputContent = ResponseOutputText | ResponseOutputRefusal + + class ResponseOutputMessage(BaseModel): - """Assistant message returned by Responses API.""" + """Assistant message output item in a Responses API response.""" - id: str - type: Literal["message"] + id: str | None = Field(default=None) + type: Literal["message"] | None = Field(default="message") status: Literal["in_progress", "completed", "incomplete"] = Field(default="completed") role: Literal["assistant"] - content: list[ResponseOutputContent] + content: list[ResponseOutputText | ResponseOutputRefusal] -class ResponseSummaryPart(BaseModel): - """Summary part for reasoning.""" +class SummaryTextContent(BaseModel): + """Summary text part inside a reasoning item.""" - type: Literal["summary_text"] = Field(default="summary_text") - text: str + type: Literal["summary_text"] | None = Field(default="summary_text") + text: str | None = Field(default=None) -class ResponseReasoningContentPart(BaseModel): - """Content part for reasoning.""" +class ReasoningTextContent(BaseModel): + """Full reasoning text part inside a reasoning item.""" - type: Literal["reasoning_text"] = Field(default="reasoning_text") - text: str + type: Literal["reasoning_text"] | None = Field(default="reasoning_text") + text: str | None = Field(default=None) -class ResponseReasoning(BaseModel): - """Reasoning item returned by Responses API.""" +class ResponseReasoningItem(BaseModel): + """A reasoning output item emitted by a thinking model.""" - id: str - type: Literal["reasoning"] = Field(default="reasoning") + id: str | None = Field(default=None) + type: Literal["reasoning"] | None = Field(default="reasoning") status: Literal["in_progress", "completed", "incomplete"] | None = Field(default=None) - summary: list[ResponseSummaryPart] | None = Field(default=None) - content: list[ResponseReasoningContentPart] | None = Field(default=None) + summary: list[SummaryTextContent] | None = Field(default=None) + content: list[ReasoningTextContent] | None = Field(default=None) + encrypted_content: str | None = Field(default=None) -class ResponseImageGenerationCall(BaseModel): - """Image generation call record emitted in Responses API.""" +class ResponseToolCall(BaseModel): + """A function-call output item emitted by the model.""" - id: str - type: Literal["image_generation_call"] = Field(default="image_generation_call") + id: str | None = Field(default=None) + type: Literal["function_call"] | None = Field(default="function_call") + call_id: str | None = Field(default=None) + name: str | None = Field(default=None) + arguments: str | None = Field(default=None) + status: Literal["in_progress", "completed", "incomplete"] | None = Field(default="completed") + + +class ImageGenerationCall(BaseModel): + """An image-generation output item emitted by the Responses API.""" + + id: str | None = Field(default=None) + type: Literal["image_generation_call"] | None = Field(default="image_generation_call") status: Literal["completed", "in_progress", "generating", "failed"] = Field(default="completed") result: str | None = Field(default=None) output_format: str | None = Field(default=None) @@ -287,31 +389,79 @@ class ResponseImageGenerationCall(BaseModel): revised_prompt: str | None = Field(default=None) -class ResponseToolCall(BaseModel): - """Tool call record emitted in Responses API.""" +class ResponseFormatText(BaseModel): + """Plain-text output format.""" + + type: Literal["text"] = Field(default="text") - id: str - type: Literal["tool_call"] = Field(default="tool_call") - status: Literal["in_progress", "completed", "failed", "requires_action"] = Field( - default="completed" - ) - function: FunctionCall +class ResponseFormatJSONObject(BaseModel): + """Legacy JSON mode: valid JSON is promised, schema conformance is not.""" -class ResponseTextFormat(BaseModel): - """Text format configuration for Responses API.""" + type: Literal["json_object"] = Field(default="json_object") - type: Literal["text", "json_schema"] = Field(default="text") + +class ResponseFormatTextJSONSchemaConfig(BaseModel): + """JSON-schema-constrained output format.""" + + model_config = {"protected_namespaces": (), "arbitrary_types_allowed": True} + + type: Literal["json_schema"] = Field(default="json_schema") + name: str | None = Field(default=None) + schema_: dict[str, Any] | None = Field( + default=None, alias="schema", serialization_alias="schema" + ) + description: str | None = Field(default=None) + # Unset, not False: the resolved value is stamped back onto the echoed response. + strict: StrictBool | None = Field(default=None) class ResponseTextConfig(BaseModel): - """Text configuration for Responses API.""" + """Top-level text configuration block in a Responses API response.""" + + format: ResponseFormatTextJSONSchemaConfig | ResponseFormatJSONObject | ResponseFormatText = ( + Field(default_factory=ResponseFormatText) + ) + + +class ResponseCreateRequest(BaseModel): + """Request body for POST /v1/responses.""" - format: ResponseTextFormat = Field(default_factory=ResponseTextFormat) + model: str + input: ( + str + | list[ + ResponseInputMessage + | ResponseOutputMessage + | ResponseReasoningItem + | ResponseFunctionToolCall + | FunctionCallOutput + | ImageGenerationCall + ] + ) + instructions: str | None = Field(default=None) + temperature: float | None = Field(default=1, ge=0, le=2) + top_p: float | None = Field(default=1, ge=0, le=1) + max_output_tokens: int | None = Field(default=None) + stream: bool | None = Field(default=False) + stream_options: dict[str, Any] | None = Field(default=None) + tool_choice: ( + Literal["none", "auto", "required"] | ToolChoiceFunction | ToolChoiceTypes | None + ) = Field(default=None) + tools: list[FunctionTool | ChatCompletionFunctionTool | ImageGeneration] | None = Field( + default=None + ) + store: bool | None = Field(default=None) + prompt_cache_key: str | None = Field(default=None) + text: ResponseTextConfig | None = Field(default=None) + # Backward-compatible project extension. Current OpenAI Responses requests use `text.format`. + response_format: dict[str, Any] | None = Field(default=None) + metadata: dict[str, Any] | None = Field(default=None) + parallel_tool_calls: bool | None = Field(default=True) class ResponseCreateResponse(BaseModel): - """Responses API response payload.""" + """Response body for POST /v1/responses.""" id: str object: Literal["response"] = Field(default="response") @@ -319,26 +469,53 @@ class ResponseCreateResponse(BaseModel): completed_at: int | None = Field(default=None) model: str output: list[ - ResponseReasoning | ResponseOutputMessage | ResponseImageGenerationCall | ResponseToolCall + ResponseReasoningItem + | ResponseOutputMessage + | ResponseFunctionToolCall + | ImageGenerationCall ] - status: Literal[ - "in_progress", - "completed", - "failed", - "incomplete", - "cancelled", - "requires_action", - ] = Field(default="completed") - tool_choice: str | ToolChoiceFunction | ResponseToolChoice = Field(default="auto") - tools: list[Tool | ResponseImageTool] = Field(default_factory=list) + status: Literal["completed", "failed", "in_progress", "cancelled", "queued", "incomplete"] = ( + Field(default="completed") + ) + tool_choice: ( + Literal["none", "auto", "required"] + | ToolChoiceFunction + | ToolChoiceTypes + | dict[str, Any] + | None + ) = Field(default=None) + tools: list[dict[str, Any]] = Field(default_factory=list) usage: ResponseUsage | None = Field(default=None) error: dict[str, Any] | None = Field(default=None) metadata: dict[str, Any] = Field(default_factory=dict) - input: str | list[ResponseInputItem] | None = Field(default=None) text: ResponseTextConfig | None = Field(default_factory=ResponseTextConfig) -# Rebuild models with forward references -Message.model_rebuild() -ToolCall.model_rebuild() +class ModelData(BaseModel): + """Single model entry in the model list.""" + + id: str + object: str = "model" + created: int + owned_by: str = "google" + + +class ModelListResponse(BaseModel): + """Response body for GET /v1/models.""" + + object: str = "list" + data: list[ModelData] + + +class HealthCheckResponse(BaseModel): + """Response body for the health check endpoint.""" + + ok: bool + storage: Mapping[str, Any] | None = Field(default=None) + clients: Mapping[str, bool] | None = Field(default=None) + error: str | None = Field(default=None) + + +ChatCompletionMessage.model_rebuild() +ChatCompletionMessageToolCall.model_rebuild() ChatCompletionRequest.model_rebuild() diff --git a/app/server/chat.py b/app/server/chat.py index dad65f5..48f56c9 100644 --- a/app/server/chat.py +++ b/app/server/chat.py @@ -1,199 +1,112 @@ +import asyncio import base64 import hashlib import io -import re import reprlib import uuid -from collections.abc import AsyncGenerator -from dataclasses import dataclass +from collections.abc import AsyncGenerator, Sequence from datetime import UTC, datetime from pathlib import Path -from typing import Any +from typing import Any, Literal, cast import orjson from fastapi import APIRouter, Depends, HTTPException, Request, status from fastapi.responses import StreamingResponse from gemini_webapi import ModelOutput from gemini_webapi.client import ChatSession -from gemini_webapi.constants import Model +from gemini_webapi.exceptions import ModelInvalidError from gemini_webapi.types.image import GeneratedImage, Image +from gemini_webapi.types.video import GeneratedMedia, GeneratedVideo from loguru import logger from app.models import ( + AppContentItem, + AppMessage, + AppToolCall, + AppToolCallFunction, + ChatCompletionChoice, + ChatCompletionMessage, + ChatCompletionMessageToolCall, + ChatCompletionNamedToolChoice, ChatCompletionRequest, ChatCompletionResponse, - Choice, - ContentItem, + CompletionUsage, ConversationInStore, - Message, + FunctionCall, + FunctionCallOutput, + FunctionTool, + ImageGeneration, + ImageGenerationCall, ModelData, ModelListResponse, ResponseCreateRequest, ResponseCreateResponse, - ResponseImageGenerationCall, - ResponseImageTool, - ResponseInputContent, - ResponseInputItem, + ResponseFormatJSONObject, + ResponseFormatText, + ResponseFormatTextJSONSchemaConfig, + ResponseFunctionToolCall, + ResponseInputMessage, ResponseOutputContent, ResponseOutputMessage, - ResponseReasoning, - ResponseSummaryPart, + ResponseOutputText, + ResponseReasoningItem, ResponseTextConfig, - ResponseToolCall, - ResponseToolChoice, ResponseUsage, - Tool, - ToolCall, + StructuredOutputRequirement, + SummaryTextContent, ToolChoiceFunction, - Usage, + ToolChoiceTypes, ) from app.server.middleware import ( - get_image_store_dir, - get_image_token, + get_media_store_dir, + get_media_token, get_temp_dir, verify_api_key, ) from app.services import GeminiClientPool, GeminiClientWrapper, LMDBConversationStore from app.utils import g_config -from app.utils.config import ChatMode, OversizedContextStrategy +from app.utils.config import ChatMode from app.utils.helper import ( + SCHEMA_ADHERENCE_PROMPT, + STREAM_FLUSH_TAIL_RE, STREAM_MASTER_RE, STREAM_TAIL_RE, - TOOL_HINT_STRIPPED, - TOOL_WRAP_HINT, + STRICT_SCHEMA_ADHERENCE_PROMPT, + STRUCTURED_JSON_WRAP_HINT, + StructuredOutputValidationError, + append_tool_hint_to_last_user_message, + build_image_generation_instruction, + build_tool_prompt, + calculate_usage, + convert_to_app_messages, detect_image_extension, - estimate_tokens, + dump_model, extract_image_dimensions, - extract_tool_calls, - remove_tool_call_blocks, + normalize_app_message_role, + normalize_llm_text, + process_llm_output, + serialize_tool_choice_for_response, + serialize_tools_for_response, strip_system_hints, - text_from_message, + validate_json_schema, ) -METADATA_TTL_MINUTES = 15 -SUMMARY_KEEP_LAST_MESSAGES = 8 -SUMMARY_MAX_LINES = 24 -SUMMARY_MAX_LINE_CHARS = 320 -SUMMARY_MAX_TOTAL_CHARS = 6000 -COMPACTED_SUMMARY_PROMPT = ( - "Conversation summary for older turns (compacted to stay within provider limits):\n" - "{summary}\n" - "Use this as context continuity for earlier turns." -) - -_MISSING_CHAT_ERROR_PATTERNS = ( - # gemini_webapi maps ErrorCode.MODEL_INCONSISTENT (1050) to this message. - re.compile(r"\bmodel\s+is\s+inconsistent\s+with\s+the\s+conversation\s+history\b"), - # Defensive pattern for equivalent wording in wrappers/alternate versions. - re.compile(r"\bconversation\s+history\b[^\n]{0,120}\b(?:inconsistent|mismatch|does\s+not\s+match)\b"), -) +MAX_CHARS_PER_REQUEST = int(g_config.gemini.max_chars_per_request * 0.9) +# Google's temporary chat mode accepts a smaller payload than a normal chat, so tighten +# the guardrail further on top of the standard 10% safety margin. +TEMPORARY_MAX_CHARS_PER_REQUEST = int(MAX_CHARS_PER_REQUEST * 0.9) router = APIRouter() +_AVAILABLE_MODELS_CACHE: list[ModelData] | None = None +_AVAILABLE_MODELS_CACHE_LOCK = asyncio.Lock() -def _effective_max_chars_per_request() -> int: - """Compute effective request size guardrail from config values.""" - limit = g_config.gemini.max_chars_per_request - if g_config.gemini.chat_mode == ChatMode.TEMPORARY: - limit = int(limit * 0.9) - return max(limit, 1) - - -def _build_history_summary_message(messages: list[Message]) -> Message | None: - """Create a compact summary message for older turns to reduce oversized replay payloads.""" - if not messages: - return None - - summary_lines: list[str] = [] - used_chars = 0 - for msg in messages: - if len(summary_lines) >= SUMMARY_MAX_LINES or used_chars >= SUMMARY_MAX_TOTAL_CHARS: - break - - raw = text_from_message(msg).replace("\n", " ").strip() - if not raw and not msg.tool_calls: - continue - - if msg.tool_calls: - raw = f"{raw} [tool_calls={len(msg.tool_calls)}]".strip() - - if len(raw) > SUMMARY_MAX_LINE_CHARS: - raw = f"{raw[: SUMMARY_MAX_LINE_CHARS - 3]}..." - - line = f"- {msg.role}: {raw}" - used_chars += len(line) - summary_lines.append(line) - - if not summary_lines: - return None - - summary_text = COMPACTED_SUMMARY_PROMPT.format(summary="\n".join(summary_lines)) - return Message(role="system", content=summary_text) - - -def _compact_messages_with_summary(messages: list[Message]) -> list[Message]: - """Keep recent turns verbatim and compact older turns into one summary message.""" - if len(messages) <= SUMMARY_KEEP_LAST_MESSAGES: - return messages - - older = messages[:-SUMMARY_KEEP_LAST_MESSAGES] - recent = messages[-SUMMARY_KEEP_LAST_MESSAGES:] - summary_msg = _build_history_summary_message(older) - if not summary_msg: - return messages - - compacted: list[Message] = [] - if messages and messages[0].role == "system": - first = messages[0].model_copy(deep=True) - if isinstance(first.content, str): - first.content = ( - f"{first.content}\n\n{summary_msg.content}" - if first.content - else str(summary_msg.content) - ) - compacted.append(first) - else: - compacted.append(summary_msg) - else: - compacted.append(summary_msg) - - compacted.extend(recent) - return compacted - - -async def _process_conversation_with_compaction( - messages: list[Message], - tmp_dir: Path, - allow_summary_compaction: bool, - reason: str, -) -> tuple[str, list[Path | str]]: - """Build conversation payload and optionally compact oversized histories.""" - model_input, files = await GeminiClientWrapper.process_conversation(messages, tmp_dir) - effective_limit = _effective_max_chars_per_request() - if len(model_input) <= effective_limit or not allow_summary_compaction: - return model_input, files - - compacted = _compact_messages_with_summary(messages) - if compacted == messages: - return model_input, files - - compacted_input, compacted_files = await GeminiClientWrapper.process_conversation( - compacted, tmp_dir - ) - logger.warning( - f"Input too large for {reason} ({len(model_input)}>{effective_limit}); compacted history to {len(compacted_input)} chars before send." - ) - return compacted_input, compacted_files - - -@dataclass -class StructuredOutputRequirement: - """Represents a structured response request from the client.""" - - schema_name: str - schema: dict[str, Any] - instruction: str - raw_format: dict[str, Any] +type ProcessedImageData = tuple[str, int | None, int | None, str, str] +type ProcessedMediaData = dict[str, tuple[str, str]] +type ProcessedImageResult = tuple[Literal["image"], Image, ProcessedImageData] +type ProcessedMediaResult = tuple[ + Literal["media"], GeneratedVideo | GeneratedMedia, ProcessedMediaData +] # --- Helper Functions --- @@ -235,86 +148,111 @@ async def _image_to_base64( return base64.b64encode(data).decode("ascii"), width, height, filename, file_hash -def _calculate_usage( - messages: list[Message], - assistant_text: str | None, - tool_calls: list[Any] | None, - thoughts: str | None = None, -) -> tuple[int, int, int, int]: - """Calculate prompt, completion, total and reasoning tokens consistently.""" - prompt_tokens = sum(estimate_tokens(text_from_message(msg)) for msg in messages) - tool_args_text = "" - if tool_calls: - for call in tool_calls: - if hasattr(call, "function"): - tool_args_text += call.function.arguments or "" - elif isinstance(call, dict): - tool_args_text += call.get("function", {}).get("arguments", "") - - completion_basis = assistant_text or "" - if tool_args_text: - completion_basis = ( - f"{completion_basis}\n{tool_args_text}" if completion_basis else tool_args_text - ) +async def _media_to_local_file( + media: GeneratedVideo | GeneratedMedia, temp_dir: Path +) -> dict[str, tuple[str, str]]: + """Persist media and return dict mapping type to (filename, hash)""" + try: + saved_paths = await media.save(path=str(temp_dir)) + if not saved_paths: + logger.warning("No files saved from media object.") + return {} + except Exception as e: + logger.error(f"Failed to save media: {e}") + return {} + + default_extensions = { + "video": ".mp4", + "audio": ".mp3", + "video_thumbnail": ".jpg", + "audio_thumbnail": ".jpg", + } - completion_tokens = estimate_tokens(completion_basis) - reasoning_tokens = estimate_tokens(thoughts) if thoughts else 0 - total_completion_tokens = completion_tokens + reasoning_tokens + results = {} + path_map = {} - return ( - prompt_tokens, - total_completion_tokens, - prompt_tokens + total_completion_tokens, - reasoning_tokens, - ) + for mtype, spath in saved_paths.items(): + if not spath: + continue + try: + original_path = Path(spath) + if not original_path.exists(): + if spath in path_map: + results[mtype] = path_map[spath] + continue + + if spath in path_map: + results[mtype] = path_map[spath] + continue + + data = original_path.read_bytes() + suffix = original_path.suffix or ( + default_extensions.get(mtype) or (".mp4" if "video" in mtype else ".mp3") + ) + + random_name = f"media_{uuid.uuid4().hex}{suffix}" + new_path = temp_dir / random_name + original_path.rename(new_path) + + fhash = hashlib.sha256(data).hexdigest() + results[mtype] = (random_name, fhash) + path_map[spath] = (random_name, fhash) + except Exception as e: + logger.warning(f"Error processing {mtype} at {spath}: {e}") + + return results def _create_responses_standard_payload( response_id: str, created_time: int, model_name: str, - detected_tool_calls: list[Any] | None, - image_call_items: list[ResponseImageGenerationCall], + detected_tool_calls: list[AppToolCall] | None, + image_call_items: list[ImageGenerationCall], response_contents: list[ResponseOutputContent], usage: ResponseUsage, request: ResponseCreateRequest, - normalized_input: Any, + structured_requirement: StructuredOutputRequirement | None = None, full_thoughts: str | None = None, + message_id: str | None = None, + reason_id: str | None = None, ) -> ResponseCreateResponse: """Unified factory for building ResponseCreateResponse objects.""" - message_id = f"msg_{uuid.uuid4().hex[:24]}" - reason_id = f"rs_{uuid.uuid4().hex[:24]}" + message_id = message_id or f"msg_{uuid.uuid4().hex[:24]}" + reason_id = reason_id or f"rs_{uuid.uuid4().hex[:24]}" now_ts = int(datetime.now(tz=UTC).timestamp()) output_items: list[Any] = [] if full_thoughts: output_items.append( - ResponseReasoning( + ResponseReasoningItem( id=reason_id, type="reasoning", status="completed", - summary=[ResponseSummaryPart(type="summary_text", text=full_thoughts)], + summary=[SummaryTextContent(type="summary_text", text=full_thoughts)], ) ) - output_items.append( - ResponseOutputMessage( - id=message_id, - type="message", - status="completed", - role="assistant", - content=response_contents, + if response_contents or not (detected_tool_calls or image_call_items): + output_items.append( + ResponseOutputMessage( + id=message_id, + type="message", + status="completed", + role="assistant", + content=response_contents, + ) ) - ) if detected_tool_calls: output_items.extend( [ - ResponseToolCall( - id=call.id if hasattr(call, "id") else call["id"], - type="tool_call", + ResponseFunctionToolCall( + id=call.id, + call_id=call.id, + name=call.function.name, + arguments=call.function.arguments, status="completed", - function=call.function if hasattr(call, "function") else call["function"], ) for call in detected_tool_calls ] @@ -322,9 +260,19 @@ def _create_responses_standard_payload( output_items.extend(image_call_items) - text_config = ResponseTextConfig() + text_config = request.text.model_copy(deep=True) if request.text else ResponseTextConfig() if request.response_format and request.response_format.get("type") == "json_schema": - text_config.format.type = "json_schema" + legacy_config = request.response_format.get("json_schema") or {} + text_config.format = ResponseFormatTextJSONSchemaConfig( + name=legacy_config.get("name"), + schema=legacy_config.get("schema"), + description=legacy_config.get("description"), + ) + + # Report the enforcement applied, not the value the request carried: a client that reads + # `strict: true` back may skip its own validation. + if isinstance(text_config.format, ResponseFormatTextJSONSchemaConfig): + text_config.format.strict = bool(structured_requirement and structured_requirement.strict) return ResponseCreateResponse( id=response_id, @@ -335,10 +283,9 @@ def _create_responses_standard_payload( output=output_items, status="completed", usage=usage, - input=normalized_input or None, metadata=request.metadata or {}, - tools=request.tools or [], - tool_choice=request.tool_choice or "auto", + tools=serialize_tools_for_response(request.tools), + tool_choice=serialize_tool_choice_for_response(request.tool_choice), text=text_config, ) @@ -348,21 +295,27 @@ def _create_chat_completion_standard_payload( created_time: int, model_name: str, visible_output: str | None, - tool_calls_payload: list[dict] | None, - finish_reason: str, + tool_calls: list[AppToolCall] | None, + finish_reason: Literal["stop", "length", "tool_calls", "content_filter"], usage: dict, reasoning_content: str | None = None, ) -> ChatCompletionResponse: """Unified factory for building Chat Completion response objects.""" - # Convert tool calls to Model objects if they are dicts - tool_calls = None - if tool_calls_payload: - tool_calls = [ToolCall.model_validate(tc) for tc in tool_calls_payload] + tc_converted = None + if tool_calls: + tc_converted = [ + ChatCompletionMessageToolCall( + id=tc.id, + type="function", + function=FunctionCall(name=tc.function.name, arguments=tc.function.arguments), + ) + for tc in tool_calls + ] - message = Message( + message = ChatCompletionMessage( role="assistant", content=visible_output or None, - tool_calls=tool_calls, + tool_calls=tc_converted, reasoning_content=reasoning_content or None, ) @@ -372,84 +325,52 @@ def _create_chat_completion_standard_payload( created=created_time, model=model_name, choices=[ - Choice( + ChatCompletionChoice( index=0, message=message, finish_reason=finish_reason, ) ], - usage=Usage(**usage), + usage=CompletionUsage(**usage), ) -def _process_llm_output( - thoughts: str | None, - raw_text: str, - structured_requirement: StructuredOutputRequirement | None, -) -> tuple[str | None, str, str, list[Any]]: - """ - Post-process Gemini output to extract tool calls and prepare clean text for display and storage. - Returns: (thoughts, visible_text, storage_output, tool_calls) - """ - if thoughts: - thoughts = thoughts.strip() - - visible_output, tool_calls = extract_tool_calls(raw_text) - if tool_calls: - logger.debug(f"Detected {len(tool_calls)} tool call(s) in model output.") - - visible_output = visible_output.strip() - - storage_output = remove_tool_call_blocks(raw_text) - storage_output = storage_output.strip() - - if structured_requirement and visible_output: - try: - structured_payload = orjson.loads(visible_output) - canonical_output = orjson.dumps(structured_payload).decode("utf-8") - visible_output = canonical_output - storage_output = canonical_output - logger.debug( - f"Structured response fulfilled (schema={structured_requirement.schema_name})." - ) - except orjson.JSONDecodeError: - logger.warning( - f"Failed to decode JSON for structured response (schema={structured_requirement.schema_name})." - ) - - return thoughts, visible_output, storage_output, tool_calls - - def _persist_conversation( db: LMDBConversationStore, model_name: str, - client_id: str, + client: GeminiClientWrapper, metadata: list[str | None], - messages: list[Message], + messages: list[AppMessage], storage_output: str | None, - tool_calls: list[Any] | None, - thoughts: str | None = None, + tool_calls: list[AppToolCall] | None, ) -> str | None: """Unified logic to save conversation history to LMDB.""" + # This turn is now the last chat this client opened; any window it replaced is closed. Set + # before the store, so a persistence failure cannot leave a closed window looking reusable, + # and in every mode, since an expired cookie can turn a client ephemeral without warning. + client.latest_chat_cid = _cid_of(metadata) + chat_scope = client.chat_scope(_use_temporary_chat_mode()) + try: - current_assistant_message = Message( + current_assistant_message = AppMessage( role="assistant", content=storage_output or None, tool_calls=tool_calls or None, - reasoning_content=thoughts or None, + reasoning_content=None, ) full_history = [*messages, current_assistant_message] - cleaned_history = db.sanitize_messages(full_history) - conv = ConversationInStore( + # An ephemeral chat is worth storing like any other while its window is live, tagged with + # that window so a later session cannot mistake it for one of its own. + db.store( + client_id=client.id, model=model_name, - client_id=client_id, + messages=full_history, metadata=metadata, - messages=cleaned_history, + chat_scope=chat_scope, ) - key = db.store(conv) - logger.debug(f"Conversation saved to LMDB with key: {key[:12]}") - return key + logger.debug("Conversation saved to LMDB.") + return "success" except Exception as e: logger.warning(f"Failed to save {len(messages) + 1} messages to LMDB: {e}") return None @@ -458,160 +379,221 @@ def _persist_conversation( def _build_structured_requirement( response_format: dict[str, Any] | None, ) -> StructuredOutputRequirement | None: - """Translate OpenAI-style response_format into internal instructions.""" + """Translate an OpenAI-style response_format into fenced-JSON prompt instructions. + + `json_schema` becomes an enforced requirement, `json_object` a best-effort one carrying an + empty schema, and every other type - including the `text` default - is dropped, since a mode + this wrapper cannot represent must not block the rest of the request. + + `strict` is read from the payload and defaults to False, matching OpenAI's own default for + Chat Completions. It has teeth here: Gemini Web has no constrained decoding, so a strict + requirement that the model misses costs the caller the whole answer. Callers that ask for + `strict: true` have opted into that; callers that say nothing get the reply as text. + A schema that is well-formed JSON but not valid JSON Schema is asked for without being + enforced, rather than failing the request; only a malformed `response_format` is rejected. + """ if not response_format or not isinstance(response_format, dict): return None - if response_format.get("type") != "json_schema": - logger.warning( - f"Unsupported response_format type requested: {reprlib.repr(response_format)}" + format_type = response_format.get("type") + if format_type == "json_object": + return StructuredOutputRequirement( + schema_name="response", + schema={}, + instruction=STRUCTURED_JSON_WRAP_HINT, + raw_format=response_format, + strict=False, ) + + if format_type != "json_schema": + logger.debug(f"Ignoring response_format type unsupported by Gemini Web: {format_type!r}") return None json_schema = response_format.get("json_schema") if not isinstance(json_schema, dict): - logger.warning( - f"Invalid json_schema payload in response_format: {reprlib.repr(response_format)}" - ) - return None + raise ValueError("response format must contain a json_schema object") schema = json_schema.get("schema") if not isinstance(schema, dict): - logger.warning( - f"Missing `schema` object in response_format payload: {reprlib.repr(response_format)}" - ) - return None + raise ValueError("json_schema must contain a schema object") schema_name = json_schema.get("name") or "response" - strict = json_schema.get("strict", True) + requested_strict = json_schema.get("strict", False) + if not isinstance(requested_strict, bool): + raise ValueError("json_schema.strict must be a boolean") + + enforced_schema, strict = schema, requested_strict + try: + validate_json_schema(schema) + except ValueError as exc: + # Same stance as the Gemini surface: a schema we cannot evaluate is still shown to the + # model, it just cannot be used to judge the reply. Failing the request instead would + # lose a usable answer over a gap on our side. + logger.warning( + f"Asking for schema {schema_name!r} without enforcing it, it is not representable " + f"as JSON Schema: {exc}" + ) + enforced_schema, strict = {}, False pretty_schema = orjson.dumps(schema, option=orjson.OPT_SORT_KEYS).decode("utf-8") instruction_parts = [ - "You must respond with a single valid JSON document that conforms to the schema shown below.", - "Do not include explanations, comments, or any text before or after the JSON.", + STRUCTURED_JSON_WRAP_HINT, + SCHEMA_ADHERENCE_PROMPT, f'Schema name: "{schema_name}"', "JSON Schema:", pretty_schema, ] - if not strict: - instruction_parts.insert( - 1, - "The schema allows unspecified fields, but include only what is necessary to satisfy the user's request.", - ) + # Prompted from what the caller asked for, enforced from what we can actually check. + if requested_strict: + instruction_parts.insert(1, STRICT_SCHEMA_ADHERENCE_PROMPT) instruction = "\n\n".join(instruction_parts) return StructuredOutputRequirement( schema_name=schema_name, - schema=schema, + schema=enforced_schema, instruction=instruction, raw_format=response_format, + strict=strict, ) -def _build_tool_prompt( - tools: list[Tool], - tool_choice: str | ToolChoiceFunction | None, -) -> str: - """Generate a system prompt describing available tools and the PascalCase protocol.""" - if not tools: - return "" - - lines: list[str] = [ - "SYSTEM INTERFACE: You have access to the following technical tools. You MUST invoke them when necessary to fulfill the request, strictly adhering to the provided JSON schemas." - ] +def _responses_response_format(request: ResponseCreateRequest) -> dict[str, Any] | None: + """Reduce Responses `text.format` and the legacy `response_format` to one format dict.""" + text_format = request.text.format if request.text is not None else None - for tool in tools: - function = tool.function - description = function.description or "No description provided." - lines.append(f"Tool `{function.name}`: {description}") - if function.parameters: - schema_text = orjson.dumps(function.parameters, option=orjson.OPT_SORT_KEYS).decode( - "utf-8" - ) - lines.append("Arguments JSON schema:") - lines.append(schema_text) - else: - lines.append("Arguments JSON schema: {}") + if request.response_format is not None: + # The legacy project extension only loses to a `text.format` that actually asks for a + # format; a default or plain-text block alongside it is not a conflict. + if isinstance(text_format, ResponseFormatText) or text_format is None: + return request.response_format + raise ValueError("Use either text.format or response_format, not both") - if tool_choice == "none": - lines.append( - "For this request you must not call any tool. Provide the best possible natural language answer." - ) - elif tool_choice == "required": - lines.append( - "You must call at least one tool before responding to the user. Do not provide a final user-facing answer until a tool call has been issued." - ) - elif isinstance(tool_choice, ToolChoiceFunction): - target = tool_choice.function.name - lines.append( - f"You are required to call the tool named `{target}`. Do not call any other tool." - ) + if isinstance(text_format, ResponseFormatJSONObject): + return {"type": "json_object"} + if not isinstance(text_format, ResponseFormatTextJSONSchemaConfig): + return None + if not isinstance(text_format.schema_, dict): + raise ValueError("text.format.schema is required for json_schema output") + return { + "type": "json_schema", + "json_schema": { + "name": text_format.name or "response", + "schema": text_format.schema_, + "description": text_format.description, + # Same default as Chat Completions and as OpenAI: an omitted `strict` is best-effort, + # so one schema cannot hard-fail on one surface and degrade on the other. + "strict": text_format.strict if text_format.strict is not None else False, + }, + } - lines.append(TOOL_WRAP_HINT) - return "\n".join(lines) +def _log_ignored_openai_options( + request: ChatCompletionRequest | ResponseCreateRequest, +) -> None: + """Debug-log generation controls that were accepted but cannot reach Gemini Web.""" + control_fields = ( + ("temperature", "top_p", "max_completion_tokens", "parallel_tool_calls") + if isinstance(request, ChatCompletionRequest) + else ("temperature", "top_p", "max_output_tokens", "parallel_tool_calls") + ) + if ignored := {name for name in control_fields if name in request.model_fields_set}: + logger.debug( + "Ignoring option(s) unsupported by the Gemini Web upstream: " + f"{', '.join(sorted(ignored))}" + ) -def _build_image_generation_instruction( - tools: list[ResponseImageTool] | None, - tool_choice: ResponseToolChoice | None, +def _tool_choice_failure( + tool_choice: Any, + tool_calls: list[AppToolCall], + *, + has_images: bool = False, + has_image_tool: bool = False, ) -> str | None: - """Construct explicit guidance so Gemini emits images when requested.""" - has_forced_choice = tool_choice is not None and tool_choice.type == "image_generation" - primary = tools[0] if tools else None - - if not has_forced_choice and primary is None: - return None + """Describe how the model failed a forced tool_choice, or None if it honored it. - instructions: list[str] = [ - "IMAGE GENERATION ENABLED: When an image is requested, you MUST return a real generated image directly.", - "1. For new requests, generate new images matching the description immediately.", - "2. For edits to existing images, apply changes and return a new generated version.", - "3. CRITICAL: Provide ZERO text explanation, prologue, or apologies. Do not describe the creation process.", - "4. NEVER send placeholder text or descriptions like 'Generating image...' without an actual image attachment.", - ] + Gemini Web has no constrained decoding, so a forced choice is only ever a prompt + instruction; this is the check that the instruction actually took. - if has_forced_choice: - instructions.append( - "Image generation was explicitly requested. You MUST return at least one generated image. Any response without an image will be treated as a failure." + An image only discharges `required` when an image-generation tool was declared, so that an + image Gemini volunteers on its own cannot stand in for the function call that was forced. + """ + if tool_choice == "required" and not tool_calls and not (has_images and has_image_tool): + return "The model did not return a required tool result" + if ( + target_name := ( + tool_choice.function.name + if isinstance(tool_choice, ChatCompletionNamedToolChoice) + else (tool_choice.name if isinstance(tool_choice, ToolChoiceFunction) else None) ) - - return "\n\n".join(instructions) + ) and all(call.function.name != target_name for call in tool_calls): + return f"The model did not call the required function {target_name!r}" + if isinstance(tool_choice, ToolChoiceTypes) and not has_images: + return "The model did not return a required image generation result" + return None -def _append_tool_hint_to_last_user_message(messages: list[Message]) -> None: - """Ensure the last user message carries the tool wrap hint.""" - for msg in reversed(messages): - if msg.role != "user" or msg.content is None: +def _tool_choice_declaration_error( + function_names: set[str], + has_image_tool: bool, + tool_choice: Any, +) -> str | None: + """Reject forced choices that do not name a declared compatible tool.""" + if tool_choice == "required" and not function_names and not has_image_tool: + return "tool_choice='required' requires at least one tool" + if isinstance(tool_choice, ChatCompletionNamedToolChoice): + target_name = tool_choice.function.name + if target_name not in function_names: + return f"tool_choice names undeclared function {target_name!r}" + if isinstance(tool_choice, ToolChoiceFunction) and tool_choice.name not in function_names: + return f"tool_choice names undeclared function {tool_choice.name!r}" + if isinstance(tool_choice, ToolChoiceTypes) and not has_image_tool: + return "tool_choice='image_generation' requires an image_generation tool" + return None + + +def _validate_responses_input(items: Any) -> str | None: + """Describe why an input item is unusable, or None if every part can be represented. + + Only content is judged here: a reference this wrapper cannot resolve, or a media part whose + source is missing or ambiguous. Dropping either silently would change what the model sees. + """ + if isinstance(items, str): + return None + for item in items: + parts = ( + item.output if isinstance(item, FunctionCallOutput) else getattr(item, "content", None) + ) + if not isinstance(parts, list): continue - - if isinstance(msg.content, str): - if TOOL_HINT_STRIPPED not in msg.content: - msg.content = f"{msg.content}\n{TOOL_WRAP_HINT}" - return - - if isinstance(msg.content, list): - for part in reversed(msg.content): - if getattr(part, "type", None) != "text": - continue - text_value = part.text or "" - if TOOL_HINT_STRIPPED in text_value: - return - part.text = f"{text_value}\n{TOOL_WRAP_HINT}" - return - - messages_text = TOOL_WRAP_HINT.strip() - msg.content.append(ContentItem(type="text", text=messages_text)) - return + for part in parts: + if getattr(part, "file_id", None): + return "file_id inputs are not supported; use file_url or inline Base64 data" + if getattr(part, "type", None) == "input_file": + sources = [ + getattr(part, "file_url", None), + getattr(part, "file_data", None), + ] + if sum(value is not None for value in sources) != 1: + return "input_file must contain exactly one of file_url or file_data" + if getattr(part, "type", None) == "input_image" and not getattr( + part, "image_url", None + ): + return "input_image must contain image_url" + return None def _prepare_messages_for_model( - source_messages: list[Message], - tools: list[Tool] | None, - tool_choice: str | ToolChoiceFunction | None, + source_messages: list[AppMessage], + tools: Sequence[Any] | None, + tool_choice: Literal["none", "auto", "required"] + | ChatCompletionNamedToolChoice + | ToolChoiceFunction + | ToolChoiceTypes + | None, extra_instructions: list[str] | None = None, inject_system_defaults: bool = True, -) -> list[Message]: +) -> list[AppMessage]: """Return a copy of messages enriched with tool instructions when needed.""" prepared = [msg.model_copy(deep=True) for msg in source_messages] @@ -628,22 +610,19 @@ def _prepare_messages_for_model( instructions: list[str] = [] tool_prompt_injected = False - if inject_system_defaults: - if tools: - tool_prompt = _build_tool_prompt(tools, tool_choice) - if tool_prompt: - instructions.append(tool_prompt) - tool_prompt_injected = True - - if extra_instructions: - instructions.extend(instr for instr in extra_instructions if instr) - logger.debug( - f"Applied {len(extra_instructions)} extra instructions for tool/structured output." - ) + if inject_system_defaults and tools and (tool_prompt := build_tool_prompt(tools, tool_choice)): + instructions.append(tool_prompt) + tool_prompt_injected = True + + if extra_instructions: + instructions.extend(instr for instr in extra_instructions if instr) + logger.debug( + f"Applied {len(extra_instructions)} extra instructions for tool/structured output." + ) if not instructions: if tools and tool_choice != "none" and not tool_prompt_injected: - _append_tool_hint_to_last_user_message(prepared) + append_tool_hint_to_last_user_message(prepared) return prepared combined_instructions = "\n\n".join(instructions) @@ -653,244 +632,398 @@ def _prepare_messages_for_model( separator = "\n\n" if existing else "" prepared[0].content = f"{existing}{separator}{combined_instructions}" else: - prepared.insert(0, Message(role="system", content=combined_instructions)) + prepared.insert(0, AppMessage(role="system", content=combined_instructions)) if tools and tool_choice != "none" and not tool_prompt_injected: - _append_tool_hint_to_last_user_message(prepared) + append_tool_hint_to_last_user_message(prepared) return prepared -def _response_items_to_messages( - items: str | list[ResponseInputItem], -) -> tuple[list[Message], str | list[ResponseInputItem]]: - """Convert Responses API input items into internal Message objects and normalized input.""" - messages: list[Message] = [] +def _convert_responses_to_app_messages( + items: Any, +) -> list[AppMessage]: + """Convert Responses API input items into internal AppMessage objects, skipping incomplete tool calls.""" + messages: list[AppMessage] = [] if isinstance(items, str): - messages.append(Message(role="user", content=items)) + messages.append(AppMessage(role="user", content=items)) logger.debug("Normalized Responses input: single string message.") - return messages, items + return messages - normalized_input: list[ResponseInputItem] = [] for item in items: - role = item.role - content = item.content - normalized_contents: list[ResponseInputContent] = [] - if isinstance(content, str): - normalized_contents.append(ResponseInputContent(type="input_text", text=content)) - messages.append(Message(role=role, content=content)) - else: - converted: list[ContentItem] = [] - reasoning_parts: list[str] = [] - for part in content: - if part.type in ("input_text", "output_text"): - text_value = part.text or "" - normalized_contents.append( - ResponseInputContent(type=part.type, text=text_value) - ) - if text_value: - converted.append(ContentItem(type="text", text=text_value)) - elif part.type == "reasoning_text": - text_value = part.text or "" - normalized_contents.append( - ResponseInputContent(type="reasoning_text", text=text_value) - ) - if text_value: - reasoning_parts.append(text_value) - elif part.type == "input_image": - image_url = part.image_url - if image_url: - normalized_contents.append( - ResponseInputContent( - type="input_image", - image_url=image_url, - detail=part.detail if part.detail else "auto", + if isinstance(item, (ResponseInputMessage, ResponseOutputMessage)): + raw_role = getattr(item, "role", "user") + role = normalize_app_message_role(raw_role) + + content = item.content + if isinstance(content, str): + messages.append(AppMessage(role=role, content=content)) + else: + converted: list[AppContentItem] = [] + reasoning_parts: list[str] = [] + for part in content: + if part.type in ("input_text", "output_text"): + if text_value := getattr(part, "text", "") or "": + converted.append(AppContentItem(type="text", text=text_value)) + elif part.type == "reasoning_text": + if text_value := getattr(part, "text", "") or "": + reasoning_parts.append(text_value) + elif part.type == "input_image": + if image_url := getattr(part, "image_url", None): + converted.append(AppContentItem(type="image_url", url=image_url)) + elif part.type == "input_file": + file_url = getattr(part, "file_url", None) + file_data = getattr(part, "file_data", None) + if file_url or file_data: + converted.append( + AppContentItem( + type="file", + url=file_url, + file_data=file_data, + filename=getattr(part, "filename", None), + ) ) + reasoning_val = "\n\n".join(reasoning_parts) if reasoning_parts else None + messages.append( + AppMessage( + role=role, + content=converted or None, + reasoning_content=reasoning_val, + ) + ) + + elif isinstance(item, ResponseFunctionToolCall): + call_id = item.call_id or item.id + if not call_id or not item.name or item.arguments is None: + logger.warning( + f"Skipping incomplete function_call input item: {reprlib.repr(item.model_dump(mode='json'))}" + ) + continue + messages.append( + AppMessage( + role="assistant", + tool_calls=[ + AppToolCall( + id=call_id, + type="function", + function=AppToolCallFunction(name=item.name, arguments=item.arguments), ) - converted.append( - ContentItem( - type="image_url", - image_url={ - "url": image_url, - "detail": part.detail if part.detail else "auto", - }, + ], + ) + ) + elif isinstance(item, FunctionCallOutput): + output_content: str | list[AppContentItem] | None + if isinstance(item.output, list): + converted_output: list[AppContentItem] = [] + for part in item.output: + if part.type == "input_text": + converted_output.append(AppContentItem(type="text", text=part.text or "")) + elif part.type == "input_image" and part.image_url: + converted_output.append( + AppContentItem(type="image_url", url=part.image_url) + ) + elif part.type == "input_file": + converted_output.append( + AppContentItem( + type="file", + url=part.file_url, + file_data=part.file_data, + filename=part.filename, ) ) - elif part.type == "input_file": - if part.file_url or part.file_data: - normalized_contents.append(part) - file_info = {} - if part.file_data: - file_info["file_data"] = part.file_data - file_info["filename"] = part.filename - if part.file_url: - file_info["url"] = part.file_url - converted.append(ContentItem(type="file", file=file_info)) - messages.append(Message(role=role, content=converted or None)) - - normalized_input.append( - ResponseInputItem(type="message", role=item.role, content=normalized_contents or []) - ) + output_content = converted_output or None + else: + output_content = item.output + messages.append( + AppMessage( + role="tool", + tool_call_id=item.call_id, + content=output_content, + ) + ) + elif isinstance(item, ResponseReasoningItem): + reasoning_val = None + if item.content: + reasoning_val = "\n\n".join(x.text for x in item.content if x.text) + messages.append( + AppMessage( + role="assistant", + reasoning_content=reasoning_val, + ) + ) + elif isinstance(item, ImageGenerationCall): + messages.append( + AppMessage( + role="assistant", + content=item.result or None, + ) + ) + + else: + if hasattr(item, "role"): + raw_role = getattr(item, "role", "user") + role = normalize_app_message_role(raw_role) + messages.append( + AppMessage( + role=role, + content=str(getattr(item, "content", "")), + ) + ) + + compacted_messages: list[AppMessage] = [] + for msg in messages: + if not compacted_messages: + compacted_messages.append(msg) + continue + + last_msg = compacted_messages[-1] + if last_msg.role == "assistant" and msg.role == "assistant": + reasoning_parts = [] + if last_msg.reasoning_content: + reasoning_parts.append(last_msg.reasoning_content) + if msg.reasoning_content: + reasoning_parts.append(msg.reasoning_content) + + merged_content = [] + if isinstance(last_msg.content, str): + merged_content.append(AppContentItem(type="text", text=last_msg.content)) + elif isinstance(last_msg.content, list): + merged_content.extend(last_msg.content) + + if isinstance(msg.content, str): + merged_content.append(AppContentItem(type="text", text=msg.content)) + elif isinstance(msg.content, list): + merged_content.extend(msg.content) + + merged_tools = [] + if last_msg.tool_calls: + merged_tools.extend(last_msg.tool_calls) + if msg.tool_calls: + merged_tools.extend(msg.tool_calls) + + last_msg.reasoning_content = "\n\n".join(reasoning_parts) if reasoning_parts else None + last_msg.content = merged_content or None + last_msg.tool_calls = merged_tools or None + else: + compacted_messages.append(msg) - logger.debug(f"Normalized Responses input: {len(normalized_input)} message items.") - return messages, normalized_input + logger.debug(f"Normalized Responses input: {len(compacted_messages)} message items.") + return compacted_messages -def _instructions_to_messages( - instructions: str | list[ResponseInputItem] | None, -) -> list[Message]: - """Normalize instructions payload into Message objects.""" +def _convert_instructions_to_app_messages( + instructions: str | list[ResponseInputMessage] | None, +) -> list[AppMessage]: + """Normalize instructions payload into AppMessage objects.""" if not instructions: return [] if isinstance(instructions, str): - return [Message(role="system", content=instructions)] + return [AppMessage(role="system", content=instructions)] - instruction_messages: list[Message] = [] - for item in instructions: - if item.type and item.type != "message": + instruction_messages: list[AppMessage] = [] + for instruction in instructions: + if instruction.type and instruction.type != "message": continue - role = item.role - content = item.content + role = normalize_app_message_role(instruction.role) + + content = instruction.content if isinstance(content, str): - instruction_messages.append(Message(role=role, content=content)) + instruction_messages.append(AppMessage(role=role, content=content)) else: - converted: list[ContentItem] = [] - reasoning_parts: list[str] = [] + converted: list[AppContentItem] = [] for part in content: if part.type in ("input_text", "output_text"): - text_value = part.text or "" - if text_value: - converted.append(ContentItem(type="text", text=text_value)) - elif part.type == "reasoning_text": - text_value = part.text or "" - if text_value: - reasoning_parts.append(text_value) + if text_value := getattr(part, "text", "") or "": + converted.append(AppContentItem(type="text", text=text_value)) elif part.type == "input_image": - image_url = part.image_url - if image_url: + if image_url := getattr(part, "image_url", None): + converted.append(AppContentItem(type="image_url", url=image_url)) + elif part.type == "input_file": + file_url = getattr(part, "file_url", None) + file_data = getattr(part, "file_data", None) + if file_url or file_data: converted.append( - ContentItem( - type="image_url", - image_url={ - "url": image_url, - "detail": part.detail if part.detail else "auto", - }, + AppContentItem( + type="file", + url=file_url, + file_data=file_data, + filename=getattr(part, "filename", None), ) ) - elif part.type == "input_file": - file_info = {} - if part.file_data: - file_info["file_data"] = part.file_data - file_info["filename"] = part.filename - if part.file_url: - file_info["url"] = part.file_url - if file_info: - converted.append(ContentItem(type="file", file=file_info)) - instruction_messages.append( - Message( - role=role, - content=converted or None, - reasoning_content="\n".join(reasoning_parts) if reasoning_parts else None, - ) - ) + instruction_messages.append(AppMessage(role=role, content=converted or None)) return instruction_messages -def _get_model_by_name(name: str) -> Model: - """Retrieve a Model instance by name.""" - strategy = g_config.gemini.model_strategy - custom_models = {m.model_name: m for m in g_config.gemini.models if m.model_name} +def _resolve_model_name(pool: GeminiClientPool, name: str) -> str: + """Canonical name of the model a request asked for, resolved against a client's registry. + + Names, aliases and hex ids all resolve, and every client discovers its own models, so nothing + here has to be configured or kept up to date. Resolution is canonical on purpose: two aliases + of one model must reach the same conversation records. - if name in custom_models: - return Model.from_dict(custom_models[name].model_dump()) + The name, not the resolved object, is what callers pass on - each client re-resolves it in its + own registry at send time, where the model header carries that account's tier. Preferring an + authenticated client keeps a guest, whose registry marks everything but the default + unavailable, from narrowing what the whole pool accepts. + """ + # A registry outlives an auto-close, so an idle client still resolves; one that never + # initialized has nothing to offer. + clients = sorted(pool.clients, key=lambda c: (c.is_guest(), not c.running())) + for client in clients: + if not client.list_models(): + continue - if strategy == "overwrite": - raise ValueError(f"Model '{name}' not found in custom models (strategy='overwrite').") + try: + return client.resolve_model(name).model_name + except ValueError: + continue - return Model.from_name(name) + raise ValueError(f"Model '{name}' is not available on any Gemini client.") -def _get_available_models() -> list[ModelData]: - """Return a list of available models based on configuration strategy.""" +async def _build_available_models(pool: GeminiClientPool) -> list[ModelData]: + """Build the available model list from the models the clients discovered.""" now = int(datetime.now(tz=UTC).timestamp()) - strategy = g_config.gemini.model_strategy models_data = [] + seen_model_ids = set() + + for client in pool.clients: + if client_models := client.list_models(): + for model in client_models: + # A guest session registers the models it can see but may only use the + # default one; advertising the rest would promise what it cannot serve. + if not model.is_available: + continue - custom_models = [m for m in g_config.gemini.models if m.model_name] - for m in custom_models: - models_data.append( - ModelData( - id=m.model_name, - created=now, - owned_by="custom", - ) + model_id = model.model_name or model.model_id + if model_id and model_id not in seen_model_ids: + models_data.append( + ModelData( + id=model_id, + created=now, + owned_by="google", + ) + ) + seen_model_ids.add(model_id) + + return models_data + + +async def refresh_available_models_cache(pool: GeminiClientPool) -> list[ModelData]: + """Refresh and return the cached model list while clients are available.""" + global _AVAILABLE_MODELS_CACHE + + async with _AVAILABLE_MODELS_CACHE_LOCK: + models = await _build_available_models(pool) + _AVAILABLE_MODELS_CACHE = models + logger.info(f"Cached {len(models)} available model(s).") + return list(models) + + +async def _get_available_models(pool: GeminiClientPool) -> list[ModelData]: + """Return cached available models, populating the cache if it has not been warmed yet.""" + if _AVAILABLE_MODELS_CACHE is not None: + return list(_AVAILABLE_MODELS_CACHE) + + return await refresh_available_models_cache(pool) + + +def _cid_of(metadata: list[str | None] | None) -> str | None: + """Chat id from a metadata list, which stores it at index 0.""" + return metadata[0] if metadata else None + + +def _is_reusable_chat( + conv: ConversationInStore, client: GeminiClientWrapper, temporary: bool +) -> bool: + """Whether a stored chat can still be continued on this client. + + A normal chat lives in the account's history and stays continuable indefinitely, so only the + ephemeral records need checking: temporary-mode chats, and everything a guest session opened. + Those survive only as the one open window of the session that created them, and replaying a + closed one makes Google answer from a fresh chat without the earlier context - no error, just + silent loss. So an ephemeral record must still carry the client's current scope, which no + longer matches once that session is gone (reinitialized, or downgraded to guest by expired + cookies, or authenticated again afterwards), and its cid must be the last one this client + opened, since a newer conversation has closed anything older. + + A `latest_chat_cid` match is necessary but not proof: the cid's kind is never verified. + """ + scope = client.chat_scope(temporary) + if conv.chat_scope is None and scope is None: + return True + + if conv.chat_scope != scope: + logger.debug( + f"Stored chat scope {conv.chat_scope!r} no longer matches client {client.id} " + f"({scope!r}); the window behind it is gone, so replaying the full history in a " + "fresh conversation." ) + return False - if strategy == "append": - custom_ids = {m.model_name for m in custom_models} - for model in Model: - m_name = model.model_name - if not m_name or m_name == "unspecified": - continue - if m_name in custom_ids: - continue + latest = client.latest_chat_cid + if not latest: + logger.debug(f"Client {client.id} has no chat on record; starting a fresh conversation.") + return False - models_data.append( - ModelData( - id=m_name, - created=now, - owned_by="gemini-web", - ) - ) + cid = _cid_of(conv.metadata) + if cid and cid == latest: + return True - return models_data + logger.debug( + f"Stored chat {cid!r} is not the latest ({latest!r}) on client {client.id}; a newer " + "conversation has closed it, so replaying the full history in a fresh conversation." + ) + return False async def _find_reusable_session( db: LMDBConversationStore, pool: GeminiClientPool, - model: Model, - messages: list[Message], -) -> tuple[ChatSession | None, GeminiClientWrapper | None, list[Message]]: + resolved_model: str, + messages: list[AppMessage], + temporary: bool = False, + require_account: bool = False, +) -> tuple[ + ChatSession | None, GeminiClientWrapper | None, list[AppMessage], ConversationInStore | None +]: """Find an existing chat session matching the longest suitable history prefix.""" if len(messages) < 2: - return None, None, messages + return None, None, messages, None search_end = len(messages) while search_end >= 2: search_history = messages[:search_end] if search_history[-1].role in {"assistant", "system", "tool"}: try: - if conv := db.find(model.model_name, search_history): - now = datetime.now() - updated_at = conv.updated_at or conv.created_at or now - age_minutes = (now - updated_at).total_seconds() / 60 - if age_minutes <= METADATA_TTL_MINUTES: - client = await pool.acquire(conv.client_id) - try: - session = client.start_chat(metadata=conv.metadata, model=model) - except Exception as exc: - logger.warning( - f"Failed to reuse metadata chat at prefix length {search_end}: {exc}" - ) - search_end -= 1 - continue - remain = messages[search_end:] - logger.debug( - f"Match found at prefix length {search_end}/{len(messages)}. Client: {conv.client_id}" - ) - return session, client, remain - else: + if conv := db.find(resolved_model, search_history): + client = await pool.acquire(conv.client_id) + if require_account and client.is_guest(): + # Continuing here would fail on the upload; a fresh chat on an + # authenticated client can still serve the request. logger.debug( - f"Matched conversation at length {search_end} is too old ({age_minutes:.1f}m), skipping reuse." + f"Client {client.id} owns the match but is a guest session and this " + "request needs an upload; starting a fresh conversation." ) - else: - # Log that we tried this prefix but failed - pass + break + # Checked after acquiring: acquire may restart a closed client, which rerolls + # the scope and clears the tracked cid, invalidating any ephemeral window. + if not _is_reusable_chat(conv, client, temporary): + # Every prefix of one conversation carries the same cid and scope, so if + # the longest match is not the live window, no shorter one will be either. + break + session = client.start_chat( + metadata=conv.metadata, model=client.usable_model(resolved_model) + ) + remain = messages[search_end:] + logger.debug( + f"Match found at prefix length {search_end}/{len(messages)}. Client: {conv.client_id}" + ) + return session, client, remain, conv except Exception as e: logger.warning( f"Error checking LMDB for reusable session at length {search_end}: {e}" @@ -899,103 +1032,210 @@ async def _find_reusable_session( search_end -= 1 logger.debug(f"No reusable session found for {len(messages)} messages.") - return None, None, messages + return None, None, messages, None + + +def _use_temporary_chat_mode() -> bool: + """Whether requests should be sent through Google's temporary chat mode.""" + return g_config.gemini.chat_mode == ChatMode.TEMPORARY + + +def _effective_max_chars_per_request(temporary: bool) -> int: + """Return the payload guardrail for the active chat mode.""" + return TEMPORARY_MAX_CHARS_PER_REQUEST if temporary else MAX_CHARS_PER_REQUEST + + +def _requires_upload(messages: list[AppMessage], temporary: bool) -> bool: + """Whether serving these messages needs a file upload, which a guest session cannot do. + + Attachments do; so does input long enough to be sent as `message.txt`. The length is measured + before the prompt is assembled, so it slightly underestimates and only steers client choice - + `_send_with_split` makes the real call. + """ + total = 0 + for message in messages: + if isinstance(message.content, str): + total += len(message.content) + elif isinstance(message.content, list): + for item in message.content: + if item.type != "text": + return True + total += len(item.text or "") + + return total > _effective_max_chars_per_request(temporary) + + +def _can_upload(session: ChatSession) -> bool: + """Whether the client behind this session may attach files.""" + client = session.geminiclient + return client.can_upload() if isinstance(client, GeminiClientWrapper) else True async def _send_with_split( session: ChatSession, text: str, - files: list[Path | str | io.BytesIO] | None = None, + files: list[Any] | None = None, stream: bool = False, temporary: bool = False, ) -> AsyncGenerator[ModelOutput] | ModelOutput: - """Send text to Gemini, splitting or converting to attachment if too long.""" - effective_limit = _effective_max_chars_per_request() - if len(text) <= effective_limit: + """Send text to Gemini with configured generation options, using an attachment if too long.""" + limit = _effective_max_chars_per_request(temporary) + if len(text) <= limit: try: if stream: - return session.send_message_stream(text, files=files, temporary=temporary) - return await session.send_message(text, files=files, temporary=temporary) + return session.send_message_stream( + text, + files=files, + temporary=temporary, + extended_thinking=g_config.gemini.extended_thinking, + ) + return await session.send_message( + text, + files=files, + temporary=temporary, + extended_thinking=g_config.gemini.extended_thinking, + ) except Exception as e: - logger.exception(f"Error sending message to Gemini: {e}") + logger.error(f"Error sending message to Gemini: {e}") raise + if not _can_upload(session): + # Only reachable once every client is a guest, since routing prefers an authenticated one. + raise RuntimeError( + f"Message length ({len(text)}) exceeds limit ({limit}) and would have to be sent as " + "an attachment, which a guest session cannot upload. Refresh the client cookies or " + "shorten the request." + ) + logger.info( - f"Message length ({len(text)}) exceeds effective limit ({effective_limit})." + f"Message length ({len(text)}) exceeds limit ({limit}). Converting text to file attachment." ) - logger.info("Converting oversized message to file attachment.") file_obj = io.BytesIO(text.encode("utf-8")) file_obj.name = "message.txt" try: - final_files = list(files) if files else [] - final_files.append(file_obj) + final_files: list[Any] = list(files) if files else [] + final_files.insert(0, file_obj) instruction = ( - "Context is attached in `message.txt`. " - "Acknowledge it briefly, then treat it as the primary user input for this turn and answer based on it." + "The user's input exceeds the character limit and is provided in the attached file `message.txt`.\n\n" + "**System Instruction:**\n" + "1. Read the content of `message.txt`.\n" + "2. Treat that content as the **primary** user prompt for this turn.\n" + "3. Execute the instructions or answer the questions found *inside* that file immediately.\n" ) if stream: - return session.send_message_stream(instruction, files=final_files, temporary=temporary) - return await session.send_message(instruction, files=final_files, temporary=temporary) + return session.send_message_stream( + instruction, + files=final_files, + temporary=temporary, + extended_thinking=g_config.gemini.extended_thinking, + ) + return await session.send_message( + instruction, + files=final_files, + temporary=temporary, + extended_thinking=g_config.gemini.extended_thinking, + ) except Exception as e: - logger.exception(f"Error sending large text as file to Gemini: {e}") + logger.error(f"Error sending large text as file to Gemini: {e}") raise -def _is_missing_chat_error(exc: Exception) -> bool: - normalized = " ".join(part for part in (str(exc), repr(exc)) if part).lower() - if not normalized: - return False - return any(pattern.search(normalized) for pattern in _MISSING_CHAT_ERROR_PATTERNS) +async def _restream( + first: ModelOutput, rest: AsyncGenerator[ModelOutput] +) -> AsyncGenerator[ModelOutput]: + """Re-emit an already-consumed first chunk, then delegate to the remainder.""" + yield first + async for chunk in rest: + yield chunk + + +async def _send_and_await_first_chunk( + session: ChatSession, + text: str, + *, + files: list[Any], + stream: bool, + temporary: bool, +) -> AsyncGenerator[ModelOutput] | ModelOutput: + """Send to Gemini, pulling the first streamed chunk so start-of-stream errors surface here. + + `send_message_stream` is an async generator function: calling it runs none of its body, so + without this the request would not reach Google until the caller iterates - by which point + the HTTP response has already been committed and a failure can no longer be recovered. + """ + output = await _send_with_split(session, text, files=files, stream=stream, temporary=temporary) + if not stream: + return output + + generator = cast(AsyncGenerator[ModelOutput], output) + try: + first = await anext(generator) + except StopAsyncIteration: + return generator # already exhausted, so iterating it again simply yields nothing + except BaseException: + await generator.aclose() + raise + return _restream(first, generator) async def _send_with_internal_fallback( *, pool: GeminiClientPool, - model: Model, + db: LMDBConversationStore, + resolved_model: str, session: ChatSession, client: GeminiClientWrapper, current_input: str, - files: list[Path | str | io.BytesIO], - full_prepared_messages: list[Message], + files: list[Any], + full_prepared_messages: list[AppMessage], + stored_conversation: ConversationInStore | None, tmp_dir: Path, stream: bool, - reused_session: bool, temporary: bool, ) -> tuple[AsyncGenerator[ModelOutput] | ModelOutput, ChatSession, GeminiClientWrapper]: + """Send the request, replaying the full history in a fresh chat if reused metadata is dead. + + Streaming is recovered as well as non-streaming: the first chunk is pulled here, which is + where Google reports a rejected chat, so the retry happens before any response is committed + to the client. The cost is that response headers wait for Google's first chunk. + """ try: - output = await _send_with_split( - session, - current_input, - files=files, - stream=stream, - temporary=temporary, + output = await _send_and_await_first_chunk( + session, current_input, files=files, stream=stream, temporary=temporary ) return output, session, client - except Exception as exc: - should_fallback = ( - reused_session - and not stream - and _is_missing_chat_error(exc) - ) - if not should_fallback: + except ModelInvalidError: + if stored_conversation is None: raise + # Drop the dead metadata so the next request does not rediscover and re-fail on it. + try: + if db.evict(stored_conversation): + logger.info("Evicted stale conversation metadata after Google rejected it.") + except Exception as evict_exc: + logger.warning(f"Failed to evict stale conversation metadata: {evict_exc}") + logger.warning( "Metadata-backed chat reuse failed; retrying with internal history replay in a fresh chat." ) - fallback_client = await pool.acquire() - fallback_session = fallback_client.start_chat(model=model) - fallback_input, fallback_files = await _process_conversation_with_compaction( - full_prepared_messages, - tmp_dir, - allow_summary_compaction=(g_config.gemini.oversized_context_strategy == OversizedContextStrategy.COMPACTION), - reason="fallback replay", + fallback_input, fallback_files = await GeminiClientWrapper.process_conversation( + full_prepared_messages, tmp_dir + ) + # Built before acquiring, so a replay that needs an upload is not handed to a guest. + fallback_client = await pool.acquire( + require_account=bool(fallback_files) + or len(fallback_input) > _effective_max_chars_per_request(temporary) + ) + fallback_session = fallback_client.start_chat( + model=fallback_client.usable_model(resolved_model) ) - output = await _send_with_split( + # Keep the caller's streaming mode: the endpoints reject a ModelOutput when the client + # asked for a stream, so downgrading here would turn a recovery into a 502. + output = await _send_and_await_first_chunk( fallback_session, fallback_input, - files=fallback_files, - stream=False, + files=list(fallback_files), + stream=stream, temporary=temporary, ) return output, fallback_session, fallback_client @@ -1018,6 +1258,8 @@ def state(self): def _is_outputting(self) -> bool: """Determines if the current state allows yielding text to the stream.""" + if self.state == "POST_BLOCK": + return False return self.state == "NORMAL" or (self.state == "IN_BLOCK" and self.current_role != "tool") def process(self, chunk: str) -> str: @@ -1027,23 +1269,32 @@ def process(self, chunk: str) -> str: while self.buffer: if self.state == "IN_TAG_HEADER": nl_idx = self.buffer.find("\n") - if nl_idx != -1: - self.current_role = self.buffer[:nl_idx].strip().lower() - self.buffer = self.buffer[nl_idx + 1 :] - self.stack[-1] = "IN_BLOCK" - continue - else: + if nl_idx == -1: break + self.current_role = self.buffer[:nl_idx].strip().lower() + self.buffer = self.buffer[nl_idx + 1 :] + self.stack[-1] = "IN_BLOCK" + continue + if self.state == "POST_BLOCK": + stripped = self.buffer.lstrip() + if not stripped: + break + self.buffer = stripped + self.stack[-1] = "NORMAL" + match = STREAM_MASTER_RE.search(self.buffer) if not match: - tail_match = STREAM_TAIL_RE.search(self.buffer) - keep_len = len(tail_match.group(0)) if tail_match else 0 - yield_len = len(self.buffer) - keep_len - if yield_len > 0: + if tail_match := STREAM_TAIL_RE.search(self.buffer): + yield_len = len(self.buffer) - len(tail_match.group(0)) + if yield_len > 0: + if self._is_outputting(): + output.append(self.buffer[:yield_len]) + self.buffer = self.buffer[yield_len:] + else: if self._is_outputting(): - output.append(self.buffer[:yield_len]) - self.buffer = self.buffer[yield_len:] + output.append(self.buffer) + self.buffer = "" break start, end = match.span() @@ -1053,7 +1304,7 @@ def process(self, chunk: str) -> str: if self._is_outputting(): output.append(pre_text) - if matched_group.endswith("_START"): + if matched_group and matched_group.endswith("_START"): m_type = matched_group.split("_")[0] if m_type == "TAG": self.stack.append("IN_TAG_HEADER") @@ -1065,7 +1316,13 @@ def process(self, chunk: str) -> str: else: self.stack = ["NORMAL"] - if self.state == "NORMAL": + if self.state == "NORMAL" and matched_group in ( + "PROTOCOL_EXIT", + "HINT_EXIT", + ): + self.stack[-1] = "POST_BLOCK" + + if self.state in ("NORMAL", "POST_BLOCK"): self.current_role = "" self.buffer = self.buffer[end:] @@ -1077,8 +1334,7 @@ def flush(self) -> str: res = "" if self._is_outputting(): res = self.buffer - tail_match = STREAM_TAIL_RE.search(res) - if tail_match: + if tail_match := STREAM_FLUSH_TAIL_RE.search(res): res = res[: -len(tail_match.group(0))] self.buffer = "" @@ -1087,218 +1343,362 @@ def flush(self) -> str: return strip_system_hints(res) +# --- Media Processing Helpers --- + + +async def _process_image_item(image: Image) -> ProcessedImageResult | None: + """Process an image item by converting it to base64 and returning a typed image result tuple.""" + try: + media_store = get_media_store_dir() + return "image", image, await _image_to_base64(image, media_store) + except Exception as exc: + logger.warning(f"Background image processing failed: {exc}") + return None + + +async def _process_media_item( + media_item: GeneratedVideo | GeneratedMedia, +) -> ProcessedMediaResult | None: + """Process a media item by saving it to local files and returning a typed media result tuple.""" + try: + media_store = get_media_store_dir() + return "media", media_item, await _media_to_local_file(media_item, media_store) + except Exception as exc: + logger.warning(f"Background media processing failed: {exc}") + return None + + # --- Response Builders & Streaming --- +def _sse_error( + message: str, error_type: str, param: str | None = None, code: str | None = None +) -> str: + """Render a Chat Completions SSE error frame followed by the stream terminator. + + The status line is already committed by the time these fire, so the terminator is the only + way left to tell a client the stream ended on purpose rather than being cut off. + """ + payload = {"error": {"message": message, "type": error_type, "param": param, "code": code}} + return f"data: {orjson.dumps(payload).decode('utf-8')}\n\ndata: [DONE]\n\n" + + def _create_real_streaming_response( - generator: AsyncGenerator[ModelOutput], + resp_or_stream: AsyncGenerator[ModelOutput] | ModelOutput, completion_id: str, created_time: int, model_name: str, - messages: list[Message], + messages: list[AppMessage], db: LMDBConversationStore, - model: Model, + resolved_model: str, client_wrapper: GeminiClientWrapper, session: ChatSession, base_url: str, structured_requirement: StructuredOutputRequirement | None = None, + tool_choice: Any = None, ) -> StreamingResponse: """ Create a real-time streaming response. Reconciles manual delta accumulation with the model's final authoritative state. + Emits typed image and media results as incremental markdown deltas. """ async def generate_stream(): - full_thoughts, full_text = "", "" + full_text = "" + full_thoughts = "" has_started = False - all_outputs: list[ModelOutput] = [] + last_output: ModelOutput | None = None suppressor = StreamingOutputFilter() - try: - async for chunk in generator: - all_outputs.append(chunk) - if not has_started: - data = { - "id": completion_id, - "object": "chat.completion.chunk", - "created": created_time, - "model": model_name, - "choices": [ - {"index": 0, "delta": {"role": "assistant"}, "finish_reason": None} - ], - } - yield f"data: {orjson.dumps(data).decode('utf-8')}\n\n" - has_started = True - - if t_delta := chunk.thoughts_delta: - full_thoughts += t_delta - data = { - "id": completion_id, - "object": "chat.completion.chunk", - "created": created_time, - "model": model_name, - "choices": [ - { - "index": 0, - "delta": {"reasoning_content": t_delta}, - "finish_reason": None, - } - ], - } - yield f"data: {orjson.dumps(data).decode('utf-8')}\n\n" - - if text_delta := chunk.text_delta: - full_text += text_delta - if visible_delta := suppressor.process(text_delta): - data = { - "id": completion_id, - "object": "chat.completion.chunk", - "created": created_time, - "model": model_name, - "choices": [ - { - "index": 0, - "delta": {"content": visible_delta}, - "finish_reason": None, - } - ], - } - yield f"data: {orjson.dumps(data).decode('utf-8')}\n\n" - except Exception as e: - logger.exception(f"Error during OpenAI streaming: {e}") - yield f"data: {orjson.dumps({'error': {'message': 'Streaming error occurred.', 'type': 'server_error', 'param': None, 'code': None}}).decode('utf-8')}\n\n" - return - - if all_outputs: - final_chunk = all_outputs[-1] - if final_chunk.text: - full_text = final_chunk.text - if final_chunk.thoughts: - full_thoughts = final_chunk.thoughts - - if remaining_text := suppressor.flush(): + + media_tasks = [] + seen_media_urls = set() + seen_image_urls = set() + + async def _make_async_gen(item: ModelOutput) -> AsyncGenerator[ModelOutput]: + yield item + + async def discard_media_tasks() -> None: + """Cancel and reap media downloads on a path that will never publish their results. + + These tasks are spawned eagerly while chunks arrive. Abandoning them on an early + return leaves them writing files nothing references and, on failure, raises + `Task exception was never retrieved` once they are garbage collected. + """ + if not media_tasks: + return + for task in media_tasks: + task.cancel() + await asyncio.gather(*media_tasks, return_exceptions=True) + media_tasks.clear() + + def make_chunk(delta_content: dict) -> str: data = { "id": completion_id, "object": "chat.completion.chunk", "created": created_time, "model": model_name, - "choices": [ - {"index": 0, "delta": {"content": remaining_text}, "finish_reason": None} - ], + "choices": [{"index": 0, **delta_content}], } - yield f"data: {orjson.dumps(data).decode('utf-8')}\n\n" + return f"data: {orjson.dumps(data).decode('utf-8')}\n\n" - _thoughts, assistant_text, storage_output, tool_calls = _process_llm_output( - full_thoughts, full_text, structured_requirement - ) + try: + try: + if hasattr(resp_or_stream, "__aiter__"): + generator = cast(AsyncGenerator[ModelOutput], resp_or_stream) + else: + generator = _make_async_gen(cast(ModelOutput, resp_or_stream)) + + async for chunk in generator: + last_output = chunk + if not has_started: + yield make_chunk( + {"delta": {"role": "assistant", "content": ""}, "finish_reason": None} + ) + has_started = True + + if t_delta := chunk.thoughts_delta: + full_thoughts += t_delta + yield make_chunk( + {"delta": {"reasoning_content": t_delta}, "finish_reason": None} + ) + + if text_delta := chunk.text_delta: + full_text += text_delta + if not structured_requirement and ( + visible_delta := suppressor.process(text_delta) + ): + yield make_chunk( + {"delta": {"content": visible_delta}, "finish_reason": None} + ) + + for img in chunk.images or []: + if img.url and img.url not in seen_image_urls: + seen_image_urls.add(img.url) + media_tasks.append(asyncio.create_task(_process_image_item(img))) + + m_list = (chunk.videos or []) + (chunk.media or []) + for m in m_list: + p_url = getattr(m, "url", None) or getattr(m, "mp3_url", None) + if p_url and p_url not in seen_media_urls: + seen_media_urls.add(p_url) + media_tasks.append(asyncio.create_task(_process_media_item(m))) + except Exception as e: + logger.error(f"Error during streaming: {e}") + await discard_media_tasks() + yield _sse_error(f"Streaming error occurred: {e}", "server_error") + return + + if last_output is not None: + final_chunk = last_output + if final_chunk.thoughts: + f_thoughts = final_chunk.thoughts + ft_len, ct_len = len(f_thoughts), len(full_thoughts) + if ft_len > ct_len and f_thoughts.startswith(full_thoughts): + drift_t = f_thoughts[ct_len:] + full_thoughts = f_thoughts + yield make_chunk( + {"delta": {"reasoning_content": drift_t}, "finish_reason": None} + ) + + if final_chunk.text: + f_text = final_chunk.text + f_len, c_len = len(f_text), len(full_text) + if f_len > c_len and f_text.startswith(full_text): + drift = f_text[c_len:] + full_text = f_text + if not structured_requirement and ( + visible_drift := suppressor.process(drift) + ): + yield make_chunk( + {"delta": {"content": visible_drift}, "finish_reason": None} + ) + + if not structured_requirement and (remaining_text := suppressor.flush()): + yield make_chunk({"delta": {"content": remaining_text}, "finish_reason": None}) - images = [] - seen_urls = set() - for out in all_outputs: - if out.images: - for img in out.images: - if img.url not in seen_urls: - images.append(img) - seen_urls.add(img.url) - - image_markdown = "" - seen_hashes = set() - for image in images: try: - image_store = get_image_store_dir() - _, _, _, fname, fhash = await _image_to_base64(image, image_store) - if fhash in seen_hashes: - (image_store / fname).unlink(missing_ok=True) - continue - seen_hashes.add(fhash) + _, visible_output, storage_output, detected_tool_calls = process_llm_output( + normalize_llm_text(full_thoughts or ""), + normalize_llm_text(full_text or ""), + structured_requirement, + ) + except StructuredOutputValidationError as exc: + await discard_media_tasks() + yield _sse_error( + str(exc), "invalid_model_output", "response_format", "schema_validation_failed" + ) + return + # No `has_images` escape hatch here: Chat Completions has no image-generation tool, so an + # image Gemini volunteers on its own cannot stand in for a function call that was forced. + if choice_error := _tool_choice_failure(tool_choice, detected_tool_calls): + await discard_media_tasks() + yield _sse_error( + choice_error, "invalid_model_output", "tool_choice", "required_tool_missing" + ) + return + if structured_requirement and visible_output: + yield make_chunk({"delta": {"content": visible_output}, "finish_reason": None}) - img_url = f"![{fname}]({base_url}images/{fname}?token={get_image_token(fname)})" - image_markdown += f"\n\n{img_url}" - except Exception as exc: - logger.warning(f"Failed to process image in OpenAI stream: {exc}") + seen_hashes = {} + seen_media_hashes = {} + media_store = get_media_store_dir() - if image_markdown: - assistant_text += image_markdown - storage_output += image_markdown - data = { - "id": completion_id, - "object": "chat.completion.chunk", - "created": created_time, - "model": model_name, - "choices": [ - {"index": 0, "delta": {"content": image_markdown}, "finish_reason": None} - ], - } - yield f"data: {orjson.dumps(data).decode('utf-8')}\n\n" + if media_tasks: + logger.debug( + f"Waiting for {len(media_tasks)} background media tasks with heartbeat..." + ) + while media_tasks: + done, pending = await asyncio.wait( + media_tasks, timeout=5.0, return_when=asyncio.FIRST_COMPLETED + ) + media_tasks = list(pending) - tool_calls_payload = [call.model_dump(mode="json") for call in tool_calls] - if tool_calls_payload: - tool_calls_delta = [ - {**call, "index": idx} for idx, call in enumerate(tool_calls_payload) - ] - data = { - "id": completion_id, - "object": "chat.completion.chunk", - "created": created_time, - "model": model_name, - "choices": [ - {"index": 0, "delta": {"tool_calls": tool_calls_delta}, "finish_reason": None} - ], - } - yield f"data: {orjson.dumps(data).decode('utf-8')}\n\n" + if not done: + yield ": ping\n\n" + continue - p_tok, c_tok, t_tok, r_tok = _calculate_usage( - messages, assistant_text, tool_calls, full_thoughts - ) - usage = Usage( - prompt_tokens=p_tok, - completion_tokens=c_tok, - total_tokens=t_tok, - completion_tokens_details={"reasoning_tokens": r_tok}, - ) - data = { - "id": completion_id, - "object": "chat.completion.chunk", - "created": created_time, - "model": model_name, - "choices": [ - {"index": 0, "delta": {}, "finish_reason": "tool_calls" if tool_calls else "stop"} - ], - "usage": usage.model_dump(mode="json"), - } - _persist_conversation( - db, - model.model_name, - client_wrapper.id, - session.metadata, - messages, - storage_output, - tool_calls, - full_thoughts, - ) - yield f"data: {orjson.dumps(data).decode('utf-8')}\n\n" - yield "data: [DONE]\n\n" + for task in done: + res = task.result() + if not res: + continue + + rtype, original_item, media_data = res + if rtype == "image": + _, _, _, fname, fhash = media_data + if fhash in seen_hashes: + (media_store / fname).unlink(missing_ok=True) + fname = seen_hashes[fhash] + else: + seen_hashes[fhash] = fname + + img_url = f"{base_url}media/{fname}?token={get_media_token(fname)}" + title = getattr(original_item, "title", "Image") + md = f"![{title}]({img_url})" + storage_output += f"\n\n{md}" + yield make_chunk( + {"delta": {"content": f"\n\n{md}"}, "finish_reason": None} + ) + + elif rtype == "media": + m_dict = cast(ProcessedMediaData, media_data) + if not m_dict: + continue + + m_urls = {} + for mtype, (random_name, fhash) in m_dict.items(): + if fhash in seen_media_hashes: + existing_name = seen_media_hashes[fhash] + if random_name != existing_name: + (media_store / random_name).unlink(missing_ok=True) + m_urls[mtype] = ( + f"{base_url}media/{existing_name}?token={get_media_token(existing_name)}" + ) + else: + seen_media_hashes[fhash] = random_name + m_urls[mtype] = ( + f"{base_url}media/{random_name}?token={get_media_token(random_name)}" + ) + + title = getattr(original_item, "title", "Media") + video_url = m_urls.get("video") + audio_url = m_urls.get("audio") + current_thumb = m_urls.get("video_thumbnail") or m_urls.get( + "audio_thumbnail" + ) + + md_parts = [] + if video_url: + md_parts.append( + f"[![{title}]({current_thumb})]({video_url})" + if current_thumb + else f"[{title}]({video_url})" + ) + if audio_url: + md_parts.append( + f"[![{title} - Audio]({current_thumb})]({audio_url})" + if current_thumb + else f"[{title} - Audio]({audio_url})" + ) + + if md_parts: + md = "\n\n".join(md_parts) + storage_output += f"\n\n{md}" + yield make_chunk( + {"delta": {"content": f"\n\n{md}"}, "finish_reason": None} + ) + + if detected_tool_calls: + for idx, call in enumerate(detected_tool_calls): + tc_dict = { + "index": idx, + "id": call.id, + "type": "function", + "function": { + "name": call.function.name, + "arguments": call.function.arguments, + }, + } + + yield make_chunk( + { + "delta": { + "tool_calls": [tc_dict], + }, + "finish_reason": None, + } + ) + + p_tok, c_tok, t_tok, r_tok = calculate_usage( + messages, storage_output, detected_tool_calls, full_thoughts + ) + usage = CompletionUsage( + prompt_tokens=p_tok, + completion_tokens=c_tok, + total_tokens=t_tok, + completion_tokens_details={"reasoning_tokens": r_tok}, + ) + _persist_conversation( + db, + resolved_model, + client_wrapper, + session.metadata, + messages, + storage_output, + detected_tool_calls, + ) + yield make_chunk( + { + "delta": {}, + "finish_reason": "tool_calls" if detected_tool_calls else "stop", + "usage": dump_model(usage), + } + ) + yield "data: [DONE]\n\n" + finally: + await discard_media_tasks() return StreamingResponse(generate_stream(), media_type="text/event-stream") def _create_responses_real_streaming_response( - generator: AsyncGenerator[ModelOutput], + resp_or_stream: AsyncGenerator[ModelOutput] | ModelOutput, response_id: str, created_time: int, model_name: str, - messages: list[Message], + messages: list[AppMessage], db: LMDBConversationStore, - model: Model, + resolved_model: str, client_wrapper: GeminiClientWrapper, session: ChatSession, request: ResponseCreateRequest, - image_store: Path, base_url: str, structured_requirement: StructuredOutputRequirement | None = None, + has_image_tool: bool = False, ) -> StreamingResponse: """ Create a real-time streaming response for the Responses API. Ensures final accumulated text and thoughts are synchronized and follow the formal event stream spec. + Emits typed image and media results as incremental response output text events. """ base_event = { "id": response_id, @@ -1316,430 +1716,838 @@ def make_event(etype: str, data: dict) -> str: seq += 1 return f"event: {etype}\ndata: {orjson.dumps(data).decode()}\n\n" - yield make_event( - "response.created", - { - **base_event, - "type": "response.created", - "response": { - "id": response_id, - "object": "response", - "created_at": created_time, - "model": model_name, - "status": "in_progress", - "metadata": request.metadata or {}, - "input": None, - "tools": request.tools or [], - "tool_choice": request.tool_choice or "auto", - "output": [], - "usage": None, - }, - }, - ) + media_tasks = [] + seen_media_urls = set() + seen_image_urls = set() - yield make_event( - "response.in_progress", - { - **base_event, - "type": "response.in_progress", - "response": { - "id": response_id, - "object": "response", - "created_at": created_time, - "model": model_name, - "status": "in_progress", - "metadata": request.metadata or {}, - "output": [], + async def discard_media_tasks() -> None: + """Cancel downloads whose results cannot be published after a stream failure.""" + if not media_tasks: + return + for task in media_tasks: + task.cancel() + await asyncio.gather(*media_tasks, return_exceptions=True) + media_tasks.clear() + + try: + yield make_event( + "response.created", + { + **base_event, + "type": "response.created", + "response": { + "id": response_id, + "object": "response", + "created_at": created_time, + "model": model_name, + "status": "in_progress", + "metadata": request.metadata or {}, + "input": None, + "tools": serialize_tools_for_response(request.tools), + "tool_choice": serialize_tool_choice_for_response(request.tool_choice), + "output": [], + "usage": None, + }, }, - }, - ) + ) + yield make_event( + "response.in_progress", + { + **base_event, + "type": "response.in_progress", + "response": { + "id": response_id, + "object": "response", + "created_at": created_time, + "model": model_name, + "status": "in_progress", + "metadata": request.metadata or {}, + "output": [], + }, + }, + ) - full_thoughts, full_text = "", "" - all_outputs: list[ModelOutput] = [] + full_text = "" + full_thoughts = "" + last_output: ModelOutput | None = None - thought_item_id = f"rs_{uuid.uuid4().hex[:24]}" - message_item_id = f"msg_{uuid.uuid4().hex[:24]}" + thought_item_id = f"rs_{uuid.uuid4().hex[:24]}" + message_item_id = f"msg_{uuid.uuid4().hex[:24]}" - thought_open, message_open = False, False - current_index = 0 - suppressor = StreamingOutputFilter() + thought_open, message_open = False, False + next_output_index = 0 + thought_index = 0 + message_index = 0 + suppressor = StreamingOutputFilter() - try: - async for chunk in generator: - all_outputs.append(chunk) + try: + if hasattr(resp_or_stream, "__aiter__"): + generator = cast(AsyncGenerator[ModelOutput], resp_or_stream) + else: - if chunk.thoughts_delta: - if not thought_open: - yield make_event( - "response.output_item.added", - { - **base_event, - "type": "response.output_item.added", - "output_index": current_index, - "item": ResponseReasoning( - id=thought_item_id, - type="reasoning", - status="in_progress", - summary=[], - ).model_dump(mode="json"), - }, - ) + async def _make_async_gen(item: ModelOutput) -> AsyncGenerator[ModelOutput]: + yield item - yield make_event( - "response.reasoning_summary_part.added", - { - **base_event, - "type": "response.reasoning_summary_part.added", - "item_id": thought_item_id, - "output_index": current_index, - "summary_index": 0, - "part": ResponseSummaryPart(text="").model_dump(mode="json"), - }, - ) - thought_open = True + generator = _make_async_gen(cast(ModelOutput, resp_or_stream)) - full_thoughts += chunk.thoughts_delta - yield make_event( - "response.reasoning_summary_text.delta", - { - **base_event, - "type": "response.reasoning_summary_text.delta", - "item_id": thought_item_id, - "output_index": current_index, - "summary_index": 0, - "delta": chunk.thoughts_delta, - }, - ) + async for chunk in generator: + last_output = chunk - if chunk.text_delta: - if thought_open: + if chunk.thoughts_delta: + if not thought_open: + thought_index = next_output_index + next_output_index += 1 + yield make_event( + "response.output_item.added", + { + **base_event, + "type": "response.output_item.added", + "output_index": thought_index, + "item": dump_model( + ResponseReasoningItem( + id=thought_item_id, + type="reasoning", + status="in_progress", + summary=[], + ) + ), + }, + ) + + yield make_event( + "response.reasoning_summary_part.added", + { + **base_event, + "type": "response.reasoning_summary_part.added", + "item_id": thought_item_id, + "output_index": thought_index, + "summary_index": 0, + "part": dump_model(SummaryTextContent(text="")), + }, + ) + thought_open = True + + full_thoughts += chunk.thoughts_delta yield make_event( - "response.reasoning_summary_text.done", + "response.reasoning_summary_text.delta", { **base_event, - "type": "response.reasoning_summary_text.done", + "type": "response.reasoning_summary_text.delta", "item_id": thought_item_id, - "output_index": current_index, + "output_index": thought_index, "summary_index": 0, - "text": full_thoughts, + "delta": chunk.thoughts_delta, }, ) + + if chunk.text_delta: + full_text += chunk.text_delta + if thought_open: + yield make_event( + "response.reasoning_summary_text.done", + { + **base_event, + "type": "response.reasoning_summary_text.done", + "item_id": thought_item_id, + "output_index": thought_index, + "summary_index": 0, + "text": full_thoughts, + }, + ) + yield make_event( + "response.reasoning_summary_part.done", + { + **base_event, + "type": "response.reasoning_summary_part.done", + "item_id": thought_item_id, + "output_index": thought_index, + "summary_index": 0, + "part": dump_model(SummaryTextContent(text=full_thoughts)), + }, + ) + yield make_event( + "response.output_item.done", + { + **base_event, + "type": "response.output_item.done", + "output_index": thought_index, + "item": dump_model( + ResponseReasoningItem( + id=thought_item_id, + type="reasoning", + status="completed", + summary=[SummaryTextContent(text=full_thoughts)], + ) + ), + }, + ) + thought_open = False + + if not structured_requirement: + if not message_open: + message_index = next_output_index + next_output_index += 1 + yield make_event( + "response.output_item.added", + { + **base_event, + "type": "response.output_item.added", + "output_index": message_index, + "item": dump_model( + ResponseOutputMessage( + id=message_item_id, + type="message", + status="in_progress", + role="assistant", + content=[], + ) + ), + }, + ) + + yield make_event( + "response.content_part.added", + { + **base_event, + "type": "response.content_part.added", + "item_id": message_item_id, + "output_index": message_index, + "content_index": 0, + "part": dump_model( + ResponseOutputText(type="output_text", text="") + ), + }, + ) + message_open = True + + if visible := suppressor.process(chunk.text_delta): + yield make_event( + "response.output_text.delta", + { + **base_event, + "type": "response.output_text.delta", + "item_id": message_item_id, + "output_index": message_index, + "content_index": 0, + "delta": visible, + "logprobs": [], + }, + ) + + for img in chunk.images or []: + if img.url and img.url not in seen_image_urls: + seen_image_urls.add(img.url) + media_tasks.append(asyncio.create_task(_process_image_item(img))) + + m_list = (chunk.videos or []) + (chunk.media or []) + for m in m_list: + p_url = getattr(m, "url", None) or getattr(m, "mp3_url", None) + if p_url and p_url not in seen_media_urls: + seen_media_urls.add(p_url) + media_tasks.append(asyncio.create_task(_process_media_item(m))) + + except Exception as e: + logger.error(f"Error during streaming: {e}") + await discard_media_tasks() + yield make_event( + "error", + { + **base_event, + "type": "error", + "error": {"message": f"Streaming error occurred: {e}"}, + }, + ) + return + + if last_output is not None: + last = last_output + if last.thoughts: + l_thoughts = last.thoughts + lt_len, ct_len = len(l_thoughts), len(full_thoughts) + if lt_len > ct_len and l_thoughts.startswith(full_thoughts): + drift_t = l_thoughts[ct_len:] + full_thoughts = l_thoughts + if not thought_open: + thought_index = next_output_index + next_output_index += 1 + yield make_event( + "response.output_item.added", + { + **base_event, + "type": "response.output_item.added", + "output_index": thought_index, + "item": dump_model( + ResponseReasoningItem( + id=thought_item_id, + type="reasoning", + status="in_progress", + summary=[], + ) + ), + }, + ) + yield make_event( + "response.reasoning_summary_part.added", + { + **base_event, + "type": "response.reasoning_summary_part.added", + "item_id": thought_item_id, + "output_index": thought_index, + "summary_index": 0, + "part": dump_model(SummaryTextContent(text="")), + }, + ) + thought_open = True + yield make_event( - "response.reasoning_summary_part.done", + "response.reasoning_summary_text.delta", { **base_event, - "type": "response.reasoning_summary_part.done", + "type": "response.reasoning_summary_text.delta", "item_id": thought_item_id, - "output_index": current_index, + "output_index": thought_index, "summary_index": 0, - "part": ResponseSummaryPart(text=full_thoughts).model_dump( - mode="json" - ), - }, - ) - yield make_event( - "response.output_item.done", - { - **base_event, - "type": "response.output_item.done", - "output_index": current_index, - "item": ResponseReasoning( - id=thought_item_id, - type="reasoning", - status="completed", - summary=[ResponseSummaryPart(text=full_thoughts)], - ).model_dump(mode="json"), + "delta": drift_t, }, ) - current_index += 1 - thought_open = False - if not message_open: - yield make_event( - "response.output_item.added", - { - **base_event, - "type": "response.output_item.added", - "output_index": current_index, - "item": ResponseOutputMessage( - id=message_item_id, - type="message", - status="in_progress", - role="assistant", - content=[], - ).model_dump(mode="json"), - }, - ) + if last.text: + l_text = last.text + l_len, c_len = len(l_text), len(full_text) + if l_len > c_len and l_text.startswith(full_text): + drift = l_text[c_len:] + full_text = l_text + if not structured_requirement and (visible := suppressor.process(drift)): + if not message_open: + message_index = next_output_index + next_output_index += 1 + yield make_event( + "response.output_item.added", + { + **base_event, + "type": "response.output_item.added", + "output_index": message_index, + "item": dump_model( + ResponseOutputMessage( + id=message_item_id, + type="message", + status="in_progress", + role="assistant", + content=[], + ) + ), + }, + ) + yield make_event( + "response.content_part.added", + { + **base_event, + "type": "response.content_part.added", + "item_id": message_item_id, + "output_index": message_index, + "content_index": 0, + "part": dump_model( + ResponseOutputText(type="output_text", text="") + ), + }, + ) + message_open = True + + yield make_event( + "response.output_text.delta", + { + **base_event, + "type": "response.output_text.delta", + "item_id": message_item_id, + "output_index": message_index, + "content_index": 0, + "delta": visible, + "logprobs": [], + }, + ) - yield make_event( - "response.content_part.added", - { - **base_event, - "type": "response.content_part.added", - "item_id": message_item_id, - "output_index": current_index, - "content_index": 0, - "part": ResponseOutputContent( - type="output_text", text="" - ).model_dump(mode="json"), - }, - ) - message_open = True + remaining = "" if structured_requirement else suppressor.flush() + if remaining and message_open: + yield make_event( + "response.output_text.delta", + { + **base_event, + "type": "response.output_text.delta", + "item_id": message_item_id, + "output_index": message_index, + "content_index": 0, + "delta": remaining, + "logprobs": [], + }, + ) - full_text += chunk.text_delta - if visible := suppressor.process(chunk.text_delta): - yield make_event( - "response.output_text.delta", - { - **base_event, - "type": "response.output_text.delta", - "item_id": message_item_id, - "output_index": current_index, - "content_index": 0, - "delta": visible, - "logprobs": [], - }, - ) + if thought_open: + yield make_event( + "response.reasoning_summary_text.done", + { + **base_event, + "type": "response.reasoning_summary_text.done", + "item_id": thought_item_id, + "output_index": thought_index, + "summary_index": 0, + "text": full_thoughts, + }, + ) + yield make_event( + "response.reasoning_summary_part.done", + { + **base_event, + "type": "response.reasoning_summary_part.done", + "item_id": thought_item_id, + "output_index": thought_index, + "summary_index": 0, + "part": dump_model(SummaryTextContent(text=full_thoughts)), + }, + ) + yield make_event( + "response.output_item.done", + { + **base_event, + "type": "response.output_item.done", + "output_index": thought_index, + "item": dump_model( + ResponseReasoningItem( + id=thought_item_id, + type="reasoning", + status="completed", + summary=[SummaryTextContent(text=full_thoughts)], + ) + ), + }, + ) - except Exception: - logger.exception("Responses streaming error") - yield make_event( - "error", - {**base_event, "type": "error", "error": {"message": "Streaming error."}}, - ) - return + try: + _, assistant_text, storage_output, detected_tool_calls = process_llm_output( + normalize_llm_text(full_thoughts or ""), + normalize_llm_text(full_text or ""), + structured_requirement, + ) + except StructuredOutputValidationError as exc: + await discard_media_tasks() + yield make_event( + "error", + { + **base_event, + "type": "error", + "error": { + "message": str(exc), + "type": "invalid_model_output", + "param": "text.format", + "code": "schema_validation_failed", + }, + }, + ) + return - if all_outputs: - last = all_outputs[-1] - if last.text: - full_text = last.text - if last.thoughts: - full_thoughts = last.thoughts + if structured_requirement and assistant_text and not message_open: + message_index = next_output_index + next_output_index += 1 + yield make_event( + "response.output_item.added", + { + **base_event, + "type": "response.output_item.added", + "output_index": message_index, + "item": dump_model( + ResponseOutputMessage( + id=message_item_id, + type="message", + status="in_progress", + role="assistant", + content=[], + ) + ), + }, + ) + yield make_event( + "response.content_part.added", + { + **base_event, + "type": "response.content_part.added", + "item_id": message_item_id, + "output_index": message_index, + "content_index": 0, + "part": dump_model(ResponseOutputText(type="output_text", text="")), + }, + ) + message_open = True + yield make_event( + "response.output_text.delta", + { + **base_event, + "type": "response.output_text.delta", + "item_id": message_item_id, + "output_index": message_index, + "content_index": 0, + "delta": assistant_text, + "logprobs": [], + }, + ) - remaining = suppressor.flush() - if remaining and message_open: - yield make_event( - "response.output_text.delta", - { - **base_event, - "type": "response.output_text.delta", - "item_id": message_item_id, - "output_index": current_index, - "content_index": 0, - "delta": remaining, - "logprobs": [], - }, - ) + image_items = [] + seen_hashes = {} + seen_media_hashes = {} + media_store = get_media_store_dir() - if thought_open: - yield make_event( - "response.reasoning_summary_text.done", - { - **base_event, - "type": "response.reasoning_summary_text.done", - "item_id": thought_item_id, - "output_index": current_index, - "summary_index": 0, - "text": full_thoughts, - }, - ) - yield make_event( - "response.reasoning_summary_part.done", - { - **base_event, - "type": "response.reasoning_summary_part.done", - "item_id": thought_item_id, - "output_index": current_index, - "summary_index": 0, - "part": ResponseSummaryPart(text=full_thoughts).model_dump(mode="json"), - }, - ) - yield make_event( - "response.output_item.done", - { - **base_event, - "type": "response.output_item.done", - "output_index": current_index, - "item": ResponseReasoning( - id=thought_item_id, - type="reasoning", - status="completed", - summary=[ResponseSummaryPart(text=full_thoughts)], - ).model_dump(mode="json"), - }, - ) - current_index += 1 + if media_tasks: + logger.debug( + f"Waiting for {len(media_tasks)} background media tasks in Responses with heartbeat..." + ) + while media_tasks: + done, pending = await asyncio.wait( + media_tasks, timeout=5.0, return_when=asyncio.FIRST_COMPLETED + ) + media_tasks = list(pending) - _thoughts, assistant_text, storage_output, detected_tool_calls = _process_llm_output( - full_thoughts, full_text, structured_requirement - ) + if not done: + yield ": ping\n\n" + continue - if message_open: - yield make_event( - "response.output_text.done", - { - **base_event, - "type": "response.output_text.done", - "item_id": message_item_id, - "output_index": current_index, - "content_index": 0, - }, - ) - yield make_event( - "response.content_part.done", - { - **base_event, - "type": "response.content_part.done", - "item_id": message_item_id, - "output_index": current_index, - "content_index": 0, - "part": ResponseOutputContent( - type="output_text", text=assistant_text - ).model_dump(mode="json"), - }, - ) - yield make_event( - "response.output_item.done", - { - **base_event, - "type": "response.output_item.done", - "output_index": current_index, - "item": ResponseOutputMessage( - id=message_item_id, - type="message", - status="completed", - role="assistant", - content=[ResponseOutputContent(type="output_text", text=assistant_text)], - ).model_dump(mode="json"), - }, - ) - current_index += 1 - - image_items: list[ResponseImageGenerationCall] = [] - final_response_contents: list[ResponseOutputContent] = [] - seen_hashes = set() - - for out in all_outputs: - if out.images: - for image in out.images: - try: - b64, w, h, fname, fhash = await _image_to_base64(image, image_store) - if fhash in seen_hashes: + for task in done: + res = task.result() + if not res: continue - seen_hashes.add(fhash) - parts = fname.rsplit(".", 1) - img_id = parts[0] - fmt = parts[1] if len(parts) > 1 else "png" + rtype, original_item, media_data = res + if rtype == "image": + b64, w, h, fname, fhash = media_data + if fhash in seen_hashes: + (media_store / fname).unlink(missing_ok=True) + b64, w, h, fname = seen_hashes[fhash] + else: + seen_hashes[fhash] = (b64, w, h, fname) + + parts = fname.rsplit(".", 1) + img_id = parts[0] + fmt = parts[1] if len(parts) > 1 else "png" + + img_item = ImageGenerationCall( + id=img_id, + result=b64, + output_format=fmt, + size=f"{w}x{h}" if w and h else None, + ) - img_item = ResponseImageGenerationCall( - id=img_id, - result=b64, - output_format=fmt, - size=f"{w}x{h}" if w and h else None, - ) + img_link = f"![{fname}]({base_url}media/{fname}?token={get_media_token(fname)})" + md_to_add = f"\n\n{img_link}" - image_url = ( - f"![{fname}]({base_url}images/{fname}?token={get_image_token(fname)})" - ) - final_response_contents.append( - ResponseOutputContent(type="output_text", text=image_url) - ) + img_index = next_output_index + next_output_index += 1 + yield make_event( + "response.output_item.added", + { + **base_event, + "type": "response.output_item.added", + "output_index": img_index, + "item": dump_model(img_item), + }, + ) + yield make_event( + "response.output_item.done", + { + **base_event, + "type": "response.output_item.done", + "output_index": img_index, + "item": dump_model(img_item), + }, + ) - yield make_event( - "response.output_item.added", - { - **base_event, - "type": "response.output_item.added", - "output_index": current_index, - "item": img_item.model_dump(mode="json"), - }, - ) + if not message_open: + message_index = next_output_index + next_output_index += 1 + yield make_event( + "response.output_item.added", + { + **base_event, + "type": "response.output_item.added", + "output_index": message_index, + "item": dump_model( + ResponseOutputMessage( + id=message_item_id, + type="message", + status="in_progress", + role="assistant", + content=[], + ) + ), + }, + ) + yield make_event( + "response.content_part.added", + { + **base_event, + "type": "response.content_part.added", + "item_id": message_item_id, + "output_index": message_index, + "content_index": 0, + "part": dump_model( + ResponseOutputText(type="output_text", text="") + ), + }, + ) + message_open = True + + yield make_event( + "response.output_text.delta", + { + **base_event, + "type": "response.output_text.delta", + "item_id": message_item_id, + "output_index": message_index, + "content_index": 0, + "delta": md_to_add, + "logprobs": [], + }, + ) + assistant_text += md_to_add + storage_output += md_to_add + image_items.append(img_item) + + elif rtype == "media": + m_dict = cast(ProcessedMediaData, media_data) + if not m_dict: + continue + + m_urls = {} + for mtype, (random_name, fhash) in m_dict.items(): + if fhash in seen_media_hashes: + existing_name = seen_media_hashes[fhash] + if random_name != existing_name: + (media_store / random_name).unlink(missing_ok=True) + m_urls[mtype] = ( + f"{base_url}media/{existing_name}?token={get_media_token(existing_name)}" + ) + else: + seen_media_hashes[fhash] = random_name + m_urls[mtype] = ( + f"{base_url}media/{random_name}?token={get_media_token(random_name)}" + ) + + title = getattr(original_item, "title", "Media") + video_url = m_urls.get("video") + audio_url = m_urls.get("audio") + current_thumb = m_urls.get("video_thumbnail") or m_urls.get( + "audio_thumbnail" + ) - yield make_event( - "response.output_item.done", - { - **base_event, - "type": "response.output_item.done", - "output_index": current_index, - "item": img_item.model_dump(mode="json"), - }, - ) - current_index += 1 - image_items.append(img_item) - storage_output += f"\n\n{image_url}" - except Exception: - logger.warning("Image processing failed in stream") - - for call in detected_tool_calls: - tc_item = ResponseToolCall(id=call.id, status="completed", function=call.function) - yield make_event( - "response.output_item.added", - { - **base_event, - "type": "response.output_item.added", - "output_index": current_index, - "item": tc_item.model_dump(mode="json"), - }, + md_parts = [] + if video_url: + md_parts.append( + f"[![{title}]({current_thumb})]({video_url})" + if current_thumb + else f"[{title}]({video_url})" + ) + if audio_url: + md_parts.append( + f"[![{title} - Audio]({current_thumb})]({audio_url})" + if current_thumb + else f"[{title} - Audio]({audio_url})" + ) + + if md_parts: + media_md = "\n\n".join(md_parts) + md_to_add = f"\n\n{media_md}" + + if not message_open: + message_index = next_output_index + next_output_index += 1 + yield make_event( + "response.output_item.added", + { + **base_event, + "type": "response.output_item.added", + "output_index": message_index, + "item": dump_model( + ResponseOutputMessage( + id=message_item_id, + type="message", + status="in_progress", + role="assistant", + content=[], + ) + ), + }, + ) + yield make_event( + "response.content_part.added", + { + **base_event, + "type": "response.content_part.added", + "item_id": message_item_id, + "output_index": message_index, + "content_index": 0, + "part": dump_model( + ResponseOutputText(type="output_text", text="") + ), + }, + ) + message_open = True + + yield make_event( + "response.output_text.delta", + { + **base_event, + "type": "response.output_text.delta", + "item_id": message_item_id, + "output_index": message_index, + "content_index": 0, + "delta": md_to_add, + "logprobs": [], + }, + ) + assistant_text += md_to_add + storage_output += md_to_add + + final_response_contents: list[ResponseOutputContent] = [] + if choice_error := _tool_choice_failure( + request.tool_choice, + detected_tool_calls, + has_images=bool(image_items), + has_image_tool=has_image_tool, + ): + yield make_event( + "error", + { + **base_event, + "type": "error", + "error": { + "message": choice_error, + "type": "invalid_model_output", + "param": "tool_choice", + "code": "required_tool_missing", + }, + }, + ) + return + if message_open: + if assistant_text: + final_response_contents = [ + ResponseOutputText(type="output_text", text=assistant_text) + ] + else: + final_response_contents = [ResponseOutputText(type="output_text", text="")] + + yield make_event( + "response.output_text.done", + { + **base_event, + "type": "response.output_text.done", + "item_id": message_item_id, + "output_index": message_index, + "content_index": 0, + }, + ) + yield make_event( + "response.content_part.done", + { + **base_event, + "type": "response.content_part.done", + "item_id": message_item_id, + "output_index": message_index, + "content_index": 0, + "part": dump_model( + ResponseOutputText(type="output_text", text=assistant_text) + ), + }, + ) + + yield make_event( + "response.output_item.done", + { + **base_event, + "type": "response.output_item.done", + "output_index": message_index, + "item": dump_model( + ResponseOutputMessage( + id=message_item_id, + type="message", + status="completed", + role="assistant", + content=final_response_contents, + ) + ), + }, + ) + + for call in detected_tool_calls: + tc_index = next_output_index + next_output_index += 1 + tc_item = ResponseFunctionToolCall( + id=call.id, + call_id=call.id, + name=call.function.name, + arguments=call.function.arguments, + status="completed", + ) + yield make_event( + "response.output_item.added", + { + **base_event, + "type": "response.output_item.added", + "output_index": tc_index, + "item": dump_model(tc_item), + }, + ) + yield make_event( + "response.output_item.done", + { + **base_event, + "type": "response.output_item.done", + "output_index": tc_index, + "item": dump_model(tc_item), + }, + ) + + p_tok, c_tok, t_tok, r_tok = calculate_usage( + messages, storage_output, detected_tool_calls, full_thoughts ) + usage = ResponseUsage( + input_tokens=p_tok, + output_tokens=c_tok, + total_tokens=t_tok, + output_tokens_details={"reasoning_tokens": r_tok}, + ) + payload = _create_responses_standard_payload( + response_id, + created_time, + model_name, + detected_tool_calls, + image_items, + final_response_contents, + usage, + request, + structured_requirement, + full_thoughts, + message_item_id, + thought_item_id, + ) + _persist_conversation( + db, + resolved_model, + client_wrapper, + session.metadata, + messages, + storage_output, + detected_tool_calls, + ) + yield make_event( - "response.output_item.done", + "response.completed", { **base_event, - "type": "response.output_item.done", - "output_index": current_index, - "item": tc_item.model_dump(mode="json"), + "type": "response.completed", + "response": dump_model(payload), }, ) - current_index += 1 - if assistant_text: - final_response_contents.insert( - 0, ResponseOutputContent(type="output_text", text=assistant_text) - ) - - p_tok, c_tok, t_tok, r_tok = _calculate_usage( - messages, assistant_text, detected_tool_calls, full_thoughts - ) - usage = ResponseUsage( - input_tokens=p_tok, - output_tokens=c_tok, - total_tokens=t_tok, - output_tokens_details={"reasoning_tokens": r_tok}, - ) - payload = _create_responses_standard_payload( - response_id, - created_time, - model_name, - detected_tool_calls, - image_items, - final_response_contents, - usage, - request, - None, - full_thoughts, - ) - _persist_conversation( - db, - model.model_name, - client_wrapper.id, - session.metadata, - messages, - storage_output, - detected_tool_calls, - full_thoughts, - ) - - yield make_event( - "response.completed", - { - **base_event, - "type": "response.completed", - "response": payload.model_dump(mode="json"), - }, - ) - - yield "data: [DONE]\n\n" + yield "data: [DONE]\n\n" + finally: + await discard_media_tasks() return StreamingResponse(generate_stream(), media_type="text/event-stream") @@ -1749,48 +2557,57 @@ def make_event(etype: str, data: dict) -> str: @router.get("/v1/models", response_model=ModelListResponse) async def list_models(api_key: str = Depends(verify_api_key)): - models = _get_available_models() + pool = GeminiClientPool() + models = await _get_available_models(pool) return ModelListResponse(data=models) -@router.post("/v1/chat/completions") +@router.post("/v1/chat/completions", response_model_exclude_none=True) async def create_chat_completion( request: ChatCompletionRequest, raw_request: Request, api_key: str = Depends(verify_api_key), tmp_dir: Path = Depends(get_temp_dir), - image_store: Path = Depends(get_image_store_dir), ): base_url = str(raw_request.base_url) pool, db = GeminiClientPool(), LMDBConversationStore() try: - model = _get_model_by_name(request.model) + resolved_model = _resolve_model_name(pool, request.model) except ValueError as exc: raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc if not request.messages: raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Messages required.") - structured_requirement = _build_structured_requirement(request.response_format) + _log_ignored_openai_options(request) + function_names = {tool.function.name for tool in request.tools or []} + if choice_error := _tool_choice_declaration_error(function_names, False, request.tool_choice): + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=choice_error) + + try: + structured_requirement = _build_structured_requirement(request.response_format) + except ValueError as exc: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc extra_instr = [structured_requirement.instruction] if structured_requirement else None - # This ensures that server-injected system instructions are part of the history + app_messages = convert_to_app_messages(request.messages) + msgs = _prepare_messages_for_model( - request.messages, + app_messages, request.tools, request.tool_choice, extra_instr, ) - session, client, remain = await _find_reusable_session(db, pool, model, msgs) - reused_session = session is not None - use_google_temporary_mode = g_config.gemini.chat_mode == ChatMode.TEMPORARY + use_temporary = _use_temporary_chat_mode() + needs_upload = _requires_upload(msgs, use_temporary) + session, client, remain, stored_conv = await _find_reusable_session( + db, pool, resolved_model, msgs, temporary=use_temporary, require_account=needs_upload + ) if session: if not remain: raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="No new messages.") - # For reused sessions, we only need to process the remaining messages. - # We don't re-inject system defaults to avoid duplicating instructions already in history. input_msgs = _prepare_messages_for_model( remain, request.tools, @@ -1798,113 +2615,190 @@ async def create_chat_completion( extra_instr, False, ) - m_input, files = await _process_conversation_with_compaction( - input_msgs, - tmp_dir, - allow_summary_compaction=use_google_temporary_mode and (g_config.gemini.oversized_context_strategy == OversizedContextStrategy.COMPACTION), - reason="temporary session replay", - ) + m_input, files = await GeminiClientWrapper.process_conversation(input_msgs, tmp_dir) logger.debug( f"Reused session {reprlib.repr(session.metadata)} - sending {len(input_msgs)} prepared messages." ) else: try: - client = await pool.acquire() - session = client.start_chat(model=model) - # Use the already prepared 'msgs' for a fresh session - m_input, files = await _process_conversation_with_compaction( - msgs, - tmp_dir, - allow_summary_compaction=use_google_temporary_mode and (g_config.gemini.oversized_context_strategy == OversizedContextStrategy.COMPACTION), - reason="temporary fresh replay", - ) + client = await pool.acquire(require_account=needs_upload) + session = client.start_chat(model=client.usable_model(resolved_model)) + m_input, files = await GeminiClientWrapper.process_conversation(msgs, tmp_dir) except Exception as e: - logger.exception("Error in preparing conversation") + logger.error(f"Error in preparing conversation: {e}") raise HTTPException( status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail=str(e) ) from e completion_id = f"chatcmpl-{uuid.uuid4()}" created_time = int(datetime.now(tz=UTC).timestamp()) + + if session is None or client is None: + logger.error("No Gemini session or client available after preparing conversation.") + raise HTTPException( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail="No available Gemini client." + ) + try: - assert session and client logger.debug( f"Client ID: {client.id}, Input length: {len(m_input)}, files count: {len(files)}" ) resp_or_stream, session, client = await _send_with_internal_fallback( pool=pool, - model=model, + db=db, + resolved_model=resolved_model, session=session, client=client, current_input=m_input, files=files, full_prepared_messages=msgs, + stored_conversation=stored_conv, tmp_dir=tmp_dir, stream=bool(request.stream), - reused_session=reused_session, - temporary=use_google_temporary_mode, + temporary=use_temporary, ) except Exception as e: - logger.exception("Gemini API error") + logger.error(f"Gemini API error: {e}") raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail=str(e)) from e if request.stream: + if isinstance(resp_or_stream, ModelOutput): + logger.error("Expected a streaming response from Gemini but got a complete output.") + raise HTTPException( + status_code=status.HTTP_502_BAD_GATEWAY, detail="Streaming response unavailable." + ) return _create_real_streaming_response( resp_or_stream, completion_id, created_time, request.model, - msgs, # Use prepared 'msgs' + msgs, db, - model, + resolved_model, client, session, base_url, structured_requirement, + request.tool_choice, ) - try: - thoughts = resp_or_stream.thoughts - raw_clean = GeminiClientWrapper.extract_output(resp_or_stream, include_thoughts=False) - except Exception as exc: - logger.exception("Gemini output parsing failed.") + if not isinstance(resp_or_stream, ModelOutput): + logger.error("Expected a complete output from Gemini but got a stream.") raise HTTPException( - status_code=status.HTTP_502_BAD_GATEWAY, detail="Malformed response." - ) from exc + status_code=status.HTTP_502_BAD_GATEWAY, detail="Unexpected streaming response." + ) - thoughts, visible_output, storage_output, tool_calls = _process_llm_output( - thoughts, raw_clean, structured_requirement - ) + try: + thoughts, visible_output, storage_output, tool_calls = process_llm_output( + normalize_llm_text(resp_or_stream.thoughts or ""), + normalize_llm_text(resp_or_stream.text or ""), + structured_requirement, + ) + except StructuredOutputValidationError as exc: + raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail=str(exc)) from exc - # Process images for OpenAI non-streaming flow images = resp_or_stream.images or [] + # No `has_images` escape hatch here: Chat Completions has no image-generation tool, so an + # image Gemini volunteers on its own cannot stand in for a function call that was forced. + if choice_error := _tool_choice_failure(request.tool_choice, tool_calls): + raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail=choice_error) + + media_items: list[GeneratedVideo | GeneratedMedia] = (resp_or_stream.videos or []) + ( + resp_or_stream.media or [] + ) + unique_media = [] + seen_urls = set() + for m in media_items: + v_url = getattr(m, "url", None) + a_url = getattr(m, "mp3_url", None) + primary_url = v_url or a_url + if primary_url and primary_url not in seen_urls: + unique_media.append(m) + seen_urls.add(primary_url) + + tasks = [_process_image_item(img) for img in images] + [ + _process_media_item(m) for m in unique_media + ] + results = await asyncio.gather(*tasks) + image_markdown = "" - seen_hashes = set() - for image in images: - try: - _, _, _, fname, fhash = await _image_to_base64(image, image_store) + media_markdown = "" + seen_hashes = {} + seen_media_hashes = {} + media_store = get_media_store_dir() + + for res in results: + if not res: + continue + + if res[0] == "image": + original_item = res[1] + media_data = res[2] + _, _, _, fname, fhash = media_data if fhash in seen_hashes: - (image_store / fname).unlink(missing_ok=True) + (media_store / fname).unlink(missing_ok=True) + fname = seen_hashes[fhash] + else: + seen_hashes[fhash] = fname + + img_url = f"{base_url}media/{fname}?token={get_media_token(fname)}" + title = getattr(original_item, "title", "Image") + image_markdown += f"\n\n![{title}]({img_url})" + + elif res[0] == "media": + original_item = res[1] + media_data = res[2] + m_dict = media_data + if not m_dict: continue - seen_hashes.add(fhash) - img_url = f"![{fname}]({base_url}images/{fname}?token={get_image_token(fname)})" - image_markdown += f"\n\n{img_url}" - except Exception as exc: - logger.warning(f"Failed to process image in OpenAI response: {exc}") + m_urls = {} + for mtype, (random_name, fhash) in m_dict.items(): + if fhash in seen_media_hashes: + existing_name = seen_media_hashes[fhash] + if random_name != existing_name: + (media_store / random_name).unlink(missing_ok=True) + m_urls[mtype] = ( + f"{base_url}media/{existing_name}?token={get_media_token(existing_name)}" + ) + else: + seen_media_hashes[fhash] = random_name + m_urls[mtype] = ( + f"{base_url}media/{random_name}?token={get_media_token(random_name)}" + ) + + title = getattr(original_item, "title", "Media") + video_url = m_urls.get("video") + audio_url = m_urls.get("audio") + current_thumb = m_urls.get("video_thumbnail") or m_urls.get("audio_thumbnail") + + md_parts = [] + if video_url: + md_parts.append( + f"[![{title}]({current_thumb})]({video_url})" + if current_thumb + else f"[{title}]({video_url})" + ) + if audio_url: + md_parts.append( + f"[![{title} - Audio]({current_thumb})]({audio_url})" + if current_thumb + else f"[{title} - Audio]({audio_url})" + ) + + if md_parts: + media_markdown += f"\n\n{'\n\n'.join(md_parts)}" if image_markdown: visible_output += image_markdown storage_output += image_markdown - tool_calls_payload = [call.model_dump(mode="json") for call in tool_calls] - if tool_calls_payload: - logger.debug(f"Detected tool calls: {reprlib.repr(tool_calls_payload)}") + if media_markdown: + visible_output += media_markdown + storage_output += media_markdown - p_tok, c_tok, t_tok, r_tok = _calculate_usage( - request.messages, visible_output, tool_calls, thoughts - ) + p_tok, c_tok, t_tok, r_tok = calculate_usage(app_messages, storage_output, tool_calls, thoughts) usage = { "prompt_tokens": p_tok, "completion_tokens": c_tok, @@ -1916,57 +2810,76 @@ async def create_chat_completion( created_time, request.model, visible_output, - tool_calls_payload, + tool_calls or None, "tool_calls" if tool_calls else "stop", usage, thoughts, ) _persist_conversation( db, - model.model_name, - client.id, + resolved_model, + client, session.metadata, - msgs, # Use prepared messages 'msgs' + msgs, storage_output, tool_calls, - thoughts, ) return payload -@router.post("/v1/responses") +@router.post("/v1/responses", response_model_exclude_none=True) async def create_response( request: ResponseCreateRequest, raw_request: Request, api_key: str = Depends(verify_api_key), tmp_dir: Path = Depends(get_temp_dir), - image_store: Path = Depends(get_image_store_dir), ): base_url = str(raw_request.base_url) - base_messages, norm_input = _response_items_to_messages(request.input) - struct_req = _build_structured_requirement(request.response_format) - extra_instr = [struct_req.instruction] if struct_req else [] + _log_ignored_openai_options(request) + if input_error := _validate_responses_input(request.input): + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=input_error) + base_messages = _convert_responses_to_app_messages(request.input) + try: + structured_requirement = _build_structured_requirement(_responses_response_format(request)) + except ValueError as exc: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc + extra_instr = [structured_requirement.instruction] if structured_requirement else [] standard_tools, image_tools = [], [] if request.tools: for t in request.tools: - if isinstance(t, Tool): + if isinstance(t, FunctionTool): standard_tools.append(t) - elif isinstance(t, ResponseImageTool): + elif isinstance(t, ImageGeneration): image_tools.append(t) elif isinstance(t, dict): if t.get("type") == "function": - standard_tools.append(Tool.model_validate(t)) + standard_tools.append(FunctionTool.model_validate(t)) elif t.get("type") == "image_generation": - image_tools.append(ResponseImageTool.model_validate(t)) + image_tools.append(ImageGeneration.model_validate(t)) + + if ignored_image_options := { + name for image_tool in image_tools for name in image_tool.model_fields_set if name != "type" + }: + logger.debug( + "Ignoring image-generation option(s) unsupported by the Gemini Web upstream: " + f"{', '.join(sorted(ignored_image_options))}" + ) - img_instr = _build_image_generation_instruction( + if choice_error := _tool_choice_declaration_error( + {tool.name for tool in standard_tools}, + bool(image_tools), + request.tool_choice, + ): + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=choice_error) + + img_instr = build_image_generation_instruction( image_tools, - request.tool_choice if isinstance(request.tool_choice, ResponseToolChoice) else None, + request.tool_choice if isinstance(request.tool_choice, ToolChoiceTypes) else None, ) if img_instr: extra_instr.append(img_instr) - preface = _instructions_to_messages(request.instructions) + preface = _convert_instructions_to_app_messages(request.instructions) conv_messages = [*preface, *base_messages] if preface else base_messages model_tool_choice = ( request.tool_choice if isinstance(request.tool_choice, (str, ToolChoiceFunction)) else None @@ -1980,73 +2893,77 @@ async def create_response( ) pool, db = GeminiClientPool(), LMDBConversationStore() try: - model = _get_model_by_name(request.model) + resolved_model = _resolve_model_name(pool, request.model) except ValueError as exc: raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc - session, client, remain = await _find_reusable_session(db, pool, model, messages) - reused_session = session is not None - use_google_temporary_mode = g_config.gemini.chat_mode == ChatMode.TEMPORARY + use_temporary = _use_temporary_chat_mode() + needs_upload = _requires_upload(messages, use_temporary) + session, client, remain, stored_conv = await _find_reusable_session( + db, pool, resolved_model, messages, temporary=use_temporary, require_account=needs_upload + ) if session: msgs = _prepare_messages_for_model( remain, - request.tools, - request.tool_choice, + standard_tools or None, + model_tool_choice, None, False, ) if not msgs: raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="No new messages.") - m_input, files = await _process_conversation_with_compaction( - msgs, - tmp_dir, - allow_summary_compaction=use_google_temporary_mode and (g_config.gemini.oversized_context_strategy == OversizedContextStrategy.COMPACTION), - reason="temporary session replay", - ) + m_input, files = await GeminiClientWrapper.process_conversation(msgs, tmp_dir) logger.debug( f"Reused session {reprlib.repr(session.metadata)} - sending {len(msgs)} prepared messages." ) else: try: - client = await pool.acquire() - session = client.start_chat(model=model) - m_input, files = await _process_conversation_with_compaction( - messages, - tmp_dir, - allow_summary_compaction=use_google_temporary_mode and (g_config.gemini.oversized_context_strategy == OversizedContextStrategy.COMPACTION), - reason="temporary fresh replay", - ) + client = await pool.acquire(require_account=needs_upload) + session = client.start_chat(model=client.usable_model(resolved_model)) + m_input, files = await GeminiClientWrapper.process_conversation(messages, tmp_dir) except Exception as e: - logger.exception("Error in preparing conversation") + logger.error(f"Error in preparing conversation: {e}") raise HTTPException( status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail=str(e) ) from e response_id = f"resp_{uuid.uuid4().hex}" created_time = int(datetime.now(tz=UTC).timestamp()) + + if session is None or client is None: + logger.error("No Gemini session or client available after preparing conversation.") + raise HTTPException( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail="No available Gemini client." + ) + try: - assert session and client logger.debug( f"Client ID: {client.id}, Input length: {len(m_input)}, files count: {len(files)}" ) resp_or_stream, session, client = await _send_with_internal_fallback( pool=pool, - model=model, + db=db, + resolved_model=resolved_model, session=session, client=client, current_input=m_input, files=files, full_prepared_messages=messages, + stored_conversation=stored_conv, tmp_dir=tmp_dir, stream=bool(request.stream), - reused_session=reused_session, - temporary=use_google_temporary_mode, + temporary=use_temporary, ) except Exception as e: - logger.exception("Gemini API error") + logger.error(f"Gemini API error: {e}") raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail=str(e)) from e if request.stream: + if isinstance(resp_or_stream, ModelOutput): + logger.error("Expected a streaming response from Gemini but got a complete output.") + raise HTTPException( + status_code=status.HTTP_502_BAD_GATEWAY, detail="Streaming response unavailable." + ) return _create_responses_real_streaming_response( resp_or_stream, response_id, @@ -2054,84 +2971,143 @@ async def create_response( request.model, messages, db, - model, + resolved_model, client, session, request, - image_store, base_url, - struct_req, + structured_requirement, + bool(image_tools), ) - try: - thoughts = resp_or_stream.thoughts - raw_clean = GeminiClientWrapper.extract_output(resp_or_stream, include_thoughts=False) - except Exception as exc: - logger.exception("Gemini parsing failed") + if not isinstance(resp_or_stream, ModelOutput): + logger.error("Expected a complete output from Gemini but got a stream.") raise HTTPException( - status_code=status.HTTP_502_BAD_GATEWAY, detail="Malformed response." - ) from exc + status_code=status.HTTP_502_BAD_GATEWAY, detail="Unexpected streaming response." + ) - thoughts, assistant_text, storage_output, tool_calls = _process_llm_output( - thoughts, raw_clean, struct_req - ) + try: + thoughts, assistant_text, storage_output, tool_calls = process_llm_output( + normalize_llm_text(resp_or_stream.thoughts or ""), + normalize_llm_text(resp_or_stream.text or ""), + structured_requirement, + ) + except StructuredOutputValidationError as exc: + raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail=str(exc)) from exc images = resp_or_stream.images or [] - if ( - request.tool_choice is not None and request.tool_choice.type == "image_generation" - ) and not images: - raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail="No images returned.") + if choice_error := _tool_choice_failure( + request.tool_choice, + tool_calls, + has_images=bool(images), + has_image_tool=bool(image_tools), + ): + raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail=choice_error) + + unique_media = [] + seen_urls = set() + for m in (resp_or_stream.videos or []) + (resp_or_stream.media or []): + p_url = getattr(m, "url", None) or getattr(m, "mp3_url", None) + if p_url and p_url not in seen_urls: + unique_media.append(m) + seen_urls.add(p_url) + + tasks = [_process_image_item(img) for img in images] + [ + _process_media_item(m) for m in unique_media + ] + results = await asyncio.gather(*tasks) contents, img_calls = [], [] - seen_hashes = set() - for img in images: - try: - b64, w, h, fname, fhash = await _image_to_base64(img, image_store) + seen_hashes = {} + seen_media_hashes = {} + media_markdown = "" + media_store = get_media_store_dir() + + for res in results: + if not res: + continue + + if res[0] == "image": + media_data = res[2] + b64, w, h, fname, fhash = media_data if fhash in seen_hashes: - (image_store / fname).unlink(missing_ok=True) - continue - seen_hashes.add(fhash) + (media_store / fname).unlink(missing_ok=True) + b64, w, h, fname = seen_hashes[fhash] + else: + seen_hashes[fhash] = (b64, w, h, fname) parts = fname.rsplit(".", 1) img_id = parts[0] - img_format = ( - parts[1] - if len(parts) > 1 - else ("png" if isinstance(img, GeneratedImage) else "jpeg") + fmt = parts[1] if len(parts) > 1 else "png" + img_calls.append( + ImageGenerationCall( + id=img_id, result=b64, output_format=fmt, size=f"{w}x{h}" if w and h else None + ) ) - contents.append( - ResponseOutputContent( - type="output_text", - text=f"![{fname}]({base_url}images/{fname}?token={get_image_token(fname)})", + elif res[0] == "media": + original_item = res[1] + media_data = res[2] + m_dict = media_data + if not m_dict: + continue + + m_urls = {} + for mtype, (random_name, fhash) in m_dict.items(): + if fhash in seen_media_hashes: + existing_name = seen_media_hashes[fhash] + if random_name != existing_name: + (media_store / random_name).unlink(missing_ok=True) + m_urls[mtype] = ( + f"{base_url}media/{existing_name}?token={get_media_token(existing_name)}" + ) + else: + seen_media_hashes[fhash] = random_name + m_urls[mtype] = ( + f"{base_url}media/{random_name}?token={get_media_token(random_name)}" + ) + + title = getattr(original_item, "title", "Media") + video_url = m_urls.get("video") + audio_url = m_urls.get("audio") + current_thumb = m_urls.get("video_thumbnail") or m_urls.get("audio_thumbnail") + + md_parts = [] + if video_url: + md_parts.append( + f"[![{title}]({current_thumb})]({video_url})" + if current_thumb + else f"[{title}]({video_url})" ) - ) - img_calls.append( - ResponseImageGenerationCall( - id=img_id, - result=b64, - output_format=img_format, - size=f"{w}x{h}" if w and h else None, + if audio_url: + md_parts.append( + f"[![{title} - Audio]({current_thumb})]({audio_url})" + if current_thumb + else f"[{title} - Audio]({audio_url})" ) - ) - except Exception as e: - logger.warning(f"Image error: {e}") + + if md_parts: + media_markdown += f"\n\n{'\n\n'.join(md_parts)}" if assistant_text: - contents.append(ResponseOutputContent(type="output_text", text=assistant_text)) - if not contents: - contents.append(ResponseOutputContent(type="output_text", text="")) + contents.append(ResponseOutputText(type="output_text", text=assistant_text)) - # Aggregate images for storage image_markdown = "" - for img_call in img_calls: - fname = f"{img_call.id}.{img_call.output_format}" - img_url = f"![{fname}]({base_url}images/{fname}?token={get_image_token(fname)})" - image_markdown += f"\n\n{img_url}" + for ic in img_calls: + img_url = f"{base_url}media/{ic.id}.{ic.output_format}?token={get_media_token(f'{ic.id}.{ic.output_format}')}" + image_markdown += f"\n\n![{ic.id}]({img_url})" if image_markdown: storage_output += image_markdown + contents.append(ResponseOutputText(type="output_text", text=image_markdown)) + + if media_markdown: + storage_output += media_markdown + contents.append(ResponseOutputText(type="output_text", text=media_markdown)) - p_tok, c_tok, t_tok, r_tok = _calculate_usage(messages, assistant_text, tool_calls, thoughts) + if not contents: + contents.append(ResponseOutputText(type="output_text", text="")) + + p_tok, c_tok, t_tok, r_tok = calculate_usage(messages, storage_output, tool_calls, thoughts) usage = ResponseUsage( input_tokens=p_tok, output_tokens=c_tok, @@ -2147,17 +3123,16 @@ async def create_response( contents, usage, request, - norm_input, + structured_requirement, thoughts, ) _persist_conversation( db, - model.model_name, - client.id, + resolved_model, + client, session.metadata, messages, storage_output, tool_calls, - thoughts, ) return payload diff --git a/app/server/gemini.py b/app/server/gemini.py new file mode 100644 index 0000000..6d3b35d --- /dev/null +++ b/app/server/gemini.py @@ -0,0 +1,1065 @@ +"""Native Gemini REST API v1beta endpoints. + +- GET /v1beta/models — list models +- GET /v1beta/models/{model} — get one model +- POST /v1beta/models/{model}:generateContent — non-streaming generation +- POST /v1beta/models/{model}:streamGenerateContent — streaming (SSE) + +Requests are translated into the same internal AppMessage/FunctionTool pipeline the +OpenAI-shaped routes use, so this module owns only the Gemini wire format: it converts +`contents` in, converts candidates back out, and renders errors in Google's envelope. + +Two Gemini features have no local equivalent and are refused rather than dropped, because +silently ignoring them would change what the model is answering: `fileData` (a Files API URI +this process cannot fetch) and `cachedContent`. Generation controls Gemini Web does not expose +are accepted and logged instead - see `_log_ignored_gemini_options`. +""" + +from __future__ import annotations + +import io +import reprlib +import uuid +from pathlib import Path +from typing import Any, Literal, cast + +import orjson +from fastapi import APIRouter, Depends, FastAPI, Request +from fastapi.exception_handlers import http_exception_handler, request_validation_exception_handler +from fastapi.exceptions import RequestValidationError +from fastapi.responses import JSONResponse, StreamingResponse +from gemini_webapi import ModelOutput +from loguru import logger +from starlette.exceptions import HTTPException as StarletteHTTPException + +from app.models import ( + AppContentItem, + AppMessage, + AppToolCall, + AppToolCallFunction, + FunctionTool, + StructuredOutputRequirement, + ToolChoiceFunction, +) +from app.models.gemini_models import ( + GeminiCandidate, + GeminiContent, + GeminiErrorDetail, + GeminiErrorResponse, + GeminiFunctionCall, + GeminiGenerateContentRequest, + GeminiGenerateContentResponse, + GeminiGenerationConfig, + GeminiInlineData, + GeminiModelInfo, + GeminiModelListResponse, + GeminiPart, + GeminiUsageMetadata, +) + +# Shared request/response pipeline helpers; this module owns only the Gemini wire format. +from app.server.chat import ( + StreamingOutputFilter, + _build_structured_requirement, + _find_reusable_session, + _get_available_models, + _image_to_base64, + _persist_conversation, + _prepare_messages_for_model, + _requires_upload, + _resolve_model_name, + _send_with_internal_fallback, + _tool_choice_failure, + _use_temporary_chat_mode, +) +from app.server.middleware import ( + get_media_store_dir, + get_media_token, + get_temp_dir, + verify_gemini_api_key, +) +from app.services import GeminiClientPool, GeminiClientWrapper, LMDBConversationStore +from app.utils.helper import ( + StructuredOutputValidationError, + calculate_usage, + guess_extension_for_mime, + normalize_llm_text, + normalize_openapi_schema, + process_llm_output, + validate_json_schema, +) + +router = APIRouter() + + +def add_gemini_exception_handlers(app: FastAPI) -> None: + """Register Google-style HTTP and validation errors for /v1beta routes.""" + + @app.exception_handler(StarletteHTTPException) + async def gemini_http_exception_handler(request: Request, exc: StarletteHTTPException): + if not request.url.path.startswith("/v1beta/"): + return await http_exception_handler(request, exc) + + grpc_status = { + 400: "INVALID_ARGUMENT", + 401: "UNAUTHENTICATED", + 403: "PERMISSION_DENIED", + 404: "NOT_FOUND", + 429: "RESOURCE_EXHAUSTED", + 503: "UNAVAILABLE", + }.get(exc.status_code, "INTERNAL") + err = _to_gemini_error(exc.status_code, str(exc.detail), grpc_status) + return JSONResponse(status_code=exc.status_code, content=err.model_dump(mode="json")) + + @app.exception_handler(RequestValidationError) + async def gemini_validation_exception_handler(request: Request, exc: RequestValidationError): + """Convert Gemini-route 422 validation errors into Google API error format.""" + if request.url.path.startswith("/v1beta/"): + detail = str(exc.errors()) if exc.errors() else str(exc) + err = GeminiErrorResponse( + error=GeminiErrorDetail( + code=400, + message=f"Invalid request: {detail}", + status="INVALID_ARGUMENT", + ) + ) + return JSONResponse(status_code=400, content=err.model_dump(mode="json")) + return await request_validation_exception_handler(request, exc) + + +# --------------------------------------------------------------------------- +# Gemini ↔ 内部格式转换函数 +# --------------------------------------------------------------------------- + + +def _gemini_contents_to_messages( + contents: list[GeminiContent], + system_instruction: Any | None = None, +) -> list[AppMessage]: + """Convert Gemini contents + systemInstruction into internal AppMessage list. + + Handles: + - role="model" + functionCall → assistant message + tool_calls + - role="user"/"function" + functionResponse → tool messages + - multiple functionResponse parts → multiple tool messages + - multi-modal parts keep original order (text/image interleaved) + """ + messages: list[AppMessage] = [] + + if system_instruction: + sys_parts = ( + system_instruction.parts + if hasattr(system_instruction, "parts") + else (system_instruction.get("parts") or []) + ) + if sys_texts := [p.text for p in sys_parts if p.text]: + messages.append(AppMessage(role="system", content="\n".join(sys_texts))) + + # Track the previous assistant message's tool_call IDs for functionResponse mapping + last_tool_call_ids: dict[str, str] = {} # function_name → call_id + + for content in contents: + role = content.role or "user" + parts = content.parts or [] + + internal_role = cast(Literal["system", "user", "assistant", "tool"], role) + if role == "model": + internal_role = "assistant" + elif role == "function": + internal_role = "tool" + + text_fragments: list[str] = [] + content_items: list[AppContentItem] = [] + tool_calls: list[AppToolCall] = [] + function_responses: list[tuple[str | None, str]] = [] # (name, content_json) + + for part in parts: + if part.text is not None: + text_fragments.append(part.text) + + if part.inlineData: + suffix = guess_extension_for_mime(part.inlineData.mimeType) + content_items.append( + AppContentItem( + type="file", + file_data=part.inlineData.data, + filename=f"inline{suffix}", + ) + ) + + if part.fileData: + # Unreachable via the routes, which reject fileData up front; kept so a direct + # caller of this converter degrades instead of silently mis-building a prompt. + logger.warning( + "[Gemini API] Skipping fileData part " + f"(unsupported): {reprlib.repr(part.fileData.fileUri)}" + ) + + if part.functionCall: + call_id = f"call_{uuid.uuid4().hex[:24]}" + tool_calls.append( + AppToolCall( + id=call_id, + type="function", + function=AppToolCallFunction( + name=part.functionCall.name, + arguments=( + orjson.dumps(part.functionCall.args).decode("utf-8") + if part.functionCall.args + else "{}" + ), + ), + ) + ) + last_tool_call_ids[part.functionCall.name] = call_id + + if part.functionResponse: + resp_content = orjson.dumps(part.functionResponse.response).decode("utf-8") + function_responses.append((part.functionResponse.name, resp_content)) + + if function_responses: + # functionResponse → tool messages regardless of original role + for fn_name, fn_content in function_responses: + call_id = last_tool_call_ids.get(fn_name or "", f"call_{uuid.uuid4().hex[:24]}") + messages.append( + AppMessage( + role="tool", + content=fn_content, + name=fn_name, + tool_call_id=call_id, + ) + ) + if text_fragments and internal_role != "tool": + messages.append(AppMessage(role=internal_role, content="\n".join(text_fragments))) + elif tool_calls: + msg_content = "\n".join(text_fragments) if text_fragments else None + messages.append( + AppMessage(role="assistant", content=msg_content, tool_calls=tool_calls) + ) + elif content_items or text_fragments: + if content_items: + # keep original interleaved text/image order + ordered_items: list[AppContentItem] = [] + text_idx, media_idx = 0, 0 + for part in parts: + if part.text is not None and text_idx < len(text_fragments): + ordered_items.append( + AppContentItem(type="text", text=text_fragments[text_idx]) + ) + text_idx += 1 + elif (part.inlineData or part.fileData) and media_idx < len(content_items): + ordered_items.append(content_items[media_idx]) + media_idx += 1 + messages.append(AppMessage(role=internal_role, content=ordered_items)) + else: + messages.append(AppMessage(role=internal_role, content="\n".join(text_fragments))) + else: + # empty parts: still create a message to keep conversation structure + messages.append(AppMessage(role=internal_role, content="")) + + return messages + + +def _gemini_tools_to_internal( + tools: list[Any] | None, + tool_config: Any | None = None, +) -> tuple[ + list[FunctionTool] | None, + Literal["none", "auto", "required"] | ToolChoiceFunction | None, +]: + """Convert Gemini tools + toolConfig into internal FunctionTool list and tool_choice.""" + if not tools: + return None, None + + internal_tools: list[FunctionTool] = [] + for tool in tools: + internal_tools.extend( + FunctionTool( + type="function", + name=decl.name, + description=decl.description, + parameters=decl.parameters, + ) + for decl in tool.functionDeclarations or [] + ) + tool_choice: Literal["none", "auto", "required"] | ToolChoiceFunction | None = None + if tool_config and tool_config.functionCallingConfig: + call_config = tool_config.functionCallingConfig + mode = call_config.mode.upper() + + # Google: "This should only be set when the Mode is ANY or VALIDATED." Acting on it + # under AUTO or NONE would hide tools the upstream would still have offered. + allowed_names = ( + set(call_config.allowedFunctionNames or []) + if mode in {"ANY", "VALIDATED"} + else set[str]() + ) + if allowed_names: + internal_tools = [tool for tool in internal_tools if tool.name in allowed_names] + + if mode == "NONE": + tool_choice = cast(Literal["none", "auto", "required"], "none") + elif mode == "ANY": + tool_choice = ( + ToolChoiceFunction(type="function", name=next(iter(allowed_names))) + if len(allowed_names) == 1 + else cast(Literal["none", "auto", "required"], "required") + ) + else: + # AUTO, and VALIDATED - which also permits a natural-language answer. + tool_choice = "auto" + + return internal_tools or None, tool_choice + + +def _to_gemini_response( + visible_text: str | None, + tool_calls: list[Any], + thoughts: str | None, + usage_tuple: tuple[int, int, int, int], + model_name: str, + image_parts: list[GeminiPart] | None = None, +) -> GeminiGenerateContentResponse: + """Convert the internal processing result into a Gemini API response.""" + parts: list[GeminiPart] = [] + + if thoughts: + parts.append(GeminiPart(text=thoughts, thought=True)) + + if visible_text: + parts.append(GeminiPart(text=visible_text)) + + if image_parts: + parts.extend(image_parts) + + for tc in tool_calls: + fn = tc.function if hasattr(tc, "function") else tc.get("function", {}) + fn_name = fn.name if hasattr(fn, "name") else fn.get("name", "") + fn_args_raw = fn.arguments if hasattr(fn, "arguments") else fn.get("arguments", "{}") + try: + fn_args = orjson.loads(fn_args_raw) if isinstance(fn_args_raw, str) else fn_args_raw + except orjson.JSONDecodeError: + fn_args = {} + parts.append(GeminiPart(functionCall=GeminiFunctionCall(name=fn_name, args=fn_args))) + + finish_reason = "STOP" + p_tok, c_tok, t_tok, r_tok = usage_tuple + + candidate = GeminiCandidate( + content=GeminiContent(role="model", parts=parts), + finishReason=finish_reason, + index=0, + ) + + usage_meta = GeminiUsageMetadata( + promptTokenCount=p_tok, + candidatesTokenCount=c_tok - r_tok, + totalTokenCount=t_tok, + thoughtsTokenCount=r_tok if r_tok > 0 else None, + ) + + return GeminiGenerateContentResponse( + candidates=[candidate], + usageMetadata=usage_meta, + modelVersion=model_name, + ) + + +def _to_gemini_error(status_code: int, message: str, grpc_status: str) -> GeminiErrorResponse: + """Build a Google API standard error response.""" + return GeminiErrorResponse( + error=GeminiErrorDetail( + code=status_code, + message=message, + status=grpc_status, + ) + ) + + +def _log_ignored_gemini_options( + request: GeminiGenerateContentRequest, response_schema: dict[str, Any] | None +) -> None: + """Debug-log valid Gemini options that the Gemini Web client cannot forward. + + `response_schema` is the already-translated result of `_gemini_response_schema`, passed in + rather than recomputed so the OpenAPI translation and its validation run once per request. + """ + ignored: set[str] = set() + if "safetySettings" in request.model_fields_set: + ignored.add("safetySettings") + + if gen_cfg := request.generationConfig: + supported_structured_fields: set[str] = set() + if gen_cfg.responseMimeType == "application/json": + supported_structured_fields.add("responseMimeType") + # The schema fields count as honored only if one survived translation. An empty schema + # is valid and still counts; `None` alone means translation failed or none was supplied. + if response_schema is not None: + supported_structured_fields.update(("responseSchema", "responseJsonSchema")) + ignored.update( + name for name in gen_cfg.model_fields_set if name not in supported_structured_fields + ) + + if request.toolConfig and (call_config := request.toolConfig.functionCallingConfig): + mode = call_config.mode.upper() + if mode == "VALIDATED": + ignored.add("toolConfig.functionCallingConfig.mode") + if call_config.allowedFunctionNames and mode not in {"ANY", "VALIDATED"}: + ignored.add("toolConfig.functionCallingConfig.allowedFunctionNames") + + if ignored: + logger.debug( + "[Gemini API] Ignoring option(s) unsupported by the Gemini Web upstream: " + f"{', '.join(sorted(ignored))}" + ) + + +def _validate_gemini_request(request: GeminiGenerateContentRequest) -> str | None: + """Reject malformed or unrepresentable inputs, not optional generation controls.""" + if not request.contents: + return "contents is required and cannot be empty." + + for content in request.contents: + if not content.parts: + return "Each content entry must contain at least one part." + if any(part.fileData is not None for part in content.parts): + return "fileData is not supported; provide the data using inlineData instead." + if request.systemInstruction and any( + part.fileData is not None for part in request.systemInstruction.parts + ): + return "fileData is not supported; provide the data using inlineData instead." + + if request.cachedContent is not None: + return "cachedContent is not supported by the Gemini Web upstream." + + if request.toolConfig and request.toolConfig.functionCallingConfig: + call_config = request.toolConfig.functionCallingConfig + # Only meaningful in the modes that act on it; elsewhere it is ignored, not invalid. + if call_config.mode.upper() in {"ANY", "VALIDATED"}: + declared_names = { + declaration.name + for tool in request.tools or [] + for declaration in tool.functionDeclarations + } + allowed_names = set(call_config.allowedFunctionNames or []) + if unknown_names := allowed_names - declared_names: + return ( + f"allowedFunctionNames contains undeclared functions: {sorted(unknown_names)}" + ) + + if request.generationConfig: + gen_cfg = request.generationConfig + # Only `responseJsonSchema` is JSON Schema, so only it can be judged as such. + # `responseSchema` is the OpenAPI subset, translated at use time; a gap in that + # translation must not turn a valid Gemini request into a 400. + if ( + gen_cfg.responseMimeType == "application/json" + and gen_cfg.responseJsonSchema is not None + ): + try: + validate_json_schema(gen_cfg.responseJsonSchema) + except ValueError as exc: + return str(exc) + return None + + +def _gemini_response_schema(gen_cfg: GeminiGenerationConfig) -> dict[str, Any] | None: + """Return the requested response schema as JSON Schema, or None if it cannot be used.""" + if gen_cfg.responseMimeType != "application/json": + return None + + if gen_cfg.responseJsonSchema is not None: + return gen_cfg.responseJsonSchema + + if gen_cfg.responseSchema is None: + return None + + schema = normalize_openapi_schema(gen_cfg.responseSchema) + try: + validate_json_schema(schema) + except ValueError as exc: + # Enforcing a schema we could not translate would reject good answers. + logger.debug(f"[Gemini API] Ignoring responseSchema that is not representable: {exc}") + return None + return schema + + +def _gemini_structured_requirement( + request: GeminiGenerateContentRequest, +) -> tuple[dict[str, Any] | None, StructuredOutputRequirement | None]: + """Translate the requested response schema once, returning it with its requirement. + + The requirement is deliberately non-strict. Google's own API guarantees conformance through + constrained decoding; Gemini Web offers no such control, so the schema can only be asked for + in the prompt. Failing the request on a near-miss would throw away an answer the caller can + still use, so a violation degrades to the raw text and is logged instead. + """ + if not request.generationConfig: + return None, None + + gen_cfg = request.generationConfig + if gen_cfg.responseMimeType != "application/json": + return None, None + + schema = _gemini_response_schema(gen_cfg) + response_format = ( + {"type": "json_object"} + if schema is None + else {"type": "json_schema", "json_schema": {"schema": schema, "strict": False}} + ) + requirement = _build_structured_requirement(response_format) + return schema, requirement + + +def _strip_model_prefix(model: str) -> str: + """Strip a leading 'models/' prefix if present.""" + return model[len("models/") :] if model.startswith("models/") else model + + +def _model_data_to_gemini_info(model_data: Any) -> GeminiModelInfo: + """Convert internal ModelData into GeminiModelInfo.""" + return GeminiModelInfo( + name=f"models/{model_data.id}", + displayName=model_data.id, + description=f"Gemini model: {model_data.id}", + supportedGenerationMethods=["generateContent", "streamGenerateContent"], + ) + + +# --------------------------------------------------------------------------- +# 路由端点 +# --------------------------------------------------------------------------- + + +@router.get("/v1beta/models") +async def gemini_list_models(api_key: str = Depends(verify_gemini_api_key)): + """List available models (Gemini API format).""" + models = await _get_available_models(GeminiClientPool()) + + logger.info(f"[Gemini API] Retrieved {len(models)} models") + if not models: + logger.warning("[Gemini API] Model list is empty") + + gemini_models = [_model_data_to_gemini_info(m) for m in models] + return GeminiModelListResponse(models=gemini_models) + + +@router.get("/v1beta/models/{model:path}") +async def gemini_get_model(model: str, api_key: str = Depends(verify_gemini_api_key)): + """Get one model's info (Gemini API format).""" + model_name = _strip_model_prefix(model) + try: + _resolve_model_name(GeminiClientPool(), model_name) + except ValueError as exc: + err = _to_gemini_error(404, str(exc), "NOT_FOUND") + return JSONResponse(status_code=404, content=err.model_dump(mode="json")) + + all_models = await _get_available_models(GeminiClientPool()) + for m in all_models: + if m.id == model_name: + return _model_data_to_gemini_info(m) + + # Model exists but not listed (e.g. resolved directly from gemini-webapi constants) + return GeminiModelInfo( + name=f"models/{model_name}", + displayName=model_name, + description=f"Gemini model: {model_name}", + supportedGenerationMethods=["generateContent", "streamGenerateContent"], + ) + + +@router.post("/v1beta/models/{model:path}:generateContent") +async def gemini_generate_content( + model: str, + request: GeminiGenerateContentRequest, + raw_request: Request, + api_key: str = Depends(verify_gemini_api_key), + tmp_dir: Path = Depends(get_temp_dir), +): + """Non-streaming content generation (Gemini API format).""" + model_name = _strip_model_prefix(model) + + try: + model_obj = _resolve_model_name(GeminiClientPool(), model_name) + except ValueError as exc: + err = _to_gemini_error(400, str(exc), "INVALID_ARGUMENT") + return JSONResponse(status_code=400, content=err.model_dump(mode="json")) + + if validation_error := _validate_gemini_request(request): + err = _to_gemini_error(400, validation_error, "INVALID_ARGUMENT") + return JSONResponse(status_code=400, content=err.model_dump(mode="json")) + response_schema, structured_requirement = _gemini_structured_requirement(request) + _log_ignored_gemini_options(request, response_schema) + + messages = _gemini_contents_to_messages(request.contents, request.systemInstruction) + + internal_tools, tool_choice = _gemini_tools_to_internal(request.tools, request.toolConfig) + + extra_instr = [structured_requirement.instruction] if structured_requirement else None + + msgs = _prepare_messages_for_model(messages, internal_tools, tool_choice, extra_instr) + + pool, db = GeminiClientPool(), LMDBConversationStore() + use_temporary = _use_temporary_chat_mode() + needs_upload = _requires_upload(msgs, use_temporary) + session, client, remain, stored_conv = await _find_reusable_session( + db, + pool, + model_obj, + msgs, + temporary=use_temporary, + require_account=needs_upload, + ) + + if session: + if not remain: + err = _to_gemini_error(400, "No new messages to send.", "INVALID_ARGUMENT") + return JSONResponse(status_code=400, content=err.model_dump(mode="json")) + + input_msgs = _prepare_messages_for_model( + remain, internal_tools, tool_choice, extra_instr, False + ) + m_input, files = await GeminiClientWrapper.process_conversation(input_msgs, tmp_dir) + logger.debug( + f"[Gemini API] Reusing session {reprlib.repr(session.metadata)}" + f" - sending {len(input_msgs)} message(s)." + ) + else: + try: + client = await pool.acquire(require_account=needs_upload) + session = client.start_chat(model=client.usable_model(model_obj)) + m_input, files = await GeminiClientWrapper.process_conversation(msgs, tmp_dir) + except Exception as e: + logger.exception("[Gemini API] Failed to prepare session") + err = _to_gemini_error(503, str(e), "UNAVAILABLE") + return JSONResponse(status_code=503, content=err.model_dump(mode="json")) + + try: + assert session is not None + assert client is not None + logger.debug( + f"[Gemini API] Client: {client.id}, input len: {len(m_input)}, files: {len(files)}" + ) + resp, session, client = await _send_with_internal_fallback( + pool=pool, + db=db, + resolved_model=model_obj, + session=session, + client=client, + current_input=m_input, + files=cast("list[Path | str | io.BytesIO]", files), + full_prepared_messages=msgs, + stored_conversation=stored_conv, + tmp_dir=tmp_dir, + stream=False, + temporary=use_temporary, + ) + except Exception as e: + logger.exception("[Gemini API] Gemini call failed") + err = _to_gemini_error(502, str(e), "INTERNAL") + return JSONResponse(status_code=502, content=err.model_dump(mode="json")) + + try: + assert isinstance(resp, ModelOutput) + thoughts = normalize_llm_text(resp.thoughts or "") + raw_clean = normalize_llm_text(resp.text or "") + except Exception: + logger.exception("[Gemini API] Output parsing failed") + err = _to_gemini_error(502, "Malformed response.", "INTERNAL") + return JSONResponse(status_code=502, content=err.model_dump(mode="json")) + + try: + thoughts, visible_output, storage_output, tool_calls = process_llm_output( + thoughts, raw_clean, structured_requirement + ) + except StructuredOutputValidationError as exc: + err = _to_gemini_error(502, str(exc), "INTERNAL") + return JSONResponse(status_code=502, content=err.model_dump(mode="json")) + if choice_error := _tool_choice_failure(tool_choice, tool_calls): + err = _to_gemini_error(502, choice_error, "INTERNAL") + return JSONResponse(status_code=502, content=err.model_dump(mode="json")) + + # Images: collect Gemini images → inlineData parts + markdown URL for LMDB persistence + image_parts: list[GeminiPart] = [] + seen_hashes: set[str] = set() + image_store = get_media_store_dir() + base_url = str(raw_request.base_url).rstrip("/") + for image in resp.images or []: + try: + b64_str, _w, _h, fname, file_hash = await _image_to_base64(image, image_store) + if file_hash in seen_hashes: + (image_store / fname).unlink(missing_ok=True) + continue + seen_hashes.add(file_hash) + suffix = Path(fname).suffix.lower() + mime_map = { + ".png": "image/png", + ".jpg": "image/jpeg", + ".jpeg": "image/jpeg", + ".webp": "image/webp", + ".gif": "image/gif", + } + mime_type = mime_map.get(suffix, "image/png") + image_parts.append( + GeminiPart(inlineData=GeminiInlineData(mimeType=mime_type, data=b64_str)) + ) + token = get_media_token(fname) + img_url = f"{base_url}/media/{fname}?token={token}" + storage_output += f"\n\n![{fname}]({img_url})" + except Exception as exc: + logger.warning(f"[Gemini API] Failed to process image: {exc}") + + usage_tuple = calculate_usage(messages, visible_output, tool_calls, thoughts) + + _persist_conversation( + db, + model_obj, + client, + session.metadata, + msgs, + storage_output, + tool_calls, + ) + + return _to_gemini_response( + visible_output, tool_calls, thoughts, usage_tuple, model_name, image_parts + ) + + +@router.post("/v1beta/models/{model:path}:streamGenerateContent") +async def gemini_stream_generate_content( + model: str, + request: GeminiGenerateContentRequest, + raw_request: Request, + api_key: str = Depends(verify_gemini_api_key), + tmp_dir: Path = Depends(get_temp_dir), +): + """Streaming content generation (Gemini API format, SSE).""" + model_name = _strip_model_prefix(model) + + try: + model_obj = _resolve_model_name(GeminiClientPool(), model_name) + except ValueError as exc: + err = _to_gemini_error(400, str(exc), "INVALID_ARGUMENT") + return JSONResponse(status_code=400, content=err.model_dump(mode="json")) + + if validation_error := _validate_gemini_request(request): + err = _to_gemini_error(400, validation_error, "INVALID_ARGUMENT") + return JSONResponse(status_code=400, content=err.model_dump(mode="json")) + response_schema, structured_requirement = _gemini_structured_requirement(request) + _log_ignored_gemini_options(request, response_schema) + + messages = _gemini_contents_to_messages(request.contents, request.systemInstruction) + internal_tools, tool_choice = _gemini_tools_to_internal(request.tools, request.toolConfig) + + extra_instr = [structured_requirement.instruction] if structured_requirement else None + msgs = _prepare_messages_for_model(messages, internal_tools, tool_choice, extra_instr) + + pool, db = GeminiClientPool(), LMDBConversationStore() + use_temporary = _use_temporary_chat_mode() + needs_upload = _requires_upload(msgs, use_temporary) + session, client, remain, stored_conv = await _find_reusable_session( + db, + pool, + model_obj, + msgs, + temporary=use_temporary, + require_account=needs_upload, + ) + + if session: + if not remain: + err = _to_gemini_error(400, "No new messages to send.", "INVALID_ARGUMENT") + return JSONResponse(status_code=400, content=err.model_dump(mode="json")) + + input_msgs = _prepare_messages_for_model( + remain, internal_tools, tool_choice, extra_instr, False + ) + m_input, files = await GeminiClientWrapper.process_conversation(input_msgs, tmp_dir) + else: + try: + client = await pool.acquire(require_account=needs_upload) + session = client.start_chat(model=client.usable_model(model_obj)) + m_input, files = await GeminiClientWrapper.process_conversation(msgs, tmp_dir) + except Exception as e: + logger.exception("[Gemini API] Failed to prepare streaming session") + err = _to_gemini_error(503, str(e), "UNAVAILABLE") + return JSONResponse(status_code=503, content=err.model_dump(mode="json")) + + try: + assert session is not None + assert client is not None + generator, session, client = await _send_with_internal_fallback( + pool=pool, + db=db, + resolved_model=model_obj, + session=session, + client=client, + current_input=m_input, + files=cast("list[Path | str | io.BytesIO]", files), + full_prepared_messages=msgs, + stored_conversation=stored_conv, + tmp_dir=tmp_dir, + stream=True, + temporary=use_temporary, + ) + except Exception as e: + logger.exception("[Gemini API] Gemini streaming call failed") + err = _to_gemini_error(502, str(e), "INTERNAL") + return JSONResponse(status_code=502, content=err.model_dump(mode="json")) + + return _create_gemini_streaming_response( + generator=generator, + model_name=model_name, + messages=msgs, + original_messages=messages, + db=db, + resolved_model=model_obj, + client_wrapper=client, + session=session, + tool_choice=tool_choice, + structured_requirement=structured_requirement, + base_url=str(raw_request.base_url).rstrip("/"), + ) + + +def _create_gemini_streaming_response( + generator, + model_name: str, + messages: list[AppMessage], + original_messages: list[AppMessage], + db: LMDBConversationStore, + resolved_model: str, + client_wrapper: GeminiClientWrapper, + session, + tool_choice: Literal["none", "auto", "required"] | ToolChoiceFunction | None, + structured_requirement=None, + base_url: str = "", +) -> StreamingResponse: + """Create a Gemini-format SSE streaming response.""" + + async def generate_stream(): + full_thoughts, full_text = "", "" + last_chunk: ModelOutput | None = None + all_images: list[Any] = [] # images from all chunks (url-deduped) + seen_image_urls: set[str] = set() + suppressor = StreamingOutputFilter() + + try: + async for chunk in generator: + last_chunk = chunk + + if chunk.images: + for img in chunk.images: + if img.url not in seen_image_urls: + all_images.append(img) + seen_image_urls.add(img.url) + + if t_delta := chunk.thoughts_delta: + full_thoughts += t_delta + think_resp = GeminiGenerateContentResponse( + candidates=[ + GeminiCandidate( + content=GeminiContent( + role="model", + parts=[GeminiPart(text=t_delta, thought=True)], + ), + index=0, + ) + ], + ) + yield f"data: {orjson.dumps(think_resp.model_dump(mode='json', exclude_none=True)).decode('utf-8')}\n\n" + + if text_delta := chunk.text_delta: + full_text += text_delta + if not structured_requirement and ( + visible_delta := suppressor.process(text_delta) + ): + chunk_resp = GeminiGenerateContentResponse( + candidates=[ + GeminiCandidate( + content=GeminiContent( + role="model", + parts=[GeminiPart(text=visible_delta)], + ), + index=0, + ) + ], + ) + yield f"data: {orjson.dumps(chunk_resp.model_dump(mode='json', exclude_none=True)).decode('utf-8')}\n\n" + + except Exception as e: + logger.exception(f"[Gemini API] Streaming error: {e}") + err_resp = _to_gemini_error(500, "Streaming error occurred.", "INTERNAL") + yield f"data: {orjson.dumps(err_resp.model_dump(mode='json')).decode('utf-8')}\n\n" + return + + # Use the final chunk's full text if available + if last_chunk is not None: + if last_chunk.text: + full_text = last_chunk.text + if last_chunk.thoughts: + full_thoughts = last_chunk.thoughts + + if not structured_requirement and (remaining_text := suppressor.flush()): + chunk_resp = GeminiGenerateContentResponse( + candidates=[ + GeminiCandidate( + content=GeminiContent( + role="model", + parts=[GeminiPart(text=remaining_text)], + ), + index=0, + ) + ], + ) + yield f"data: {orjson.dumps(chunk_resp.model_dump(mode='json', exclude_none=True)).decode('utf-8')}\n\n" + + # --- post-processing: protective layer so the SSE tail survives errors --- + # The two expected failures are reported with their own message and status, matching the + # non-streaming route; only genuinely unexpected errors fall through to the catch-all, + # which cannot say anything more useful than that something broke. + try: + _thoughts, visible_output, storage_output, tool_calls = process_llm_output( + full_thoughts, full_text, structured_requirement + ) + except StructuredOutputValidationError as exc: + logger.warning(f"[Gemini API] Structured output rejected mid-stream: {exc}") + err_resp = _to_gemini_error(502, str(exc), "INTERNAL") + yield f"data: {orjson.dumps(err_resp.model_dump(mode='json')).decode('utf-8')}\n\n" + return + + if choice_error := _tool_choice_failure(tool_choice, tool_calls): + logger.warning(f"[Gemini API] Forced tool choice unmet mid-stream: {choice_error}") + err_resp = _to_gemini_error(502, choice_error, "INTERNAL") + yield f"data: {orjson.dumps(err_resp.model_dump(mode='json')).decode('utf-8')}\n\n" + return + + try: + if structured_requirement and visible_output: + structured_chunk = GeminiGenerateContentResponse( + candidates=[ + GeminiCandidate( + content=GeminiContent( + role="model", + parts=[GeminiPart(text=visible_output)], + ), + index=0, + ) + ], + ) + yield f"data: {orjson.dumps(structured_chunk.model_dump(mode='json', exclude_none=True)).decode('utf-8')}\n\n" + + image_store = get_media_store_dir() + seen_hashes: set[str] = set() + for image in all_images: + try: + b64_str, _w, _h, fname, file_hash = await _image_to_base64(image, image_store) + if file_hash in seen_hashes: + (image_store / fname).unlink(missing_ok=True) + continue + seen_hashes.add(file_hash) + suffix = Path(fname).suffix.lower() + mime_map = { + ".png": "image/png", + ".jpg": "image/jpeg", + ".jpeg": "image/jpeg", + ".webp": "image/webp", + ".gif": "image/gif", + } + mime_type = mime_map.get(suffix, "image/png") + img_chunk = GeminiGenerateContentResponse( + candidates=[ + GeminiCandidate( + content=GeminiContent( + role="model", + parts=[ + GeminiPart( + inlineData=GeminiInlineData( + mimeType=mime_type, data=b64_str + ) + ) + ], + ), + index=0, + ) + ], + ) + yield f"data: {orjson.dumps(img_chunk.model_dump(mode='json', exclude_none=True)).decode('utf-8')}\n\n" + token = get_media_token(fname) + img_url = f"{base_url}/media/{fname}?token={token}" + storage_output += f"\n\n![{fname}]({img_url})" + except Exception as exc: + logger.warning(f"[Gemini API] Failed to process streaming image: {exc}") + # Final chunk (finishReason + usageMetadata) + usage_tuple = calculate_usage(original_messages, visible_output, tool_calls, _thoughts) + p_tok, c_tok, t_tok, r_tok = usage_tuple + + final_parts: list[GeminiPart] = [] + if tool_calls: + for tc in tool_calls: + tc_any: Any = tc + fn = ( + tc_any.function + if hasattr(tc_any, "function") + else tc_any.get("function", {}) + ) + fn_name = fn.name if hasattr(fn, "name") else fn.get("name", "") + fn_args_raw = ( + fn.arguments if hasattr(fn, "arguments") else fn.get("arguments", "{}") + ) + try: + fn_args = ( + orjson.loads(fn_args_raw) + if isinstance(fn_args_raw, str) + else fn_args_raw + ) + except orjson.JSONDecodeError: + fn_args = {} + final_parts.append( + GeminiPart(functionCall=GeminiFunctionCall(name=fn_name, args=fn_args)) + ) + + final_resp = GeminiGenerateContentResponse( + candidates=[ + GeminiCandidate( + content=GeminiContent(role="model", parts=final_parts) + if final_parts + else None, + finishReason="STOP", + index=0, + ) + ], + usageMetadata=GeminiUsageMetadata( + promptTokenCount=p_tok, + candidatesTokenCount=c_tok - r_tok, + totalTokenCount=t_tok, + thoughtsTokenCount=r_tok if r_tok > 0 else None, + ), + modelVersion=model_name, + ) + yield f"data: {orjson.dumps(final_resp.model_dump(mode='json', exclude_none=True)).decode('utf-8')}\n\n" + + _persist_conversation( + db, + resolved_model, + client_wrapper, + session.metadata, + messages, + storage_output, + tool_calls, + ) + except Exception as exc: + logger.exception(f"[Gemini API] Post-processing error: {exc}") + err_resp = _to_gemini_error(500, "Post-processing error.", "INTERNAL") + yield f"data: {orjson.dumps(err_resp.model_dump(mode='json')).decode('utf-8')}\n\n" + + return StreamingResponse( + generate_stream(), + media_type="text/event-stream", + headers={ + "Cache-Control": "no-cache", + "Connection": "keep-alive", + "X-Accel-Buffering": "no", + }, + ) diff --git a/app/server/health.py b/app/server/health.py index 444c938..41dd4f1 100644 --- a/app/server/health.py +++ b/app/server/health.py @@ -1,33 +1,49 @@ -from fastapi import APIRouter +from fastapi import APIRouter, Response, status from loguru import logger from app.models import HealthCheckResponse from app.services import GeminiClientPool, LMDBConversationStore +from app.utils import g_config +from app.utils.config import GuestMode router = APIRouter() @router.get("/health", response_model=HealthCheckResponse) -async def health_check(): +async def health_check(response: Response): pool = GeminiClientPool() db = LMDBConversationStore() - - try: - await pool.init() - except Exception as e: - logger.error(f"Failed to initialize Gemini clients: {e}") - return HealthCheckResponse(ok=False, error=str(e)) - client_status = pool.status() + stat = db.stats() if not all(client_status.values()): - logger.warning("One or more Gemini clients not running") + down_clients = [client_id for client_id, status in client_status.items() if not status] + logger.warning(f"One or more Gemini clients are unhealthy: {', '.join(down_clients)}") - stat = db.stats() if not stat: logger.error("Failed to retrieve LMDB conversation store stats") + response.status_code = status.HTTP_503_SERVICE_UNAVAILABLE return HealthCheckResponse( ok=False, error="LMDB conversation store unavailable", clients=client_status ) - return HealthCheckResponse(ok=all(client_status.values()), storage=stat, clients=client_status) + guest_mode = g_config.gemini.guest_mode + if guest_mode == GuestMode.STRICT: + any_client_unhealthy = not all(client_status.values()) + clients_unavailable = any_client_unhealthy + client_error = "One or more Gemini clients are unhealthy" + else: + all_clients_unhealthy = not any(client_status.values()) + clients_unavailable = guest_mode == GuestMode.ADAPTIVE and all_clients_unhealthy + client_error = "No usable Gemini client is available" + + if clients_unavailable: + response.status_code = status.HTTP_503_SERVICE_UNAVAILABLE + return HealthCheckResponse( + ok=False, + error=client_error, + storage=stat, + clients=client_status, + ) + + return HealthCheckResponse(ok=True, storage=stat, clients=client_status) diff --git a/app/server/images.py b/app/server/images.py deleted file mode 100644 index e1c161c..0000000 --- a/app/server/images.py +++ /dev/null @@ -1,18 +0,0 @@ -from fastapi import APIRouter, HTTPException, Query -from fastapi.responses import FileResponse - -from app.server.middleware import get_image_store_dir, verify_image_token - -router = APIRouter() - - -@router.get("/images/{filename}", tags=["Images"]) -async def get_image(filename: str, token: str | None = Query(default=None)): - if not verify_image_token(filename, token): - raise HTTPException(status_code=403, detail="Invalid token") - - image_store = get_image_store_dir() - file_path = image_store / filename - if not file_path.exists(): - raise HTTPException(status_code=404, detail="Image not found") - return FileResponse(file_path) diff --git a/app/server/media.py b/app/server/media.py new file mode 100644 index 0000000..206b0bd --- /dev/null +++ b/app/server/media.py @@ -0,0 +1,41 @@ +import re +from pathlib import Path + +from fastapi import APIRouter, HTTPException, Query +from fastapi.responses import FileResponse + +from app.server.middleware import get_media_store_dir, verify_media_token + +router = APIRouter() +# The extension is whatever the upstream save produced, so it has to allow more than plain +# alphanumerics (`.tar.gz`, `.x-m4a`) while still excluding every path separator and `..`. +MEDIA_FILENAME_RE = re.compile(r"(?:img|media)_[0-9a-f]{32}\.[A-Za-z0-9][A-Za-z0-9.\-_]{0,15}\Z") + + +def _resolve_media_file(media_store: Path, filename: str) -> Path | None: + """Return an existing media file inside the store, or None if the name is not one of ours. + + The name has to match the pattern this server generates, which excludes separators and + traversal outright; the containment check then covers a store reached through a symlink. + """ + if not MEDIA_FILENAME_RE.fullmatch(filename): + return None + + root = media_store.resolve() + candidate = (root / filename).resolve() + try: + candidate.relative_to(root) + except ValueError: + return None + return candidate if candidate.is_file() else None + + +@router.get("/media/{filename}", tags=["Media"]) +async def get_media(filename: str, token: str | None = Query(default=None)): + if not verify_media_token(filename, token): + raise HTTPException(status_code=403, detail="Invalid token") + + file_path = _resolve_media_file(get_media_store_dir(), filename) + if file_path is None: + raise HTTPException(status_code=404, detail="Media not found") + return FileResponse(file_path) diff --git a/app/server/middleware.py b/app/server/middleware.py index 2fa016b..f8dae4a 100644 --- a/app/server/middleware.py +++ b/app/server/middleware.py @@ -3,26 +3,118 @@ import tempfile import time from pathlib import Path +from typing import Any from fastapi import Depends, FastAPI, HTTPException, Request, status from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import JSONResponse from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer from loguru import logger +from starlette.types import ASGIApp, Message, Receive, Scope, Send from app.utils import g_config -# Persistent directory for storing generated images -IMAGE_STORE_DIR = Path(g_config.storage.images_path) -IMAGE_STORE_DIR.mkdir(parents=True, exist_ok=True) +# Persistent directory for storing generated media +MEDIA_STORE_DIR = Path(g_config.storage.media_path) +MEDIA_STORE_DIR.mkdir(parents=True, exist_ok=True) -def get_image_store_dir() -> Path: - """Returns a persistent directory for storing images.""" - return IMAGE_STORE_DIR +class RequestBodyLimitMiddleware: + """Reject request bodies past a local ceiling, before the app buffers them. + A declared `content-length` is refused up front; a chunked body is measured as it arrives + and cut off once it crosses the limit. The ceiling exists to bound this process's memory, + not to describe what Gemini Web will accept - the upstream decides that for itself. + """ -def get_image_token(filename: str) -> str: + def __init__(self, app: ASGIApp, max_body_bytes: int) -> None: + self.app = app + self.max_body_bytes = max(0, max_body_bytes) + + async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: + if scope["type"] != "http" or self.max_body_bytes == 0: + await self.app(scope, receive, send) + return + + content_length = next( + ( + value + for name, value in scope.get("headers", []) + if name.lower() == b"content-length" + ), + None, + ) + if content_length is not None: + try: + declared_size = int(content_length) + except ValueError: + declared_size = 0 + if declared_size > self.max_body_bytes: + await self._send_too_large(scope, receive, send) + return + + received = 0 + overflowed = False + + async def limited_receive() -> Message: + nonlocal overflowed, received + message = await receive() + if message["type"] == "http.request": + received += len(message.get("body", b"")) + if received > self.max_body_bytes: + overflowed = True + raise HTTPException( + status_code=status.HTTP_413_CONTENT_TOO_LARGE, + detail=self._detail(), + ) + return message + + async def limited_send(message: Message) -> None: + # FastAPI turns the receive error into its own generic envelope; drop that so the + # surface-appropriate body below is what the client actually gets. + if not overflowed: + await send(message) + + try: + await self.app(scope, limited_receive, limited_send) + except HTTPException: + if not overflowed: + raise + + if overflowed: + await self._send_too_large(scope, receive, send) + + def _detail(self) -> str: + return ( + f"Request body exceeds the wrapper safety ceiling of {self.max_body_bytes} bytes. " + "This is a local resource guard, not Gemini Web's upstream input limit." + ) + + async def _send_too_large(self, scope: Scope, receive: Receive, send: Send) -> None: + detail = self._detail() + if str(scope.get("path", "")).startswith("/v1beta/"): + content: dict[str, Any] = { + "error": { + "code": status.HTTP_413_CONTENT_TOO_LARGE, + "message": detail, + "status": "RESOURCE_EXHAUSTED", + } + } + else: + content = {"error": {"message": detail}} + response = JSONResponse( + status_code=status.HTTP_413_CONTENT_TOO_LARGE, + content=content, + ) + await response(scope, receive, send) + + +def get_media_store_dir() -> Path: + """Returns a persistent directory for storing media.""" + return MEDIA_STORE_DIR + + +def get_media_token(filename: str) -> str: """Generate a HMAC-SHA256 token for a filename using the API key.""" secret = g_config.server.api_key if not secret: @@ -33,18 +125,15 @@ def get_image_token(filename: str) -> str: return hmac.new(secret_bytes, msg, hashlib.sha256).hexdigest() -def verify_image_token(filename: str, token: str | None) -> bool: +def verify_media_token(filename: str, token: str | None) -> bool: """Verify the provided token against the filename.""" - expected = get_image_token(filename) - if not expected: - return True # No auth required - if not token: - return False - return hmac.compare_digest(token, expected) + if expected := get_media_token(filename): + return hmac.compare_digest(token, expected) if token else False + return True # No auth required -def cleanup_expired_images(retention_days: int) -> int: - """Delete images in IMAGE_STORE_DIR older than retention_days.""" +def cleanup_expired_media(retention_days: int) -> int: + """Delete media files in MEDIA_STORE_DIR older than retention_days.""" if retention_days <= 0: return 0 @@ -53,7 +142,7 @@ def cleanup_expired_images(retention_days: int) -> int: cutoff = now - retention_seconds count = 0 - for file_path in IMAGE_STORE_DIR.iterdir(): + for file_path in MEDIA_STORE_DIR.iterdir(): if not file_path.is_file(): continue try: @@ -61,10 +150,10 @@ def cleanup_expired_images(retention_days: int) -> int: file_path.unlink() count += 1 except Exception as e: - logger.warning(f"Failed to delete expired image {file_path}: {e}") + logger.warning(f"Failed to delete expired media {file_path}: {e}") if count > 0: - logger.info(f"Cleaned up {count} expired images.") + logger.info(f"Cleaned up {count} expired media files.") return count @@ -89,6 +178,34 @@ def get_temp_dir(): temp_dir.cleanup() +def verify_gemini_api_key(request: Request): + """Gemini-style auth: x-goog-api-key header, key= query param, or Bearer fallback.""" + if not g_config.server.api_key: + return "" + + # 1) x-goog-api-key header + if api_key := request.headers.get("x-goog-api-key"): + if api_key != g_config.server.api_key: + raise HTTPException(status.HTTP_403_FORBIDDEN, detail="Wrong API key") + return api_key + + # 2) key= query parameter + if api_key := request.query_params.get("key"): + if api_key != g_config.server.api_key: + raise HTTPException(status.HTTP_403_FORBIDDEN, detail="Wrong API key") + return api_key + + # 3) fall back to Authorization: Bearer + auth_header = request.headers.get("authorization", "") + if auth_header.lower().startswith("bearer "): + api_key = auth_header[7:] + if api_key != g_config.server.api_key: + raise HTTPException(status.HTTP_403_FORBIDDEN, detail="Wrong API key") + return api_key + + raise HTTPException(status.HTTP_401_UNAUTHORIZED, detail="Invalid or missing API key") + + def verify_api_key( credentials: HTTPAuthorizationCredentials = Depends(HTTPBearer(auto_error=False)), ): @@ -109,6 +226,13 @@ def add_exception_handler(app: FastAPI): app.add_exception_handler(Exception, global_exception_handler) +def add_request_size_limit_middleware(app: FastAPI) -> None: + app.add_middleware( + RequestBodyLimitMiddleware, + max_body_bytes=g_config.server.max_request_body_bytes, + ) + + def add_cors_middleware(app: FastAPI): if g_config.cors.enabled: cors = g_config.cors diff --git a/app/services/client.py b/app/services/client.py index b8f976b..4d9d5f0 100644 --- a/app/services/client.py +++ b/app/services/client.py @@ -1,78 +1,163 @@ +import io from pathlib import Path -from typing import Any, cast +from typing import Any import orjson -from gemini_webapi import GeminiClient, ModelOutput +from gemini_webapi import GeminiClient +from gemini_webapi.constants import AccountStatus +from gemini_webapi.types import AvailableModel from loguru import logger -from app.models import Message +from app.models import AppMessage from app.utils import g_config from app.utils.helper import ( add_tag, - normalize_llm_text, save_file_to_tempfile, save_url_to_tempfile, ) -_UNSET = object() - - -def _resolve(value: Any, fallback: Any): - return fallback if value is _UNSET else value +# How a model is addressed: by name, or as one of the models a client discovered. `None` leaves +# the choice to Google. +type ModelSpec = str | AvailableModel | None class GeminiClientWrapper(GeminiClient): """Gemini client with helper methods.""" def __init__(self, client_id: str, **kwargs): + self._cfg_impersonate: str | None = kwargs.pop("impersonate", None) super().__init__(**kwargs) self.id = client_id + self._initialized = False + # Chat id of the last conversation this client opened, its kind unverified. Google closes + # an ephemeral window as soon as another conversation is created, so for those chats this + # is the only cid still continuable. In memory and cleared on every (re)initialization: + # once the session that opened a window is gone, nothing can vouch for it. + self.latest_chat_cid: str | None = None - async def init( - self, - timeout: float = cast(float, _UNSET), - watchdog_timeout: float = cast(float, _UNSET), - auto_close: bool = False, - close_delay: float = cast(float, _UNSET), - auto_refresh: bool = cast(bool, _UNSET), - refresh_interval: float = cast(float, _UNSET), - verbose: bool = cast(bool, _UNSET), - ) -> None: + async def init(self, *args: Any, **kwargs: Any) -> None: """ - Inject default configuration values. + Inject default configuration values from global settings. """ config = g_config.gemini - timeout = cast(float, _resolve(timeout, config.timeout)) - watchdog_timeout = cast(float, _resolve(watchdog_timeout, config.watchdog_timeout)) - close_delay = timeout - auto_refresh = cast(bool, _resolve(auto_refresh, config.auto_refresh)) - refresh_interval = cast(float, _resolve(refresh_interval, config.refresh_interval)) - verbose = cast(bool, _resolve(verbose, config.verbose)) - + init_kwargs: dict[str, Any] = { + "timeout": config.timeout, + "watchdog_timeout": config.watchdog_timeout, + "auto_refresh": config.auto_refresh, + "refresh_interval": config.refresh_interval, + "auto_close": config.auto_close, + "close_delay": config.close_delay, + "verbose": config.verbose, + } + if self._cfg_impersonate is not None: + init_kwargs["impersonate"] = self._cfg_impersonate try: - await super().init( - timeout=timeout, - watchdog_timeout=watchdog_timeout, - auto_close=auto_close, - close_delay=close_delay, - auto_refresh=auto_refresh, - refresh_interval=refresh_interval, - verbose=verbose, - ) + await super().init(**init_kwargs) + self._initialized = True + self.latest_chat_cid = None except Exception: + self._initialized = False logger.exception(f"Failed to initialize GeminiClient {self.id}") raise def running(self) -> bool: return self._running + def is_healthy(self) -> bool: + """ + Check if the client is healthy. + + A client is healthy if it is active (running or initialized with auto-close) + and the account status is available. + """ + is_active = self._running or (self.auto_close and self._initialized) + return is_active and self.account_status == AccountStatus.AVAILABLE + + def is_guest(self) -> bool: + """Whether this client talks to Google without an account. + + Set when initialization falls back to a guest session, or when an authenticated one is + rejected mid-flight because its cookies expired. Text generation keeps working, so this + has to be asked rather than assumed away: the session just has no history, no uploads + and no model choice. + """ + return self.account_status == AccountStatus.UNAUTHENTICATED + + def can_upload(self) -> bool: + """Whether files may be attached; Google rejects uploads from a guest session.""" + return not self.is_guest() + + def usable_model(self, model: ModelSpec) -> ModelSpec: + """The requested model, or the only one a guest session may use. + + Google offers a guest no model choice, so honouring the request is impossible; serving + its default beats failing outright. Callers keep addressing the request by its original + model name, which is what conversation storage stays keyed on. + """ + if not self.is_guest(): + return model + + # None when the guest registry is empty, which leaves the model unspecified and lets + # Google answer with whatever it gives a signed-out visitor. + default = next((m for m in self._model_registry.values() if m.is_available), None) + requested = model if isinstance(model, str) else getattr(model, "model_name", None) + if requested != getattr(default, "model_name", None): + logger.warning( + f"Client {self.id} is a guest session; serving " + f"{default or 'the default model'} instead of the requested {requested}." + ) + return default + + def chat_scope(self, temporary: bool) -> str | None: + """Identity of the ephemeral window chats opened now belong to, else None. + + `None` is a normal chat, which Google keeps in the account's history and stays reusable + across restarts. Any other value names a window only that exact session can continue, so + a stored scope that no longer matches this client's proves the window behind it is gone. + + Keyed on the parent's per-session id, rerolled on every successful initialization, plus + guest state. That covers both ways a window dies silently: a reinitialization, and a + mid-session downgrade to guest, which keeps the session id but loses the account's chats. + """ + if self.is_guest(): + return f"guest:{self._sessionid}" + return f"temporary:{self._sessionid}" if temporary else None + @staticmethod - async def process_message( - message: Message, tempdir: Path | None = None, tagged: bool = True, wrap_tool: bool = True - ) -> tuple[str, list[Path | str]]: + async def _process_content_item( + item: Any, role: str, tempdir: Path | None + ) -> tuple[str | None, Path | str | None]: """ - Process a Message into Gemini API format using the PascalCase technical protocol. - Extracts text, handles files, and appends ToolCalls/ToolResults blocks. + Process a single content item (text, image_url, file, input_audio). + Returns a tuple of (text_fragment, file_path). + """ + if item.type == "text": + item_text = getattr(item, "text", "") or "" + if item_text or role == "tool": + return item_text, None + elif item.type == "image_url": + if item_media_url := getattr(item, "url", None): + return None, await save_url_to_tempfile(item_media_url, tempdir) + raise ValueError(f"{item.type} cannot be empty") + elif item.type == "file": + if file_url := getattr(item, "url", None): + return None, await save_url_to_tempfile(file_url, tempdir) + if not (file_data := getattr(item, "file_data", None)): + raise ValueError("File must contain 'file_data' or 'url'") + filename = getattr(item, "filename", "") or "" + return None, await save_file_to_tempfile(file_data, filename, tempdir) + elif item.type == "input_audio": + if file_data := getattr(item, "file_data", None): + return None, await save_file_to_tempfile(file_data, "audio.wav", tempdir) + raise ValueError("input_audio must contain 'file_data' key") + return None, None + + @staticmethod + async def _extract_content_and_files( + message: AppMessage, tempdir: Path | None + ) -> tuple[list[str], list[Path | str]]: + """ + Extract text fragments and files from message content. """ files: list[Path | str] = [] text_fragments: list[str] = [] @@ -82,68 +167,86 @@ async def process_message( text_fragments.append(message.content or "") elif isinstance(message.content, list): for item in message.content: - if item.type == "text": - if item.text or message.role == "tool": - text_fragments.append(item.text or "") - elif item.type == "image_url": - if not item.image_url: - raise ValueError("Image URL cannot be empty") - if url := item.image_url.get("url", None): - files.append(await save_url_to_tempfile(url, tempdir)) - else: - raise ValueError("Image URL must contain 'url' key") - elif item.type == "file": - if not item.file: - raise ValueError("File cannot be empty") - if file_data := item.file.get("file_data", None): - filename = item.file.get("filename", "") - files.append(await save_file_to_tempfile(file_data, filename, tempdir)) - elif url := item.file.get("url", None): - files.append(await save_url_to_tempfile(url, tempdir)) - else: - raise ValueError("File must contain 'file_data' or 'url' key") + text, file = await GeminiClientWrapper._process_content_item( + item, message.role, tempdir + ) + if text is not None: + text_fragments.append(text) + if file is not None: + files.append(file) elif message.content is None and message.role == "tool": text_fragments.append("") elif message.content is not None: - raise ValueError("Unsupported message content type.") + raise ValueError(f"Unsupported message content type: {type(message.content)}") - if message.role == "tool": - tool_name = message.name or "unknown" - combined_content = "\n".join(text_fragments).strip() - res_block = ( - f"[Result:{tool_name}]\n[ToolResult]\n{combined_content}\n[/ToolResult]\n[/Result]" - ) - if wrap_tool: - text_fragments = [f"[ToolResults]\n{res_block}\n[/ToolResults]"] - else: - text_fragments = [res_block] - - if message.tool_calls: - tool_blocks: list[str] = [] - for call in message.tool_calls: - params_text = call.function.arguments.strip() - formatted_params = "" - if params_text: - try: - parsed_params = orjson.loads(params_text) - if isinstance(parsed_params, dict): - for k, v in parsed_params.items(): - val_str = ( - v if isinstance(v, str) else orjson.dumps(v).decode("utf-8") - ) - formatted_params += ( - f"[CallParameter:{k}]\n```\n{val_str}\n```\n[/CallParameter]\n" - ) - else: - formatted_params += f"```\n{params_text}\n```\n" - except orjson.JSONDecodeError: + return text_fragments, files + + @staticmethod + def _format_tool_results( + text_fragments: list[str], tool_name: str | None, wrap_tool: bool + ) -> list[str]: + """ + Format tool results into the PascalCase technical protocol blocks. + """ + tool_name = tool_name or "unknown" + combined_content = "\n".join(text_fragments).strip() + res_block = ( + f"[Result:{tool_name}]\n[ToolResult]\n{combined_content}\n[/ToolResult]\n[/Result]" + ) + return [f"[ToolResults]\n{res_block}\n[/ToolResults]"] if wrap_tool else [res_block] + + @staticmethod + def _format_tool_calls(message: AppMessage) -> str | None: + """ + Format tool calls into the PascalCase technical protocol blocks. + """ + if not message.tool_calls: + return None + + tool_blocks: list[str] = [] + for call in message.tool_calls: + params_text = call.function.arguments.strip() + formatted_params = "" + if params_text: + try: + parsed_params = orjson.loads(params_text) + if isinstance(parsed_params, dict): + for k, v in parsed_params.items(): + val_str = v if isinstance(v, str) else orjson.dumps(v).decode("utf-8") + formatted_params += ( + f"[CallParameter:{k}]\n```\n{val_str}\n```\n[/CallParameter]\n" + ) + else: formatted_params += f"```\n{params_text}\n```\n" + except orjson.JSONDecodeError: + formatted_params += f"```\n{params_text}\n```\n" + + tool_blocks.append(f"[Call:{call.function.name}]\n{formatted_params}[/Call]") - tool_blocks.append(f"[Call:{call.function.name}]\n{formatted_params}[/Call]") + return "[ToolCalls]\n" + "\n".join(tool_blocks) + "\n[/ToolCalls]" if tool_blocks else None - if tool_blocks: - tool_section = "[ToolCalls]\n" + "\n".join(tool_blocks) + "\n[/ToolCalls]" - text_fragments.append(tool_section) + @staticmethod + async def process_message( + message: AppMessage, + tempdir: Path | None = None, + tagged: bool = True, + wrap_tool: bool = True, + ) -> tuple[str, list[Path | str]]: + """ + Process a Message into Gemini API format using the PascalCase technical protocol. + Extracts text, handles files, and appends ToolCalls/ToolResults blocks. + """ + text_fragments, files = await GeminiClientWrapper._extract_content_and_files( + message, tempdir + ) + + if message.role == "tool": + text_fragments = GeminiClientWrapper._format_tool_results( + text_fragments, message.name, wrap_tool + ) + + if tool_section := GeminiClientWrapper._format_tool_calls(message): + text_fragments.append(tool_section) model_input = "\n".join(fragment for fragment in text_fragments if fragment is not None) @@ -154,10 +257,10 @@ async def process_message( @staticmethod async def process_conversation( - messages: list[Message], tempdir: Path | None = None - ) -> tuple[str, list[Path | str]]: + messages: list[AppMessage], tempdir: Path | None = None + ) -> tuple[str, list[str | Path | bytes | io.BytesIO]]: conversation: list[str] = [] - files: list[Path | str] = [] + files: list[str | Path | bytes | io.BytesIO] = [] i = 0 while i < len(messages): @@ -185,15 +288,3 @@ async def process_conversation( conversation.append(add_tag("assistant", "", unclose=True)) return "\n".join(conversation), files - - @staticmethod - def extract_output(response: ModelOutput, include_thoughts: bool = True) -> str: - text = "" - if include_thoughts and response.thoughts: - text += f"{response.thoughts}\n" - if response.text: - text += response.text - else: - text += str(response) - - return normalize_llm_text(text) diff --git a/app/services/lmdb.py b/app/services/lmdb.py index 07f0a23..98afa5b 100644 --- a/app/services/lmdb.py +++ b/app/services/lmdb.py @@ -1,21 +1,24 @@ import hashlib import string -from contextlib import contextmanager +from collections.abc import Generator, Mapping +from contextlib import contextmanager, suppress from datetime import datetime, timedelta from pathlib import Path -from typing import Any +from typing import Any, Self, cast import lmdb import orjson +from lmdb import Environment, Error, Transaction from loguru import logger -from app.models import ContentItem, ConversationInStore, Message +from app.models import ( + AppMessage, + ConversationInStore, +) from app.utils import g_config from app.utils.helper import ( - extract_tool_calls, normalize_llm_text, remove_tool_call_blocks, - strip_system_hints, unescape_text, ) from app.utils.singleton import Singleton @@ -28,9 +31,7 @@ def _fuzzy_normalize(text: str | None) -> str | None: Lowercase and remove all whitespace and punctuation. Used as a fallback for complex/malformed contents matching. """ - if text is None: - return None - return text.lower().translate(_VOLATILE_TRANS_TABLE) + return None if text is None else text.lower().translate(_VOLATILE_TRANS_TABLE) def _normalize_text(text: str | None, fuzzy: bool = False) -> str | None: @@ -40,27 +41,19 @@ def _normalize_text(text: str | None, fuzzy: bool = False) -> str | None: text = normalize_llm_text(text) text = unescape_text(text) - text = remove_tool_call_blocks(text) - if fuzzy: - return _fuzzy_normalize(text) - - # Always strip to ensure trailing newlines/spaces don't break exact matches - return text.strip() if text.strip() else None + return _fuzzy_normalize(text) if fuzzy else text.strip() or None -def _hash_message(message: Message, fuzzy: bool = False) -> str: +def _hash_message(message: AppMessage, fuzzy: bool = False) -> str: """ Generate a stable, canonical hash for a single message. """ core_data: dict[str, Any] = { "role": message.role, - "name": message.name or None, - "tool_call_id": message.tool_call_id or None, - "reasoning_content": _normalize_text(message.reasoning_content) - if message.reasoning_content - else None, + "name": message.name, + "tool_call_id": message.tool_call_id, } content = message.content @@ -69,41 +62,34 @@ def _hash_message(message: Message, fuzzy: bool = False) -> str: elif isinstance(content, str): core_data["content"] = _normalize_text(content, fuzzy=fuzzy) elif isinstance(content, list): - text_parts = [] + content_items: list[dict[str, Any]] = [] for item in content: - text_val = "" - if isinstance(item, ContentItem) and item.type == "text": - text_val = item.text - elif isinstance(item, dict) and item.get("type") == "text": - text_val = item.get("text") - - if text_val: - normalized_part = _normalize_text(text_val, fuzzy=fuzzy) - if normalized_part: - text_parts.append(normalized_part) - elif isinstance(item, (ContentItem, dict)): - item_type = item.type if isinstance(item, ContentItem) else item.get("type") - if item_type == "image_url": - url = ( - item.image_url.get("url") - if isinstance(item, ContentItem) and item.image_url - else item.get("image_url", {}).get("url") - ) - text_parts.append(f"[image_url:{url}]") - elif item_type == "file": - url = ( - item.file.get("url") or item.file.get("filename") - if isinstance(item, ContentItem) and item.file - else item.get("file", {}).get("url") or item.get("file", {}).get("filename") - ) - text_parts.append(f"[file:{url}]") - - core_data["content"] = "\n".join(text_parts) if text_parts else None - + item_data: dict[str, Any] = { + "type": item.type, + "filename": item.filename, + "url": item.url, + "content_digest": item.content_digest, + } + if item.text is not None: + item_data["text"] = _normalize_text(item.text, fuzzy=fuzzy) + if item.raw_data is not None: + # Included directly: the outer dump sorts keys recursively, so this is already + # canonical, and digesting it would only serialize the same data a second time. + item_data["raw_data"] = item.raw_data + content_items.append(item_data) + + core_data["content"] = content_items or None + + # `reasoning_content` is deliberately NOT hashed. `_persist_conversation` stores every + # assistant turn it produces with `reasoning_content=None`, while both request converters + # populate it from whatever the client echoes back - and this server does emit reasoning on + # both surfaces. Hashing it would make the stored turn and the replayed turn disagree by + # construction, so the newest prefix could never match and reuse would collapse. if message.tool_calls: calls_data = [] for tc in message.tool_calls: - args = tc.function.arguments or "{}" + args = tc.function.arguments + name = tc.function.name try: parsed = orjson.loads(args) canon_args = orjson.dumps(parsed, option=orjson.OPT_SORT_KEYS).decode("utf-8") @@ -112,7 +98,7 @@ def _hash_message(message: Message, fuzzy: bool = False) -> str: calls_data.append( { - "name": tc.function.name, + "name": name, "arguments": canon_args, } ) @@ -126,7 +112,7 @@ def _hash_message(message: Message, fuzzy: bool = False) -> str: def _hash_conversation( - client_id: str, model: str, messages: list[Message], fuzzy: bool = False + client_id: str, model: str, messages: list[AppMessage], fuzzy: bool = False ) -> str: """Generate a hash for a list of messages and model name, tied to a specific client_id.""" combined_hash = hashlib.sha256() @@ -141,8 +127,42 @@ def _hash_conversation( class LMDBConversationStore(metaclass=Singleton): """LMDB-based storage for Message lists with hash-based key-value operations.""" - HASH_LOOKUP_PREFIX = "hash:" - FUZZY_LOOKUP_PREFIX = "fuzzy:" + # Bump when _hash_message changes shape. Entries under an older version can never match + # again, and their conversations would otherwise keep index rows no eviction can find, + # so startup sweeps them instead of leaving them to accumulate. + # + # The conversation records themselves are keyed by the hash that produced them, so records + # written under an older version stay unreachable after the sweep: a repeat of the same + # conversation is replayed in full and stored again under a current key, and the superseded + # record is left to expire on the normal retention schedule. + INDEX_VERSION = "v2" + HASH_LOOKUP_PREFIX = f"hash:{INDEX_VERSION}:" + FUZZY_LOOKUP_PREFIX = f"fuzzy:{INDEX_VERSION}:" + _INDEX_NAMESPACES = ("hash:", "fuzzy:") + _INTERNAL_NAMESPACES = ("hash:", "fuzzy:", "meta:") + _INDEX_VERSION_KEY = "meta:index_version" + + @classmethod + def open_isolated( + cls, + db_path: str, + max_db_size: int | None = None, + retention_days: int | None = None, + ) -> Self: + """Open a store outside the singleton, for maintenance commands and isolated tests. + + LMDB does not support two environments on one path in a single process, so `db_path` + must not be the path the singleton already holds open. + """ + return cast( + Self, + type.__call__( + cls, + db_path=db_path, + max_db_size=max_db_size, + retention_days=retention_days, + ), + ) def __init__( self, @@ -168,7 +188,7 @@ def __init__( self.db_path: Path = Path(db_path) self.max_db_size: int = max_db_size self.retention_days: int = max(0, int(retention_days)) - self._env: lmdb.Environment | None = None + self._env: Environment | None = None self._ensure_db_path() self._init_environment() @@ -189,12 +209,12 @@ def _init_environment(self) -> None: meminit=False, ) logger.info(f"LMDB environment initialized at {self.db_path}") - except lmdb.Error as e: + except Error as e: logger.error(f"Failed to initialize LMDB environment: {e}") raise @contextmanager - def _get_transaction(self, write: bool = False): + def _get_transaction(self, write: bool = False) -> Generator[Transaction]: """ Context manager for LMDB transactions. @@ -204,12 +224,12 @@ def _get_transaction(self, write: bool = False): if not self._env: raise RuntimeError("LMDB environment not initialized") - txn: lmdb.Transaction = self._env.begin(write=write) + txn: Transaction = self._env.begin(write=write) try: yield txn if write: txn.commit() - except lmdb.Error: + except Error: if write: txn.abort() raise @@ -220,40 +240,37 @@ def _get_transaction(self, write: bool = False): raise @staticmethod - def _decode_index_value(data: bytes) -> list[str]: + def _decode_index_value(data: bytes | memoryview) -> list[str]: """Decode index value, handling both legacy single-string and new list-of-strings formats.""" if not data: return [] + data = bytes(data) if data.startswith(b"["): - try: + with suppress(orjson.JSONDecodeError): val = orjson.loads(data) if isinstance(val, list): return [str(v) for v in val] - except orjson.JSONDecodeError: - pass try: return [data.decode("utf-8")] except UnicodeDecodeError: return [] - @staticmethod - def _update_index(txn: lmdb.Transaction, prefix: str, hash_val: str, storage_key: str): + def _update_index(self, txn: Transaction, prefix: str, hash_val: str, storage_key: str): """Add a storage key to the index for a given hash, avoiding duplicates.""" idx_key = f"{prefix}{hash_val}".encode() existing = txn.get(idx_key) - keys = LMDBConversationStore._decode_index_value(existing) if existing else [] + keys = self._decode_index_value(existing) if existing else [] if storage_key not in keys: keys.append(storage_key) txn.put(idx_key, orjson.dumps(keys)) - @staticmethod - def _remove_from_index(txn: lmdb.Transaction, prefix: str, hash_val: str, storage_key: str): + def _remove_from_index(self, txn: Transaction, prefix: str, hash_val: str, storage_key: str): """Remove a specific storage key from the index for a given hash.""" idx_key = f"{prefix}{hash_val}".encode() existing = txn.get(idx_key) if not existing: return - keys = LMDBConversationStore._decode_index_value(existing) + keys = self._decode_index_value(existing) if storage_key in keys: keys.remove(storage_key) if keys: @@ -263,29 +280,39 @@ def _remove_from_index(txn: lmdb.Transaction, prefix: str, hash_val: str, storag def store( self, - conv: ConversationInStore, - custom_key: str | None = None, - ) -> str: + client_id: str, + model: str, + messages: list[AppMessage], + metadata: list[str | None], + chat_scope: str | None = None, + ) -> None: """ Store a conversation model in LMDB. Args: - conv: Conversation model to store - custom_key: Optional custom key, if not provided, hash will be used - - Returns: - str: The key used to store the messages (hash or custom key) + client_id: The client identifier + model: The model name + messages: Unsanitized API messages + metadata: Session metadata + chat_scope: Identity of the ephemeral window owning the chat, None if it is a normal + chat kept in the account's history """ - if not conv: + if not messages: raise ValueError("Messages list cannot be empty") - # Ensure consistent sanitization before hashing and storage - sanitized_messages = self.sanitize_messages(conv.messages) - conv.messages = sanitized_messages - + now = datetime.now() + conv = ConversationInStore( + model=model, + client_id=client_id, + metadata=metadata, + messages=messages, + chat_scope=chat_scope, + created_at=now, + updated_at=now, + ) message_hash = _hash_conversation(conv.client_id, conv.model, conv.messages) fuzzy_hash = _hash_conversation(conv.client_id, conv.model, conv.messages, fuzzy=True) - storage_key = custom_key or message_hash + storage_key = message_hash now = datetime.now() if conv.created_at is None: @@ -302,9 +329,8 @@ def store( self._update_index(txn, self.FUZZY_LOOKUP_PREFIX, fuzzy_hash, storage_key) logger.debug(f"Stored {len(conv.messages)} messages with key: {storage_key[:12]}") - return storage_key - except lmdb.Error as e: + except Error as e: logger.error(f"LMDB error while storing messages with key {storage_key[:12]}: {e}") raise except Exception as e: @@ -325,26 +351,30 @@ def get(self, key: str) -> ConversationInStore | None: """ try: with self._get_transaction(write=False) as txn: - data = txn.get(key.encode("utf-8"), default=None) - if not data: - return None - - storage_data = orjson.loads(data) - conv = ConversationInStore.model_validate(storage_data) - - logger.debug(f"Retrieved {len(conv.messages)} messages with key: {key[:12]}") - return conv - except (lmdb.Error, orjson.JSONDecodeError) as e: + return self._get_messages_from_database(txn, key) + except (Error, orjson.JSONDecodeError) as e: logger.error(f"Failed to retrieve/parse messages with key {key[:12]}: {e}") return None except Exception as e: logger.error(f"Unexpected error retrieving messages with key {key[:12]}: {e}") return None - def find(self, model: str, messages: list[Message]) -> ConversationInStore | None: + @staticmethod + def _get_messages_from_database(txn, key): + data = txn.get(key.encode("utf-8"), default=None) + if not data: + return None + + storage_data = orjson.loads(data) + conv = ConversationInStore.model_validate(storage_data) + + logger.debug(f"Retrieved {len(conv.messages)} messages with key: {key[:12]}") + return conv + + def find(self, model: str, messages: list[AppMessage]) -> ConversationInStore | None: """ Search conversation data by message list. - Tries raw matching, then sanitized matching, and finally fuzzy matching. + Tries sanitized matching, and finally fuzzy matching. Args: model: Model name @@ -357,16 +387,7 @@ def find(self, model: str, messages: list[Message]) -> ConversationInStore | Non return None if conv := self._find_by_message_list(model, messages): - logger.debug(f"Session found for '{model}' with {len(messages)} raw messages.") - return conv - - cleaned_messages = self.sanitize_messages(messages) - if cleaned_messages != messages and ( - conv := self._find_by_message_list(model, cleaned_messages) - ): - logger.debug( - f"Session found for '{model}' with {len(cleaned_messages)} cleaned messages." - ) + logger.debug(f"Session found for '{model}' with {len(messages)} cleaned messages.") return conv if conv := self._find_by_message_list(model, messages, fuzzy=True): @@ -381,7 +402,7 @@ def find(self, model: str, messages: list[Message]) -> ConversationInStore | Non def _find_by_message_list( self, model: str, - messages: list[Message], + messages: list[AppMessage], fuzzy: bool = False, ) -> ConversationInStore | None: """ @@ -412,18 +433,13 @@ def _find_by_message_list( if len(conv.messages) != target_len: continue - match_found = True - for i in range(target_len): - if ( - _hash_message(conv.messages[i], fuzzy=fuzzy) - != target_hashes[i] - ): - match_found = False - break - + match_found = all( + _hash_message(conv.messages[i], fuzzy=fuzzy) == target_hashes[i] + for i in range(target_len) + ) if match_found: return conv - except lmdb.Error as e: + except Error as e: logger.error( f"LMDB error while searching for hash {message_hash} and client {c.id}: {e}" ) @@ -433,12 +449,21 @@ def _find_by_message_list( return conv return None + def evict(self, conv: ConversationInStore) -> bool: + """Delete a stored conversation given the record itself. + + Used to drop metadata that Google has already invalidated, so the next request + does not rediscover the same dead session and fail again. + """ + key = _hash_conversation(conv.client_id, conv.model, conv.messages) + return self.delete(key) is not None + def exists(self, key: str) -> bool: """Check if a key exists in the store.""" try: with self._get_transaction(write=False) as txn: return txn.get(key.encode("utf-8")) is not None - except lmdb.Error as e: + except Error as e: logger.error(f"Failed to check existence of key {key}: {e}") return False @@ -446,27 +471,73 @@ def delete(self, key: str) -> ConversationInStore | None: """Delete conversation model by key.""" try: with self._get_transaction(write=True) as txn: - data = txn.get(key.encode("utf-8")) - if not data: - return None - - storage_data = orjson.loads(data) - conv = ConversationInStore.model_validate(storage_data) - message_hash = _hash_conversation(conv.client_id, conv.model, conv.messages) - fuzzy_hash = _hash_conversation( - conv.client_id, conv.model, conv.messages, fuzzy=True - ) + return self._delete_messages_from_database(txn, key) + except (Error, orjson.JSONDecodeError) as e: + logger.error(f"Failed to delete messages with key {key[:12]}: {e}") + return None - txn.delete(key.encode("utf-8")) + def _delete_messages_from_database(self, txn, key): + data = txn.get(key.encode("utf-8")) + if not data: + return None - self._remove_from_index(txn, self.HASH_LOOKUP_PREFIX, message_hash, key) - self._remove_from_index(txn, self.FUZZY_LOOKUP_PREFIX, fuzzy_hash, key) + storage_data = orjson.loads(data) + conv = ConversationInStore.model_validate(storage_data) + message_hash = _hash_conversation(conv.client_id, conv.model, conv.messages) + fuzzy_hash = _hash_conversation(conv.client_id, conv.model, conv.messages, fuzzy=True) - logger.debug(f"Deleted messages with key: {key[:12]}") - return conv - except (lmdb.Error, orjson.JSONDecodeError) as e: - logger.error(f"Failed to delete messages with key {key[:12]}: {e}") - return None + txn.delete(key.encode("utf-8")) + + self._remove_from_index(txn, self.HASH_LOOKUP_PREFIX, message_hash, key) + self._remove_from_index(txn, self.FUZZY_LOOKUP_PREFIX, fuzzy_hash, key) + + logger.debug(f"Deleted messages with key: {key[:12]}") + return conv + + def _is_index_key(self, key: str) -> bool: + """Whether a raw key is a lookup entry rather than a stored conversation.""" + return key.startswith(self._INDEX_NAMESPACES) + + def _is_internal_key(self, key: str) -> bool: + """Whether a raw key is bookkeeping rather than a stored conversation.""" + return key.startswith(self._INTERNAL_NAMESPACES) + + def prune_stale_indexes(self) -> int: + """Drop lookup entries written under a superseded INDEX_VERSION. + + Only the lookup entries go: the conversation records they pointed at are keyed by the + old hash and cannot be re-indexed under the new one, so they are left to expire under + the normal retention window. + + A marker records that the sweep ran for this version, so later startups skip the scan. + """ + version_key = self._INDEX_VERSION_KEY.encode("utf-8") + try: + with self._get_transaction(write=True) as txn: + if txn.get(version_key) == self.INDEX_VERSION.encode("utf-8"): + return 0 + + stale = [ + bytes(key) + for key, _ in txn.cursor() + if (decoded := bytes(key).decode("utf-8", "replace")) + and self._is_index_key(decoded) + and not decoded.startswith((self.HASH_LOOKUP_PREFIX, self.FUZZY_LOOKUP_PREFIX)) + ] + for key in stale: + txn.delete(key) + txn.put(version_key, self.INDEX_VERSION.encode("utf-8"), overwrite=True) + except Error as exc: + logger.error(f"Failed to prune stale LMDB indexes: {exc}") + return 0 + + if stale: + logger.info( + f"Pruned {len(stale)} LMDB lookup entries from a superseded index version; " + "the conversations behind them are replayed in full once and stored again " + "under a current key, and the superseded records expire under retention." + ) + return len(stale) def keys(self, prefix: str = "", limit: int | None = None) -> list[str]: """List all keys in the store, optionally filtered by prefix.""" @@ -478,11 +549,8 @@ def keys(self, prefix: str = "", limit: int | None = None) -> list[str]: count = 0 for key, _ in cursor: - key_str = key.decode("utf-8") - # Skip internal index mappings - if key_str.startswith(self.HASH_LOOKUP_PREFIX) or key_str.startswith( - self.FUZZY_LOOKUP_PREFIX - ): + key_str = bytes(key).decode("utf-8") + if self._is_internal_key(key_str): continue if not prefix or key_str.startswith(prefix): @@ -490,7 +558,7 @@ def keys(self, prefix: str = "", limit: int | None = None) -> list[str]: count += 1 if limit and count >= limit: break - except lmdb.Error as e: + except Error as e: logger.error(f"Failed to list keys: {e}") return keys @@ -504,16 +572,18 @@ def cleanup_expired(self, retention_days: int | None = None) -> int: return 0 cutoff = datetime.now() - timedelta(days=retention_value) + return self.cleanup_before(cutoff) + + def cleanup_before(self, cutoff: datetime) -> int: + """Delete conversations older than an explicit timestamp and repair both indexes.""" expired_entries: list[tuple[str, ConversationInStore]] = [] try: with self._get_transaction(write=False) as txn: cursor = txn.cursor() for key_bytes, value_bytes in cursor: - key_str = key_bytes.decode("utf-8") - if key_str.startswith(self.HASH_LOOKUP_PREFIX) or key_str.startswith( - self.FUZZY_LOOKUP_PREFIX - ): + key_str = bytes(key_bytes).decode("utf-8") + if self._is_internal_key(key_str): continue try: @@ -523,13 +593,15 @@ def cleanup_expired(self, retention_days: int | None = None) -> int: logger.warning(f"Failed to decode record for key {key_str}: {exc}") continue - timestamp = conv.created_at or conv.updated_at + # Last touched, not first created: a conversation still in active use has + # not expired no matter how long ago it started. + timestamp = conv.updated_at or conv.created_at if not timestamp: continue if timestamp < cutoff: expired_entries.append((key_str, conv)) - except lmdb.Error as exc: + except Error as exc: logger.error(f"Failed to scan LMDB for retention cleanup: {exc}") raise @@ -544,15 +616,16 @@ def cleanup_expired(self, retention_days: int | None = None) -> int: if not txn.delete(key_bytes): continue - message_hash = _hash_conversation(conv.client_id, conv.model, conv.messages) - if message_hash: + if message_hash := _hash_conversation( + conv.client_id, conv.model, conv.messages + ): self._remove_from_index(txn, self.HASH_LOOKUP_PREFIX, message_hash, key_str) fuzzy_hash = _hash_conversation( conv.client_id, conv.model, conv.messages, fuzzy=True ) self._remove_from_index(txn, self.FUZZY_LOOKUP_PREFIX, fuzzy_hash, key_str) removed += 1 - except lmdb.Error as exc: + except Error as exc: logger.error(f"Failed to delete expired conversations: {exc}") raise @@ -563,14 +636,27 @@ def cleanup_expired(self, retention_days: int | None = None) -> int: return removed - def stats(self) -> dict[str, Any]: + def clear(self) -> int: + """Delete every conversation and index entry from the store.""" + removed = len(self.keys()) + try: + with self._get_transaction(write=True) as txn: + keys = [bytes(key) for key, _ in txn.cursor()] + for key in keys: + txn.delete(key) + except Error as exc: + logger.error(f"Failed to clear LMDB: {exc}") + raise + return removed + + def stats(self) -> Mapping[str, Any]: """Get database statistics.""" if not self._env: logger.error("LMDB environment not initialized") return {} try: return self._env.stat() - except lmdb.Error as e: + except Error as e: logger.error(f"Failed to get database stats: {e}") return {} @@ -584,68 +670,3 @@ def close(self) -> None: def __del__(self): """Cleanup on destruction.""" self.close() - - @staticmethod - def sanitize_messages(messages: list[Message]) -> list[Message]: - """Clean all messages of internal markers, hints and normalize tool calls.""" - cleaned_messages = [] - for msg in messages: - update_data = {} - content_changed = False - - # Normalize reasoning_content - if msg.reasoning_content: - norm_reasoning = _normalize_text(msg.reasoning_content) - if norm_reasoning != msg.reasoning_content: - update_data["reasoning_content"] = norm_reasoning - content_changed = True - - if isinstance(msg.content, str): - text = msg.content - tool_calls = msg.tool_calls - - if msg.role == "assistant" and not tool_calls: - text, tool_calls = extract_tool_calls(text) - else: - text = strip_system_hints(text) - - normalized_content = text.strip() or None - - if normalized_content != msg.content: - update_data["content"] = normalized_content - content_changed = True - if tool_calls != msg.tool_calls: - update_data["tool_calls"] = tool_calls or None - content_changed = True - - elif isinstance(msg.content, list): - new_content = [] - all_extracted_calls = list(msg.tool_calls or []) - list_changed = False - - for item in msg.content: - if isinstance(item, ContentItem) and item.type == "text" and item.text: - text = item.text - if msg.role == "assistant" and not msg.tool_calls: - text, extracted = extract_tool_calls(text) - if extracted: - all_extracted_calls.extend(extracted) - list_changed = True - else: - text = strip_system_hints(text) - - if text != item.text: - list_changed = True - item = item.model_copy(update={"text": text.strip() or None}) - new_content.append(item) - - if list_changed: - update_data["content"] = new_content - update_data["tool_calls"] = all_extracted_calls or None - content_changed = True - - if content_changed: - cleaned_messages.append(msg.model_copy(update=update_data)) - else: - cleaned_messages.append(msg) - return cleaned_messages diff --git a/app/services/pool.py b/app/services/pool.py index 3b4197c..9ebdcdc 100644 --- a/app/services/pool.py +++ b/app/services/pool.py @@ -1,4 +1,5 @@ import asyncio +import random from collections import deque from loguru import logger @@ -24,39 +25,48 @@ def __init__(self) -> None: for c in g_config.gemini.clients: client = GeminiClientWrapper( client_id=c.id, - secure_1psid=c.secure_1psid, - secure_1psidts=c.secure_1psidts, - proxy=c.proxy, + **c.model_dump(exclude={"id"}), ) self._clients.append(client) self._id_map[c.id] = client self._round_robin.append(client) self._restart_locks[c.id] = asyncio.Lock() + async def _init_one(self, client: GeminiClientWrapper) -> bool: + """Initialize a single client; returns True on success.""" + return await self._init_attempt(client) + + async def _init_attempt(self, client: GeminiClientWrapper) -> bool: + """Run library init; returns True on success.""" + try: + await client.init() + return True + except Exception: + return False + async def init(self) -> None: - """Initialize all clients in the pool.""" - success_count = 0 - for client in self._clients: - if not client.running(): - try: - await client.init( - timeout=g_config.gemini.timeout, - watchdog_timeout=g_config.gemini.watchdog_timeout, - auto_refresh=g_config.gemini.auto_refresh, - verbose=g_config.gemini.verbose, - refresh_interval=g_config.gemini.refresh_interval, - ) - except Exception: - logger.exception(f"Failed to initialize client {client.id}") + """Initialize all clients in the pool with staggered start times.""" + clients_to_init = [c for c in self._clients if not c.running()] + for i, client in enumerate(clients_to_init): + await self._init_one(client) - if client.running(): - success_count += 1 + if i < len(clients_to_init) - 1: + delay = random.uniform(5, 30) + logger.info(f"Staggering next initialization by {delay:.2f}s") + await asyncio.sleep(delay) + success_count = sum(bool(client.running()) for client in self._clients) if success_count == 0: raise RuntimeError("Failed to initialize any Gemini clients") - async def acquire(self, client_id: str | None = None) -> GeminiClientWrapper: - """Return a healthy client by id or using round-robin.""" + async def acquire( + self, client_id: str | None = None, require_account: bool = False + ) -> GeminiClientWrapper: + """Return a healthy client by id or using round-robin. + + `require_account` excludes guest sessions, for requests they cannot serve at all - file + uploads. Otherwise a guest is used only once no authenticated client is left. + """ if not self._round_robin: raise RuntimeError("No Gemini clients configured") @@ -70,12 +80,32 @@ async def acquire(self, client_id: str | None = None) -> GeminiClientWrapper: f"Gemini client {client_id} is not running and could not be restarted" ) - for _ in range(len(self._round_robin)): - client = self._round_robin[0] - self._round_robin.rotate(-1) - if await self._ensure_client_ready(client): - return client + # Authenticated clients first. A client whose cookies expired keeps answering text + # prompts as a guest, so it stays usable and must not take the pool down, but it has no + # history, no uploads and no model choice - traffic belongs elsewhere while it can. + for account_only in (True,) if require_account else (True, False): + for _ in range(len(self._round_robin)): + client = self._round_robin[0] + self._round_robin.rotate(-1) + # Rechecked after readiness: a restart can itself land in a guest session. + if account_only and client.is_guest(): + continue + if await self._ensure_client_ready(client) and not ( + account_only and client.is_guest() + ): + return client + + if account_only and not require_account and any(c.is_guest() for c in self._clients): + logger.warning( + "No authenticated Gemini client is available; falling back to a guest " + "session until cookies are refreshed." + ) + if require_account: + raise RuntimeError( + "No authenticated Gemini client is available. This request needs a file upload, " + "which a guest session cannot do - refresh the client cookies." + ) raise RuntimeError("No Gemini clients are currently available") async def _ensure_client_ready(self, client: GeminiClientWrapper) -> bool: @@ -91,25 +121,28 @@ async def _ensure_client_ready(self, client: GeminiClientWrapper) -> bool: if client.running(): return True - try: - await client.init( - timeout=g_config.gemini.timeout, - watchdog_timeout=g_config.gemini.watchdog_timeout, - auto_refresh=g_config.gemini.auto_refresh, - verbose=g_config.gemini.verbose, - refresh_interval=g_config.gemini.refresh_interval, - ) + if await self._init_attempt(client): logger.info(f"Restarted Gemini client {client.id} after it stopped.") return True - except Exception: - logger.exception(f"Failed to restart Gemini client {client.id}") - return False + return False @property def clients(self) -> list[GeminiClientWrapper]: """Return managed clients.""" return self._clients + async def close(self) -> None: + """Close all clients in the pool.""" + if not self._clients: + return + + logger.info(f"Closing {len(self._clients)} Gemini clients...") + await asyncio.gather( + *(client.close() for client in self._clients if client.running()), + return_exceptions=True, + ) + logger.info("All Gemini clients closed.") + def status(self) -> dict[str, bool]: - """Return running status for each client.""" - return {client.id: client.running() for client in self._clients} + """Return healthy status for each client.""" + return {client.id: client.is_healthy() for client in self._clients} diff --git a/app/utils/config.py b/app/utils/config.py index 4acfc4e..4294cd2 100644 --- a/app/utils/config.py +++ b/app/utils/config.py @@ -1,10 +1,9 @@ -import ast import os import sys from enum import StrEnum -from typing import Any, Literal +from typing import Any, Literal, cast, get_args -import orjson +from curl_cffi import BrowserTypeLiteral from loguru import logger from pydantic import BaseModel, Field, ValidationError, field_validator from pydantic_settings import ( @@ -33,6 +32,23 @@ class ServerConfig(BaseModel): default=None, description="API key for authentication, if set, will enable API key validation", ) + max_request_body_bytes: int = Field( + default=256 * 1024 * 1024, + ge=0, + description=( + "Local HTTP body safety ceiling in bytes (0 disables it). This protects wrapper " + "resources and does not define Gemini Web's upstream acceptance limit" + ), + ) + schema_validation_budget_seconds: float = Field( + default=1.0, + gt=0, + description=( + "Wall-clock budget for evaluating the regex keywords of a client-supplied JSON " + "Schema against one response. Guards against catastrophic backtracking; exhausting " + "it leaves the reply unverified rather than treating it as a schema violation" + ), + ) https: HTTPSConfig = Field(default=HTTPSConfig(), description="HTTPS configuration") @@ -40,43 +56,35 @@ class GeminiClientSettings(BaseModel): """Credential set for one Gemini client.""" id: str = Field(..., description="Unique identifier for the client") - secure_1psid: str = Field(..., description="Gemini Secure 1PSID") - secure_1psidts: str = Field(..., description="Gemini Secure 1PSIDTS") + secure_1psid: str | None = Field(default=None, description="Gemini Secure 1PSID") + secure_1psidts: str | None = Field(default=None, description="Gemini Secure 1PSIDTS") proxy: str | None = Field(default=None, description="Proxy URL for this Gemini client") + impersonate: str | None = Field( + default=None, + description="Browser impersonation target for curl_cffi. None uses library default", + ) - @field_validator("proxy", mode="before") + @field_validator("proxy", "impersonate", mode="before") @classmethod - def _blank_proxy_to_none(cls, value: str | None) -> str | None: + def _blank_string_to_none(cls, value: str | None) -> str | None: + """Normalize empty or whitespace-only strings to None.""" if value is None: return None stripped = value.strip() return stripped or None - -class GeminiModelConfig(BaseModel): - """Configuration for a custom Gemini model.""" - - model_name: str | None = Field(default=None, description="Name of the model") - model_header: dict[str, str | None] | None = Field( - default=None, description="Header for the model" - ) - - @field_validator("model_header", mode="before") + @field_validator("impersonate") @classmethod - def _parse_json_string(cls, v: Any) -> Any: - if isinstance(v, str) and v.strip().startswith("{"): - try: - return orjson.loads(v) - except orjson.JSONDecodeError: - return v - return v - - -class OversizedContextStrategy(StrEnum): - """Strategy for handling oversized context.""" - - COMPACTION = "compaction" - FILE = "file" + def _validate_impersonate(cls, value: str | None) -> str | None: + """Validate that impersonate is a supported curl_cffi BrowserTypeLiteral value.""" + if value is None: + return None + allowed = get_args(BrowserTypeLiteral) + if value not in allowed: + raise ValueError( + f"impersonate={value!r} is not supported. Allowed values: {', '.join(allowed)}" + ) + return value class ChatMode(StrEnum): @@ -86,69 +94,71 @@ class ChatMode(StrEnum): TEMPORARY = "temporary" +class GuestMode(StrEnum): + """Health-check policy for Gemini clients running as guests.""" + + STRICT = "strict" + ADAPTIVE = "adaptive" + PERMISSIVE = "permissive" + + class GeminiConfig(BaseModel): - """Gemini API configuration""" + """Gemini API configuration, including session behavior and generation options.""" clients: list[GeminiClientSettings] = Field( ..., description="List of Gemini client credential pairs" ) - models: list[GeminiModelConfig] = Field(default=[], description="List of custom Gemini models") - model_strategy: Literal["append", "overwrite"] = Field( - default="append", - description="Strategy for loading models: 'append' merges custom with default, 'overwrite' uses only custom", - ) - timeout: int = Field(default=600, ge=30, description="Init timeout in seconds") - watchdog_timeout: int = Field(default=300, ge=30, description="Watchdog timeout in seconds") - auto_refresh: bool = Field(True, description="Enable auto-refresh for Gemini cookies") + timeout: int = Field(default=450, ge=30, description="Init timeout in seconds") + watchdog_timeout: int = Field(default=120, ge=30, description="Watchdog timeout in seconds") + auto_refresh: bool = Field(True, description="Enable auto-refresh for Gemini sessions") refresh_interval: int = Field( default=600, ge=60, - description="Interval in seconds to refresh Gemini cookies (Not less than 60s)", + description="Interval in seconds to refresh Gemini sessions (Not less than 60s)", + ) + auto_close: bool = Field( + default=True, description="Enable auto-close for Gemini sessions after inactivity" + ) + close_delay: int = Field( + default=900, ge=30, description="Inactivity delay in seconds before auto-closing" + ) + verbose: bool = Field(True, description="Enable verbose logging for Gemini API requests") + extended_thinking: bool = Field( + default=False, + description="Enable Gemini extended thinking mode for message generation", ) - verbose: bool = Field(False, description="Enable verbose logging for Gemini API requests") max_chars_per_request: int = Field( default=1_000_000, ge=1, description="Maximum characters Gemini Web can accept per request", ) - oversized_context_strategy: OversizedContextStrategy = Field( - default=OversizedContextStrategy.COMPACTION, - description="Strategy for oversized context: 'compaction' summarizes older turns, 'file' sends oversized context as attachment", - ) chat_mode: ChatMode = Field( default=ChatMode.NORMAL, - description="Chat mode: 'normal' uses standard chats, 'temporary' uses Google's temporary mode (not saved to account) and enforces an effective input limit of 90% of max_chars_per_request", + description=( + "Chat mode: 'normal' uses standard chats; 'temporary' sends with Google's temporary " + "mode (not saved to the account) and applies a tighter effective input limit. " + "Warning: Google may close a temporary window at any time mid-conversation, and the " + "reply can then come back without the earlier context instead of erroring" + ), + ) + guest_mode: GuestMode = Field( + default=GuestMode.ADAPTIVE, + description=( + "Guest client health policy: 'strict' fails health checks when any client is " + "unhealthy; 'adaptive' fails only when all clients are unhealthy; 'permissive' " + "only logs a warning" + ), + ) + allow_private_url_fetch: bool = Field( + default=False, + description="Allow server-side fetching of private/loopback image URLs (SSRF risk; default blocks them)", + ) + url_fetch_timeout: int = Field( + default=15, + ge=1, + le=120, + description="Timeout in seconds for server-side URL image fetches", ) - - @field_validator("models", mode="before") - @classmethod - def _parse_models_json(cls, v: Any) -> Any: - if isinstance(v, str) and v.strip().startswith("["): - try: - return orjson.loads(v) - except orjson.JSONDecodeError as e: - logger.warning(f"Failed to parse models JSON string: {e}") - return v - return v - - @field_validator("models") - @classmethod - def _filter_valid_models(cls, v: list[GeminiModelConfig]) -> list[GeminiModelConfig]: - """Filter out models that don't have all required fields set.""" - valid_models = [] - for model in v: - if model.model_name and model.model_header: - valid_models.append(model) - else: - missing = [] - if not model.model_name: - missing.append("model_name") - if not model.model_header: - missing.append("model_header") - logger.warning( - f"Discarding custom model due to missing {', '.join(missing)}: {model}" - ) - return valid_models class CORSConfig(BaseModel): @@ -174,9 +184,9 @@ class StorageConfig(BaseModel): default="data/lmdb", description="Path to the storage directory where data will be saved", ) - images_path: str = Field( - default="data/images", - description="Path to the directory where generated images will be stored", + media_path: str = Field( + default="data/media", + description="Path to the directory where generated media will be stored", ) max_size: int = Field( default=1024**2 * 256, # 256 MB @@ -251,10 +261,10 @@ def settings_customise_sources( ) -def extract_gemini_clients_env() -> dict[int, dict[str, str]]: +def extract_gemini_clients_env() -> dict[int, dict[str, Any]]: """Extract and remove all Gemini clients related environment variables, return a mapping from index to field dict.""" prefix = "CONFIG_GEMINI__CLIENTS__" - env_overrides: dict[int, dict[str, str]] = {} + env_overrides: dict[int, dict[str, Any]] = {} to_delete = [] for k, v in os.environ.items(): if k.startswith(prefix): @@ -267,7 +277,7 @@ def extract_gemini_clients_env() -> dict[int, dict[str, str]]: idx = int(index_str) env_overrides.setdefault(idx, {})[field] = v to_delete.append(k) - # Remove these environment variables to avoid Pydantic parsing errors + for k in to_delete: del os.environ[k] return env_overrides @@ -275,11 +285,11 @@ def extract_gemini_clients_env() -> dict[int, dict[str, str]]: def _merge_clients_with_env( base_clients: list[GeminiClientSettings] | None, - env_overrides: dict[int, dict[str, str]], -): - """Override base_clients with env_overrides, return the new clients list.""" + env_overrides: dict[int, dict[str, Any]], +) -> list[GeminiClientSettings]: + """Return Gemini clients with environment overrides applied to the base list.""" if not env_overrides: - return base_clients + return base_clients or [] result_clients: list[GeminiClientSettings] = [] if base_clients: result_clients = [client.model_copy() for client in base_clients] @@ -297,94 +307,25 @@ def _merge_clients_with_env( f"Client index {idx} in env is out of range (current count: {len(result_clients)}). " "Client indices must be contiguous starting from 0." ) - return result_clients if result_clients else base_clients - - -def extract_gemini_models_env() -> dict[int, dict[str, Any]]: - """Extract and remove all Gemini models related environment variables, supporting nested fields.""" - root_key = "CONFIG_GEMINI__MODELS" - env_overrides: dict[int, dict[str, Any]] = {} - - if root_key in os.environ: - val = os.environ[root_key] - models_list = None - parsed_successfully = False - - try: - models_list = orjson.loads(val) - parsed_successfully = True - except orjson.JSONDecodeError: - try: - models_list = ast.literal_eval(val) - parsed_successfully = True - except (ValueError, SyntaxError) as e: - logger.warning(f"Failed to parse {root_key} as JSON or Python literal: {e}") - - if parsed_successfully and isinstance(models_list, list): - for idx, model_data in enumerate(models_list): - if isinstance(model_data, dict): - env_overrides[idx] = model_data - - # Remove the environment variable to avoid Pydantic parsing errors - del os.environ[root_key] - - return env_overrides - - -def _merge_models_with_env( - base_models: list[GeminiModelConfig] | None, - env_overrides: dict[int, dict[str, Any]], -): - """Override base_models with env_overrides using standard update (replace whole fields).""" - if not env_overrides: - return base_models or [] - result_models: list[GeminiModelConfig] = [] - if base_models: - result_models = [model.model_copy() for model in base_models] - - for idx in sorted(env_overrides): - overrides = env_overrides[idx] - if idx < len(result_models): - # Update existing model: overwrite fields found in env - model_dict = result_models[idx].model_dump() - model_dict.update(overrides) - result_models[idx] = GeminiModelConfig(**model_dict) - elif idx == len(result_models): - # Append new models - new_model = GeminiModelConfig(**overrides) - result_models.append(new_model) - else: - raise IndexError( - f"Model index {idx} in env is out of range (current count: {len(result_models)}). " - "Model indices must be contiguous starting from 0." - ) - return result_models + return result_clients or base_clients or [] def initialize_config() -> Config: """ - Initialize the configuration. + Initialize configuration from environment variables and the YAML settings source. Returns: - Config: Configuration object + Config: Configuration object with Gemini client overrides merged """ try: - # First, extract and remove Gemini clients related environment variables env_clients_overrides = extract_gemini_clients_env() - # Extract and remove Gemini models related environment variables - env_models_overrides = extract_gemini_models_env() - - # Then, initialize Config with pydantic_settings - config = Config() # type: ignore + settings_cls: type[Any] = Config + config = cast(Config, settings_cls()) - # Synthesize clients config.gemini.clients = _merge_clients_with_env( config.gemini.clients, env_clients_overrides ) - # Synthesize models - config.gemini.models = _merge_models_with_env(config.gemini.models, env_models_overrides) - return config except ValidationError as e: logger.error(f"Configuration validation failed: {e!s}") diff --git a/app/utils/helper.py b/app/utils/helper.py index 187f310..096b5be 100644 --- a/app/utils/helper.py +++ b/app/utils/helper.py @@ -1,29 +1,57 @@ import base64 import hashlib import html +import ipaddress import mimetypes import re import reprlib +import socket import struct import tempfile +import time import unicodedata +from collections.abc import Sequence +from contextvars import ContextVar from pathlib import Path +from typing import Any, Literal from urllib.parse import urlparse import orjson -from curl_cffi.requests import AsyncSession +import regex +from curl_cffi import CurlFollow, CurlHttpVersion, requests +from jsonschema import SchemaError, ValidationError, validators +from jsonschema.validators import validator_for from loguru import logger +from pydantic import BaseModel + +from app.models import ( + AppContentItem, + AppMessage, + AppMessageRole, + AppToolCall, + AppToolCallFunction, + ChatCompletionMessage, + ChatCompletionNamedToolChoice, + ImageGeneration, + StructuredOutputRequirement, + ToolChoiceFunction, + ToolChoiceTypes, +) +from app.utils import g_config + +MAX_REMOTE_FETCH_BYTES = 20 * 1024 * 1024 -from app.models import FunctionCall, Message, ToolCall +type JsonValue = bool | int | float | str | list[JsonValue] | dict[str, JsonValue] | None VALID_TAG_ROLES = {"user", "assistant", "system", "tool"} TOOL_WRAP_HINT = ( - "\n\n### SYSTEM: TOOL CALLING PROTOCOL (MANDATORY) ###\n" - "If tool execution is required, you MUST adhere to this EXACT protocol. No exceptions.\n\n" - "1. OUTPUT RESTRICTION: Your response MUST contain ONLY the [ToolCalls] block. Conversational filler, preambles, or concluding remarks are STRICTLY PROHIBITED.\n" - "2. WRAPPING LOGIC: Every parameter value MUST be enclosed in a markdown code block. Use 3 backticks (```) by default. If the value contains backticks, the outer fence MUST be longer than any sequence inside (e.g., ````).\n" - "3. TAG SYMMETRY: All tags MUST be balanced and closed in the exact reverse order of opening. Incomplete or unclosed blocks are strictly prohibited.\n\n" - "REQUIRED SYNTAX:\n" + "\n\nSYSTEM: TOOL CALLING PROTOCOL (MANDATORY)\n" + "Either emit the tool-call block alone, or answer in natural language with no protocol tags. Never both.\n\n" + "1. Names MUST match the schemas exactly; every required parameter MUST be present with its declared JSON type.\n" + "2. Each value MUST stand alone between two fences of 3 backticks; if it contains a backtick run, both fences MUST be longer.\n" + "3. Every opening tag MUST be closed in reverse order of opening. A fence closes only itself, never a tag. An unclosed tag voids the call.\n" + "4. Emit the block and nothing else. No preamble or commentary.\n\n" + "REQUIRED SYNTAX, reproduce literally:\n" "[ToolCalls]\n" "[Call:tool_name]\n" "[CallParameter:parameter_name]\n" @@ -33,89 +61,164 @@ "[/CallParameter]\n" "[/Call]\n" "[/ToolCalls]\n\n" - "CRITICAL: Do NOT mix natural language with protocol tags. Either respond naturally OR provide the protocol block alone. There is no middle ground.\n" + "END TOOL CALLING PROTOCOL" +) +STRUCTURED_JSON_WRAP_HINT = ( + "\n\nSYSTEM: STRUCTURED JSON PROTOCOL (MANDATORY)\n" + "1. Return exactly one fenced block holding one strict JSON document. No prose, no second block.\n" + "2. Open with ```json and close with a fence of the same length; if the JSON contains a backtick run, both fences MUST be longer.\n" + "3. NEVER truncate the document or omit the closing fence.\n\n" + "REQUIRED SYNTAX:\n" + "```json\n" + '{"field":"value"}\n' + "```\n\n" + "END STRUCTURED JSON PROTOCOL" +) +# Appended to the protocol above when the client supplied a schema; JSON mode sends the +# protocol alone, because valid JSON of any shape satisfies it. +SCHEMA_ADHERENCE_PROMPT = ( + "The JSON document MUST validate against the JSON Schema below. " + "Emit every required field with its declared type." +) +STRICT_SCHEMA_ADHERENCE_PROMPT = ( + "Strict schema adherence is required: the JSON must conform exactly to the schema." +) +TOOL_INTERFACE_PROMPT = ( + "SYSTEM INTERFACE: Call an available tool whenever the request requires one, with arguments that " + "validate against its JSON Schema. Never invent an undeclared tool or parameter." +) +TOOL_DESCRIPTION_PROMPT = "Tool `{name}`: {description}" +TOOL_ARGUMENTS_SCHEMA_PROMPT = "Parameters JSON Schema:" +TOOL_EMPTY_ARGUMENTS_SCHEMA_PROMPT = "Parameters JSON Schema: {} (takes no parameters)" +TOOL_CHOICE_NONE_PROMPT = ( + "TOOL CHOICE = none: You MUST NOT call a tool or emit any protocol tag this turn. " + "Answer in natural language." +) +TOOL_CHOICE_REQUIRED_PROMPT = ( + "TOOL CHOICE = required: You MUST call at least one tool this turn; " + "a natural-language answer alone is invalid." +) +TOOL_CHOICE_NAMED_PROMPT = ( + "TOOL CHOICE = `{target_name}`: You MUST call `{target_name}` this turn and no other tool." +) +IMAGE_GENERATION_PROMPT = "\n\n".join( + ( + "IMAGE PROTOCOL: Every image request MUST be answered with a generated image attachment.", + "A new request MUST produce a new image; an edit MUST return the edited image.", + "NEVER substitute text for the image: no explanation, apology, progress note, or placeholder.", + ) +) +IMAGE_GENERATION_FORCED_PROMPT = ( + "IMAGE REQUIRED: You MUST return at least one generated image; a text-only reply is a failure." ) TOOL_BLOCK_RE = re.compile( - r"\\?\[\s*ToolCalls\s*\\?]\s*(.*?)\s*\\?\[\s*\\?/\s*ToolCalls\s*\\?]", + r"\\?\[ToolCalls\\?](.*?)\\?\[\\?/ToolCalls\\?]", re.DOTALL | re.IGNORECASE, ) TOOL_CALL_RE = re.compile( - r"\\?\[\s*Call\s*\\?:\s*(?P(?:[^]\\]|\\.)+)\s*\\?]\s*(?P.*?)\s*\\?\[\s*\\?/\s*Call\s*\\?]", + r"\\?\[Call\\?:(?P[^]]+)\\?](?P.*?)\\?\[\\?/Call\\?]", re.DOTALL | re.IGNORECASE, ) RESPONSE_BLOCK_RE = re.compile( - r"\\?\[\s*ToolResults\s*\\?]\s*(.*?)\s*\\?\[\s*\\?/\s*ToolResults\s*\\?]", + r"\\?\[ToolResults\\?](.*?)\\?\[\\?/ToolResults\\?]", re.DOTALL | re.IGNORECASE, ) RESPONSE_ITEM_RE = re.compile( - r"\\?\[\s*Result\s*\\?:\s*(?P(?:[^]\\]|\\.)+)\s*\\?]\s*(?P.*?)\s*\\?\[\s*\\?/\s*Result\s*\\?]", + r"\\?\[Result\\?:(?P[^]]+)\\?](?P.*?)\\?\[\\?/Result\\?]", re.DOTALL | re.IGNORECASE, ) TAGGED_ARG_RE = re.compile( - r"\\?\[\s*CallParameter\s*\\?:\s*(?P(?:[^]\\]|\\.)+)\s*\\?]\s*(?P.*?)\s*\\?\[\s*\\?/\s*CallParameter\s*\\?]", + r"\\?\[CallParameter\\?:(?P[^]]+)\\?](?P.*?)\\?\[\\?/CallParameter\\?]", re.DOTALL | re.IGNORECASE, ) TAGGED_RESULT_RE = re.compile( - r"\\?\[\s*ToolResult\s*\\?]\s*(.*?)\s*\\?\[\s*\\?/\s*ToolResult\s*\\?]", + r"\\?\[ToolResult\\?](.*?)\\?\[\\?/ToolResult\\?]", re.DOTALL | re.IGNORECASE, ) -CONTROL_TOKEN_RE = re.compile( - r"\\?\s*<\s*\\?\|\s*im\s*\\?_(?:start|end)\s*\\?\|\s*>\s*", re.IGNORECASE -) -CHATML_START_RE = re.compile( - r"\\?\s*<\s*\\?\|\s*im\s*\\?_start\s*\\?\|\s*>\s*(\w+)\s*\n?", re.IGNORECASE -) -CHATML_END_RE = re.compile(r"\\?\s*<\s*\\?\|\s*im\s*\\?_end\s*\\?\|\s*>\s*", re.IGNORECASE) +CONTROL_TOKEN_RE = re.compile(r"\\?<\\?\|im\\?_(?:start|end)\\?\|\\?>", re.IGNORECASE) +CHATML_START_RE = re.compile(r"\\?<\\?\|im\\?_start\\?\|\\?>(\w+)\n?", re.IGNORECASE) +CHATML_END_RE = re.compile(r"\\?<\\?\|im\\?_end\\?\|\\?>", re.IGNORECASE) COMMONMARK_UNESCAPE_RE = re.compile(r"\\([!\"#$%&'()*+,\-./:;<=>?@\[\\\]^_`{|}~])") PARAM_FENCE_RE = re.compile(r"^(?P`{3,})") +MIME_SUBTYPE_UNSAFE_RE = re.compile(r"[^A-Za-z0-9._-]") TOOL_HINT_STRIPPED = TOOL_WRAP_HINT.strip() -_hint_lines = [line.strip() for line in TOOL_WRAP_HINT.split("\n") if line.strip()] -TOOL_HINT_LINE_START = _hint_lines[0] if _hint_lines else "" -TOOL_HINT_LINE_END = _hint_lines[-1] if _hint_lines else "" -TOOL_HINT_START_ESC = re.escape(TOOL_HINT_LINE_START) if TOOL_HINT_LINE_START else "" -TOOL_HINT_END_ESC = re.escape(TOOL_HINT_LINE_END) if TOOL_HINT_LINE_END else "" - -HINT_FULL_RE = ( - re.compile(rf"\n?{TOOL_HINT_START_ESC}:?.*?{TOOL_HINT_END_ESC}\n?", re.DOTALL | re.IGNORECASE) - if TOOL_HINT_START_ESC and TOOL_HINT_END_ESC - else None -) -HINT_START_RE = ( - re.compile(rf"\n?{TOOL_HINT_START_ESC}:?\s*", re.IGNORECASE) if TOOL_HINT_START_ESC else None -) -HINT_END_RE = ( - re.compile(rf"\s*{TOOL_HINT_END_ESC}\n?", re.IGNORECASE) if TOOL_HINT_END_ESC else None -) +SYSTEM_HINTS = (TOOL_WRAP_HINT, STRUCTURED_JSON_WRAP_HINT) + + +def _hint_anchors(hint: str) -> tuple[str, str]: + """Return a hint's first and last non-empty lines, used to locate echoed copies.""" + lines = [line.strip() for line in hint.split("\n") if line.strip()] + return (lines[0], lines[-1]) if lines else ("", "") + + +HINT_START_ANCHORS: list[str] = [] +HINT_END_ANCHORS: list[str] = [] +HINT_FULL_RES: list[re.Pattern[str]] = [] +HINT_START_RES: list[re.Pattern[str]] = [] +HINT_END_RES: list[re.Pattern[str]] = [] + +for _hint in SYSTEM_HINTS: + _start, _end = _hint_anchors(_hint) + if not _start or not _end: + continue + _start_esc, _end_esc = re.escape(_start), re.escape(_end) + HINT_START_ANCHORS.append(_start) + HINT_END_ANCHORS.append(_end) + HINT_FULL_RES.append( + re.compile(rf"\n?{_start_esc}:?.*?{_end_esc}\n?", re.DOTALL | re.IGNORECASE) + ) + HINT_START_RES.append(re.compile(rf"\n?{_start_esc}:?\s*", re.IGNORECASE)) + HINT_END_RES.append(re.compile(rf"\s*{_end_esc}\n?", re.IGNORECASE)) # --- Streaming Specific Patterns --- _START_PATTERNS = { - "TOOL": r"\\?\[\s*ToolCalls\s*\\?\]", - "ORPHAN": r"\\?\[\s*Call\s*\\?:\s*(?:[^\]\\]|\\.)+\s*\\?\]", - "RESP": r"\\?\[\s*ToolResults\s*\\?\]", - "ARG": r"\\?\[\s*CallParameter\s*\\?:\s*(?:[^\]\\]|\\.)+\s*\\?\]", - "RESULT": r"\\?\[\s*ToolResult\s*\\?\]", - "ITEM": r"\\?\[\s*Result\s*\\?:\s*(?:[^\]\\]|\\.)+\s*\\?\]", - "TAG": r"\\?\s*<\s*\\?\|\s*im\s*\\?_start\s*\\?\|\s*>", + "TOOL": r"\\?\[ToolCalls\\?]", + "ORPHAN": r"\\?\[Call\\?:[^]]+\\?]", + "RESP": r"\\?\[ToolResults\\?]", + "ARG": r"\\?\[CallParameter\\?:[^]]+\\?]", + "RESULT": r"\\?\[ToolResult\\?]", + "ITEM": r"\\?\[Result\\?:[^]]+\\?]", + "TAG": r"\\?<\\?\|im\\?_start\\?\|\\?>", } -_PROTOCOL_ENDS = ( - r"\\?\[\s*\\?/\s*(?:ToolCalls|Call|ToolResults|CallParameter|ToolResult|Result)\s*\\?\]" -) -_TAG_END = r"\\?\s*<\s*\\?\|\s*im\s*\\?_end\s*\\?\|\s*>" +_PROTOCOL_ENDS = r"\\?\[\\?/(?:ToolCalls|Call|ToolResults|CallParameter|ToolResult|Result)\\?]" +_TAG_END = r"\\?<\\?\|im\\?_end\\?\|\\?>" -if TOOL_HINT_START_ESC and TOOL_HINT_END_ESC: - _START_PATTERNS["HINT"] = rf"\n?{TOOL_HINT_START_ESC}:?\s*" +if HINT_START_ANCHORS and HINT_END_ANCHORS: + _starts = "|".join(re.escape(anchor) for anchor in HINT_START_ANCHORS) + _START_PATTERNS["HINT"] = rf"\n?(?:{_starts}):?\s*" _master_parts = [f"(?P<{name}_START>{pattern})" for name, pattern in _START_PATTERNS.items()] -_master_parts.append(f"(?P{_PROTOCOL_ENDS})") -_master_parts.append(f"(?P{_TAG_END})") - -if TOOL_HINT_START_ESC and TOOL_HINT_END_ESC: - _master_parts.append(f"(?P{TOOL_HINT_END_ESC}\n?)") +_master_parts.extend((f"(?P{_PROTOCOL_ENDS})", f"(?P{_TAG_END})")) +if HINT_START_ANCHORS and HINT_END_ANCHORS: + _ends = "|".join(re.escape(anchor) for anchor in HINT_END_ANCHORS) + _master_parts.append(f"(?P(?:{_ends})\n?)") STREAM_MASTER_RE = re.compile("|".join(_master_parts), re.IGNORECASE) -STREAM_TAIL_RE = re.compile( - r"(?:\\|\\?\[[TCRP/]?\s*[^]]*|\\?\s*<\s*\\?\|?\s*i?\s*m?\s*\\?_?(?:s?t?a?r?t?|e?n?d?)\s*\\?\|?\s*>?|)$", + +# Partial markers held back until the next chunk completes them. +_PARTIAL_MARKER = r"\\|\\?\[[^]]*|\\?<\\?\|?i?m?\\?_?(?:s?t?a?r?t?|e?n?d?)\\?\|?\\?>?" + +# Hint anchors are prose, so a chunk boundary inside one leaks the header. +# The line-start requirement spares ordinary words sharing a prefix. +_partial_anchors = sorted( + { + anchor[:length] + for anchor in (*HINT_START_ANCHORS, *HINT_END_ANCHORS) + for length in range(1, len(anchor)) + }, + key=len, + reverse=True, +) +if _partial_anchors: + _partial_anchor_alt = "|".join(re.escape(prefix) for prefix in _partial_anchors) + _PARTIAL_MARKER = rf"{_PARTIAL_MARKER}|(?:^|\n)(?:{_partial_anchor_alt})" + +STREAM_TAIL_RE = re.compile(rf"(?:{_PARTIAL_MARKER})$", re.IGNORECASE) + +# Flush discards what it matches, so it may only drop genuine protocol fragments. +STREAM_FLUSH_TAIL_RE = re.compile( + r"(?:\\|\\?\[[^]]*|\\?<\\?\|?i?m?\\?_?(?:s?t?a?r?t?|e?n?d?)\\?\|?\\?>?)$", re.IGNORECASE, ) @@ -126,7 +229,7 @@ def add_tag(role: str, content: str, unclose: bool = False) -> str: logger.warning(f"Unknown role: {role}, returning content without tags") return content - return f"<|im_start|>{role}\n{content}" + ("\n<|im_end|>" if not unclose else "") + return f"<|im_start|>{role}\n{content}" + ("" if unclose else "\n<|im_end|>") def normalize_llm_text(s: str) -> str: @@ -139,22 +242,20 @@ def normalize_llm_text(s: str) -> str: s = html.unescape(s) s = unicodedata.normalize("NFC", s) - s = s.replace("\r\n", "\n").replace("\r", "\n") - - return s + return s.replace("\r\n", "\n").replace("\r", "\n") def unescape_text(s: str) -> str: """Remove CommonMark backslash escapes from LLM-generated text.""" - if not s: - return "" - return COMMONMARK_UNESCAPE_RE.sub(r"\1", s) + return COMMONMARK_UNESCAPE_RE.sub(r"\1", s) if s else "" -def _strip_param_fences(s: str) -> str: +def strip_markdown_fence(s: str) -> str: """ - Remove one layer of outermost Markdown code fences, - supporting nested blocks by detecting variable fence lengths. + Remove one outer Markdown code fence layer for protected LLM payloads. + + The fence length is detected from the opening fence so tool parameters and + structured JSON can safely contain shorter backtick sequences inside. """ s = s.strip() if not s: @@ -172,49 +273,322 @@ def _strip_param_fences(s: str) -> str: return s[len(fence) : -len(fence)].strip() +def _parse_tool_argument_value(raw_value: str) -> JsonValue: + """ + Convert a tagged tool argument into the most specific JSON-compatible value. + + JSON literals, arrays, and objects are preserved so downstream clients receive + strict argument types, while plain text values remain strings for compatibility. + """ + value = strip_markdown_fence(raw_value) + if not value: + return "" + + try: + parsed_value: Any = orjson.loads(value) + except orjson.JSONDecodeError: + return value + + return parsed_value + + def estimate_tokens(text: str | None) -> int: """Estimate the number of tokens heuristically based on character count.""" - if not text: - return 0 - return int(len(text) / 3) + return len(text) // 3 if text else 0 + + +class StructuredOutputValidationError(ValueError): + """Raised when model output cannot satisfy a requested JSON Schema.""" + + +class SchemaEvaluationTimeoutError(ValueError): + """Raised when a client-provided schema exhausts its regex evaluation budget.""" + + +# One cumulative budget for every regex keyword in a response, so it is sized for total workload, +# not for a single pattern: a large conforming payload is ordinary, not pathological. +SCHEMA_REGEX_BUDGET_SECONDS: float = g_config.server.schema_validation_budget_seconds +_schema_regex_deadline: ContextVar[float | None] = ContextVar("schema_regex_deadline", default=None) +_bounded_validator_classes: dict[type[Any], type[Any]] = {} + + +def _bounded_regex_search(pattern: str, value: str) -> bool: + """Search with the remaining request-scoped regex budget.""" + deadline = _schema_regex_deadline.get() + remaining = SCHEMA_REGEX_BUDGET_SECONDS if deadline is None else deadline - time.monotonic() + if remaining <= 0: + raise SchemaEvaluationTimeoutError("JSON Schema regex evaluation exceeded its time limit") + try: + return regex.search(pattern, value, timeout=remaining) is not None + except TimeoutError as exc: + raise SchemaEvaluationTimeoutError( + "JSON Schema regex evaluation exceeded its time limit" + ) from exc + + +def _validate_bounded_pattern(validator, pattern, instance, schema): + if validator.is_type(instance, "string") and not _bounded_regex_search(pattern, instance): + yield ValidationError(f"{instance!r} does not match {pattern!r}") + + +def _validate_bounded_pattern_properties(validator, pattern_properties, instance, schema): + if not validator.is_type(instance, "object"): + return + for pattern, subschema in pattern_properties.items(): + for key, value in instance.items(): + if _bounded_regex_search(pattern, key): + yield from validator.descend( + value, + subschema, + path=key, + schema_path=pattern, + ) + + +def _validate_bounded_additional_properties(validator, additional, instance, schema): + if not validator.is_type(instance, "object"): + return + + properties = schema.get("properties", {}) + patterns = tuple(schema.get("patternProperties", {})) + extras = { + key + for key in instance + if key not in properties + and not any(_bounded_regex_search(pattern, key) for pattern in patterns) + } + if validator.is_type(additional, "object"): + for extra in extras: + yield from validator.descend(instance[extra], additional, path=extra) + elif not additional and extras: + joined = ", ".join(repr(each) for each in sorted(extras, key=str)) + yield ValidationError(f"Additional properties are not allowed ({joined} unexpected)") + + +def _bounded_validator_for(schema: dict[str, Any]): + """Return a dialect-appropriate validator with timeout-bounded regex keywords.""" + base = validator_for(schema) + bounded = _bounded_validator_classes.get(base) + if bounded is None: + bounded = validators.extend( + base, + { + "pattern": _validate_bounded_pattern, + "patternProperties": _validate_bounded_pattern_properties, + "additionalProperties": _validate_bounded_additional_properties, + }, + ) + _bounded_validator_classes[base] = bounded + return bounded(schema) + + +def validate_json_schema(schema: dict[str, Any]) -> None: + """Raise ValueError when a client-provided JSON Schema is not valid.""" + try: + validator_cls = validator_for(schema) + validator_cls.check_schema(schema) + except SchemaError as exc: + raise ValueError(f"Invalid JSON Schema: {exc.message}") from exc + + +_JSON_SCHEMA_TYPE_NAMES = frozenset( + {"string", "number", "integer", "boolean", "array", "object", "null"} +) +_NESTED_SCHEMA_KEYS = frozenset( + {"items", "additionalProperties", "not", "if", "then", "else", "contains", "propertyNames"} +) +_SCHEMA_LIST_KEYS = frozenset({"anyOf", "oneOf", "allOf", "prefixItems"}) +_SCHEMA_MAP_KEYS = frozenset({"properties", "$defs", "definitions", "patternProperties"}) +# OpenAPI-only annotations with no JSON Schema equivalent. +_OPENAPI_ONLY_KEYS = frozenset({"propertyOrdering", "example"}) + + +def normalize_openapi_schema(schema: Any) -> Any: + """Translate Gemini's OpenAPI 3.0 Schema subset into equivalent JSON Schema. + + `generationConfig.responseSchema` spells its types in uppercase (`STRING`, `OBJECT`) and + marks optional values with OpenAPI's `nullable` flag. Neither is valid JSON Schema, so the + schema has to be translated before it can be checked or used to validate a response. + `responseJsonSchema` is already JSON Schema and does not go through here. + """ + if isinstance(schema, list): + return [normalize_openapi_schema(item) for item in schema] + if not isinstance(schema, dict): + return schema + + result: dict[str, Any] = {} + for key, value in schema.items(): + if key in _OPENAPI_ONLY_KEYS: + continue + if key == "type" and isinstance(value, str) and value.lower() in _JSON_SCHEMA_TYPE_NAMES: + result[key] = value.lower() + elif key in _SCHEMA_MAP_KEYS and isinstance(value, dict): + result[key] = {name: normalize_openapi_schema(sub) for name, sub in value.items()} + elif key in _SCHEMA_LIST_KEYS and isinstance(value, list): + result[key] = [normalize_openapi_schema(sub) for sub in value] + elif key in _NESTED_SCHEMA_KEYS: + result[key] = normalize_openapi_schema(value) + else: + result[key] = value + + if result.pop("nullable", None) is True: + declared = result.get("type") + if isinstance(declared, str): + result["type"] = [declared, "null"] + elif isinstance(declared, list) and "null" not in declared: + result["type"] = [*declared, "null"] + return result + + +def decode_base64_data(value: str | bytes) -> bytes: + """Decode raw or data-URL Base64 strictly, ignoring transport whitespace. + + Both the standard and URL-safe alphabets are accepted, since clients that build a payload + with `base64.urlsafe_b64encode` send `-` and `_`. Validation stays on either way: a decode + that silently discarded stray characters would hand Gemini a corrupt file - which is also + why a non-ASCII character is an error rather than something to strip, since dropping it + could turn a corrupt payload into one that decodes cleanly to the wrong bytes. + """ + if isinstance(value, str): + try: + raw = value.encode("ascii") + except UnicodeEncodeError as exc: + raise ValueError("Base64 payload contains non-ASCII characters") from exc + else: + raw = value + if raw.startswith(b"data:"): + metadata, separator, raw = raw.partition(b",") + if not separator or b";base64" not in metadata.lower(): + raise ValueError("Data URL must contain a Base64 payload") + + payload = b"".join(raw.split()) + for altchars in (None, b"-_"): + try: + return base64.b64decode(payload, altchars=altchars, validate=True) + except ValueError: + continue + raise ValueError("Invalid Base64 payload") + + +def guess_extension_for_mime(mime_type: str | None) -> str: + """Best-effort filename extension for a MIME type, never empty. + + `mimetypes` only knows registered types, so unregistered but widely sent ones (`audio/mp3`, + `application/x-*`) fall back to the subtype. That subtype is client-controlled and ends up in + a `NamedTemporaryFile` suffix, so it is scrubbed of anything that could escape the directory. + """ + if not mime_type: + return ".bin" + + mime_type = mime_type.split(";")[0].strip() + if suffix := mimetypes.guess_extension(mime_type): + return suffix + + _, _, subtype = mime_type.partition("/") + subtype = MIME_SUBTYPE_UNSAFE_RE.sub("", subtype).lstrip(".") + return f".{subtype}" if subtype else ".bin" async def save_file_to_tempfile( - file_in_base64: str, file_name: str = "", tempdir: Path | None = None + file_in_base64: str | bytes, file_name: str = "", tempdir: Path | None = None ) -> Path: """Decode base64 file data and save to a temporary file.""" with tempfile.NamedTemporaryFile( delete=False, suffix=Path(file_name).suffix if file_name else ".bin", dir=tempdir ) as tmp: - tmp.write(base64.b64decode(file_in_base64)) - path = Path(tmp.name) - return path + tmp.write(decode_base64_data(file_in_base64)) + return Path(tmp.name) + + +def reject_unsafe_url(url: str) -> None: + """Reject remote URLs that could target internal/private networks (SSRF guard). + + Allows only http/https. When `gemini.allow_private_url_fetch` is false (default), + any resolved address that is loopback, RFC1918-private, link-local, reserved, + multicast or unspecified is refused. DNS-rebinding TOCTOU between resolve and + fetch is a known residual; the opt-out knob exists for localhost-served images. + """ + parsed = urlparse(url) + if parsed.scheme not in ("http", "https"): + raise ValueError(f"Unsupported URL scheme: {parsed.scheme!r} (only http/https allowed)") + + host = parsed.hostname + if not host: + raise ValueError("URL must include a hostname") + + if g_config.gemini.allow_private_url_fetch: + return + + try: + addrinfos = socket.getaddrinfo(host, None) + except socket.gaierror as e: + raise ValueError(f"Could not resolve host {host!r}: {e}") from e + + for addrinfo in addrinfos: + ip = ipaddress.ip_address(addrinfo[4][0]) + # `is_global` covers private, loopback, link-local, reserved, unspecified, + # documentation and shared-address ranges. Multicast is the exception: + # ipaddress reports it as global even though it is not a valid fetch target. + if not ip.is_global or ip.is_multicast: + raise ValueError( + f"Refusing to fetch private/reserved address {ip} for host {host!r} " + "(set gemini.allow_private_url_fetch=true to override)" + ) async def save_url_to_tempfile(url: str, tempdir: Path | None = None) -> Path: """Download content from a URL and save to a temporary file.""" - data: bytes | None = None - suffix: str | None = None - if url.startswith("data:image/"): + if url.startswith("data:"): metadata_part = url.split(",")[0] mime_type = metadata_part.split(":")[1].split(";")[0] - data = base64.b64decode(url.split(",")[1]) - suffix = mimetypes.guess_extension(mime_type) or f".{mime_type.split('/')[1]}" - else: - async with AsyncSession(impersonate="chrome", allow_redirects=True) as client: - resp = await client.get(url) - resp.raise_for_status() - data = resp.content - content_type = resp.headers.get("content-type") - if content_type: - suffix = mimetypes.guess_extension(content_type.split(";")[0].strip()) - if not suffix: - suffix = Path(urlparse(url).path).suffix or ".bin" - - with tempfile.NamedTemporaryFile(delete=False, suffix=suffix, dir=tempdir) as tmp: - tmp.write(data) - path = Path(tmp.name) - return path + data = decode_base64_data(url) + suffix = guess_extension_for_mime(mime_type) + with tempfile.NamedTemporaryFile(delete=False, suffix=suffix, dir=tempdir) as tmp: + tmp.write(data) + return Path(tmp.name) + + reject_unsafe_url(url) + url_suffix = Path(urlparse(url).path).suffix or ".bin" + downloaded = 0 + temp_path: Path | None = None + + try: + with tempfile.NamedTemporaryFile(delete=False, suffix=url_suffix, dir=tempdir) as tmp: + temp_path = Path(tmp.name) + + def receive_chunk(chunk: bytes) -> None: + nonlocal downloaded + downloaded += len(chunk) + if downloaded > MAX_REMOTE_FETCH_BYTES: + raise ValueError(f"Remote fetch exceeded {MAX_REMOTE_FETCH_BYTES} bytes: {url}") + tmp.write(chunk) + + async with requests.AsyncSession( + impersonate="chrome", + allow_redirects=CurlFollow.SAFE, + http_version=CurlHttpVersion.NONE, + timeout=g_config.gemini.url_fetch_timeout, + ) as client: + resp = await client.get(url, content_callback=receive_chunk) + resp.raise_for_status() + content_type = resp.headers.get("content-type") + + suffix = ( + mimetypes.guess_extension(content_type.split(";")[0].strip()) + if content_type + else None + ) + except Exception: + if temp_path is not None: + temp_path.unlink(missing_ok=True) + raise + + assert temp_path is not None + if suffix and suffix != temp_path.suffix: + final_path = temp_path.with_suffix(suffix) + temp_path.rename(final_path) + return final_path + return temp_path def strip_tagged_blocks(text: str) -> str: @@ -257,14 +631,12 @@ def strip_system_hints(text: str) -> str: t_unescaped = unescape_text(text) - cleaned = t_unescaped.replace(TOOL_WRAP_HINT, "").replace(TOOL_HINT_STRIPPED, "") + cleaned = t_unescaped + for hint in SYSTEM_HINTS: + cleaned = cleaned.replace(hint, "").replace(hint.strip(), "") - if HINT_FULL_RE: - cleaned = HINT_FULL_RE.sub("", cleaned) - if HINT_START_RE: - cleaned = HINT_START_RE.sub("", cleaned) - if HINT_END_RE: - cleaned = HINT_END_RE.sub("", cleaned) + for pattern in (*HINT_FULL_RES, *HINT_START_RES, *HINT_END_RES): + cleaned = pattern.sub("", cleaned) cleaned = strip_tagged_blocks(cleaned) cleaned = CONTROL_TOKEN_RE.sub("", cleaned) @@ -273,20 +645,18 @@ def strip_system_hints(text: str) -> str: cleaned = RESPONSE_BLOCK_RE.sub("", cleaned) cleaned = RESPONSE_ITEM_RE.sub("", cleaned) cleaned = TAGGED_ARG_RE.sub("", cleaned) - cleaned = TAGGED_RESULT_RE.sub("", cleaned) + return TAGGED_RESULT_RE.sub("", cleaned) - return cleaned - -def _process_tools_internal(text: str, extract: bool = True) -> tuple[str, list[ToolCall]]: +def _process_tools_internal(text: str, extract: bool = True) -> tuple[str, list[AppToolCall]]: """ Extract tool metadata and return text stripped of technical markers. - Arguments are parsed into JSON and assigned deterministic call IDs. + Tagged arguments preserve JSON-compatible types and receive deterministic call IDs. """ if not text: return text, [] - tool_calls: list[ToolCall] = [] + tool_calls: list[AppToolCall] = [] def _create_tool_call(name: str, raw_args: str) -> None: if not extract: @@ -298,33 +668,31 @@ def _create_tool_call(name: str, raw_args: str) -> None: name = unescape_text(name.strip()) raw_args = unescape_text(raw_args) + # Leftovers mean the call was cut short: drop it rather than emit partial arguments. + residue = TAGGED_ARG_RE.sub("", raw_args).strip() + if residue: + logger.warning( + f"Dropping malformed tool call '{name}'. Unparsed content: {reprlib.repr(residue)}" + ) + return + arg_matches = TAGGED_ARG_RE.findall(raw_args) - if arg_matches: - args_dict = { - arg_name.strip(): _strip_param_fences(arg_value) - for arg_name, arg_value in arg_matches - } - arguments = orjson.dumps(args_dict).decode("utf-8") - logger.debug(f"Successfully parsed {len(args_dict)} arguments for tool: {name}") - else: - cleaned_raw = raw_args.strip() - if not cleaned_raw: - logger.debug(f"Successfully parsed 0 arguments for tool: {name}") - else: - logger.warning( - f"Malformed arguments for tool '{name}'. Text found but no valid tags: {reprlib.repr(cleaned_raw)}" - ) - arguments = "{}" + args_dict = { + arg_name.strip(): _parse_tool_argument_value(arg_value) + for arg_name, arg_value in arg_matches + } + arguments = orjson.dumps(args_dict).decode("utf-8") + logger.debug(f"Successfully parsed {len(args_dict)} arguments for tool: {name}") index = len(tool_calls) seed = f"{name}:{arguments}:{index}".encode() call_id = f"call_{hashlib.sha256(seed).hexdigest()[:24]}" tool_calls.append( - ToolCall( + AppToolCall( id=call_id, type="function", - function=FunctionCall(name=name, arguments=arguments), + function=AppToolCallFunction(name=name, arguments=arguments), ) ) @@ -341,12 +709,12 @@ def remove_tool_call_blocks(text: str) -> str: return cleaned -def extract_tool_calls(text: str) -> tuple[str, list[ToolCall]]: +def extract_tool_calls(text: str) -> tuple[str, list[AppToolCall]]: """Extract tool calls and return cleaned text.""" return _process_tools_internal(text, extract=True) -def text_from_message(message: Message) -> str: +def text_from_message(message: AppMessage) -> str: """Concatenate text and tool arguments from a message for token estimation.""" base_text = "" if isinstance(message.content, str): @@ -374,7 +742,7 @@ def extract_image_dimensions(data: bytes) -> tuple[int | None, int | None]: except struct.error: return None, None - if len(data) >= 4 and data[0:2] == b"\xff\xd8": + if len(data) >= 4 and data[:2] == b"\xff\xd8": idx = 2 length = len(data) sof_markers = {0xC0, 0xC1, 0xC2, 0xC3, 0xC5, 0xC6, 0xC7, 0xC9, 0xCA, 0xCB, 0xCD, 0xCE, 0xCF} @@ -414,6 +782,374 @@ def detect_image_extension(data: bytes) -> str | None: return ".jpg" if data.startswith(b"GIF8"): return ".gif" - if data.startswith(b"RIFF") and data[8:12] == b"WEBP": - return ".webp" + return ".webp" if data.startswith(b"RIFF") and data[8:12] == b"WEBP" else None + + +def dump_model(model: BaseModel) -> dict[str, Any]: + """Serialize a Pydantic model into a JSON-compatible dict with None values excluded.""" + return model.model_dump(mode="json", exclude_none=True) + + +def serialize_tools_for_response(tools: Sequence[Any] | None) -> list[dict[str, Any]]: + """Serialize tool objects into clean dictionary representations without None values.""" + if not tools: + return [] + result: list[dict[str, Any]] = [] + for t in tools: + if hasattr(t, "model_dump"): + result.append(t.model_dump(exclude_none=True)) + elif hasattr(t, "dict"): + result.append(t.dict(exclude_none=True)) + elif isinstance(t, dict): + result.append({k: v for k, v in t.items() if v is not None}) + else: + result.append(t) + return result + + +def serialize_tool_choice_for_response(tool_choice: Any) -> Any: + """Serialize tool choice object into a clean dictionary or string representation.""" + if tool_choice is None: + return "auto" + if hasattr(tool_choice, "model_dump"): + return tool_choice.model_dump(exclude_none=True) + if hasattr(tool_choice, "dict"): + return tool_choice.dict(exclude_none=True) + return tool_choice + + +def calculate_usage( + messages: list[AppMessage], + assistant_text: str | None, + tool_calls: list[AppToolCall] | None, + thoughts: str | None = None, +) -> tuple[int, int, int, int]: + """Calculate prompt, completion, total and reasoning tokens consistently.""" + prompt_tokens = sum(estimate_tokens(text_from_message(msg)) for msg in messages) + tool_args_text = "" + if tool_calls: + for call in tool_calls: + tool_args_text += call.function.arguments or "" + + completion_basis = assistant_text or "" + if tool_args_text: + completion_basis = ( + f"{completion_basis}\n{tool_args_text}" if completion_basis else tool_args_text + ) + + completion_tokens = estimate_tokens(completion_basis) + reasoning_tokens = estimate_tokens(thoughts) if thoughts else 0 + total_completion_tokens = completion_tokens + reasoning_tokens + + return ( + prompt_tokens, + total_completion_tokens, + prompt_tokens + total_completion_tokens, + reasoning_tokens, + ) + + +def normalize_app_message_role(role_name: str) -> AppMessageRole: + """Normalize and validate input role string to a valid AppMessage role.""" + roles: dict[str, AppMessageRole] = { + "developer": "system", + "function": "tool", + "user": "user", + "assistant": "assistant", + "tool": "tool", + "system": "system", + } + return roles.get(role_name, "system") + + +def convert_to_app_messages(messages: list[ChatCompletionMessage]) -> list[AppMessage]: + """Convert OpenAI ChatCompletionMessage list into AppMessage format.""" + app_messages: list[AppMessage] = [] + for msg in messages: + app_content: str | list[AppContentItem] | None = None + if isinstance(msg.content, str): + app_content = msg.content + elif isinstance(msg.content, list): + app_content = [] + for item in msg.content: + if item.type == "text": + app_content.append(AppContentItem(type="text", text=item.text)) + elif item.type == "image_url": + media_dict = getattr(item, "image_url", None) + url = media_dict.get("url") if media_dict else None + app_content.append(AppContentItem(type="image_url", url=url)) + elif item.type == "file": + file_dict = getattr(item, "file", None) + filename = file_dict.get("filename") if file_dict else None + file_data = file_dict.get("file_data") if file_dict else None + app_content.append( + AppContentItem(type="file", filename=filename, file_data=file_data) + ) + elif item.type == "input_audio": + audio_dict = getattr(item, "input_audio", None) + audio_data = audio_dict.get("data") if audio_dict else None + app_content.append( + AppContentItem( + type="input_audio", + file_data=audio_data, + raw_data=audio_dict, + ) + ) + elif item.type in ("refusal", "reasoning"): + text_val = getattr(item, "text", None) or getattr(item, item.type, None) + app_content.append(AppContentItem(type=item.type, text=text_val)) + + tool_calls = None + if msg.tool_calls: + tool_calls = [ + AppToolCall( + id=tc.id, + type="function", + function=AppToolCallFunction( + name=tc.function.name, + arguments=tc.function.arguments, + ), + ) + for tc in msg.tool_calls + ] + + role = normalize_app_message_role(msg.role) + + app_messages.append( + AppMessage( + role=role, + content=app_content, + tool_calls=tool_calls, + tool_call_id=msg.tool_call_id, + name=msg.name, + reasoning_content=getattr(msg, "reasoning_content", None), + ) + ) + return app_messages + + +def canonicalize_structured_output( + visible_output: str, structured_requirement: StructuredOutputRequirement +) -> str | None: + """Parse raw or fenced structured JSON and return its canonical JSON representation. + + `None` means the model failed the format, never that this wrapper could not run the check: + a schema that cannot be evaluated still yields the canonical payload, so only the model's + own failures can be enforced against it. + """ + candidate = strip_markdown_fence(visible_output) + try: + structured_payload = orjson.loads(candidate) + except orjson.JSONDecodeError: + logger.warning( + f"Failed to decode JSON for structured response (schema={structured_requirement.schema_name})." + ) + return None + + # An empty schema is JSON mode: parsing was the whole requirement. + if structured_requirement.schema: + try: + deadline_token = _schema_regex_deadline.set( + time.monotonic() + SCHEMA_REGEX_BUDGET_SECONDS + ) + try: + _bounded_validator_for(structured_requirement.schema).validate(structured_payload) + finally: + _schema_regex_deadline.reset(deadline_token) + except ValidationError as exc: + logger.warning( + f"Structured response failed schema validation " + f"(schema={structured_requirement.schema_name}): {exc.message}" + ) + return None + # Both branches below are this wrapper failing to check, not the model failing to comply, + # so neither reports a violation: under `strict` that would 502 a conforming reply. + except SchemaEvaluationTimeoutError as exc: + logger.warning( + f"Structured response left unverified, schema evaluation timed out " + f"(schema={structured_requirement.schema_name}): {exc}" + ) + except Exception as exc: + # `check_schema` does not resolve `$ref`s, so unresolvable references and foreign + # dialects surface only here. + logger.warning( + f"Structured response left unverified, schema is not usable " + f"({structured_requirement.schema_name!r}): {exc}" + ) + + canonical_output = orjson.dumps(structured_payload).decode("utf-8") + logger.debug(f"Structured response fulfilled (schema={structured_requirement.schema_name}).") + return canonical_output + + +def process_llm_output( + thoughts: str | None, + raw_text: str, + structured_requirement: StructuredOutputRequirement | None, +) -> tuple[str | None, str, str, list[AppToolCall]]: + """ + Post-process Gemini output to extract tool calls, unwrap structured JSON fences, and prepare clean text for display and storage. + Returns: (thoughts, visible_text, storage_output, tool_calls) + """ + if thoughts: + thoughts = thoughts.strip() + + visible_output, tool_calls = extract_tool_calls(raw_text) + if tool_calls: + logger.debug(f"Detected {len(tool_calls)} tool call(s) in model output.") + + visible_output = visible_output.strip() + storage_output = visible_output + + if structured_requirement and visible_output: + canonical_output = canonicalize_structured_output(visible_output, structured_requirement) + if canonical_output is not None: + visible_output = canonical_output + storage_output = canonical_output + elif tool_calls: + # The format constrains the final answer, not a turn that asks for a tool. + logger.debug( + "Skipping structured-output enforcement for a turn that returned tool call(s)." + ) + elif structured_requirement.strict: + raise StructuredOutputValidationError( + f"Model output did not satisfy JSON Schema {structured_requirement.schema_name!r}" + ) + else: + logger.warning( + f"Returning unstructured text for best-effort response format " + f"{structured_requirement.schema_name!r}." + ) + + return thoughts, visible_output, storage_output, tool_calls + + +def extract_tool_info(tool: Any) -> tuple[str, str, dict[str, Any] | None]: + """Extract (name, description, parameters) from any tool representation.""" + if hasattr(tool, "function") and tool.function is not None: + fn = tool.function + if isinstance(fn, dict): + name = fn.get("name", "") + description = fn.get("description") or "No description provided." + parameters = fn.get("parameters") + else: + name = getattr(fn, "name", "") + description = getattr(fn, "description", None) or "No description provided." + parameters = getattr(fn, "parameters", None) + return name, description, parameters + + if isinstance(tool, dict): + if "function" in tool and isinstance(tool["function"], dict): + fn = tool["function"] + return ( + fn.get("name", ""), + fn.get("description") or "No description provided.", + fn.get("parameters"), + ) + return ( + tool.get("name", ""), + tool.get("description") or "No description provided.", + tool.get("parameters"), + ) + + name = getattr(tool, "name", "") + description = getattr(tool, "description", None) or "No description provided." + parameters = getattr(tool, "parameters", None) + return name, description, parameters + + +def extract_named_tool_choice(tool_choice: Any) -> str | None: + """Extract target function name from any named tool choice representation.""" + if isinstance(tool_choice, ChatCompletionNamedToolChoice): + return tool_choice.function.name + if isinstance(tool_choice, ToolChoiceFunction): + return tool_choice.name + if isinstance(tool_choice, dict): + if "function" in tool_choice and isinstance(tool_choice["function"], dict): + return tool_choice["function"].get("name") + return tool_choice.get("name") return None + + +def build_tool_prompt( + tools: Sequence[Any], + tool_choice: ( + Literal["none", "auto", "required"] + | ChatCompletionNamedToolChoice + | ToolChoiceFunction + | ToolChoiceTypes + | None + ), +) -> str: + """Generate a system prompt describing available tools and the PascalCase protocol.""" + if not tools: + return "" + + lines: list[str] = [TOOL_INTERFACE_PROMPT] + + for tool in tools: + name, description, parameters = extract_tool_info(tool) + if not name: + continue + lines.append(TOOL_DESCRIPTION_PROMPT.format(name=name, description=description)) + if parameters: + schema_text = orjson.dumps(parameters, option=orjson.OPT_SORT_KEYS).decode("utf-8") + lines.extend((TOOL_ARGUMENTS_SCHEMA_PROMPT, schema_text)) + else: + lines.append(TOOL_EMPTY_ARGUMENTS_SCHEMA_PROMPT) + + if tool_choice == "none": + lines.append(TOOL_CHOICE_NONE_PROMPT) + elif tool_choice == "required": + lines.append(TOOL_CHOICE_REQUIRED_PROMPT) + elif (target_name := extract_named_tool_choice(tool_choice)) is not None: + lines.append(TOOL_CHOICE_NAMED_PROMPT.format(target_name=target_name)) + + lines.append(TOOL_WRAP_HINT) + + return "\n".join(lines) + + +def build_image_generation_instruction( + tools: list[ImageGeneration] | None, + tool_choice: ToolChoiceTypes | None, +) -> str | None: + """Construct explicit guidance so Gemini emits images when requested.""" + has_forced_choice = tool_choice is not None and tool_choice.type == "image_generation" + primary = tools[0] if tools else None + + if not has_forced_choice and primary is None: + return None + + instructions = [IMAGE_GENERATION_PROMPT] + + if has_forced_choice: + instructions.append(IMAGE_GENERATION_FORCED_PROMPT) + + return "\n\n".join(instructions) + + +def append_tool_hint_to_last_user_message(messages: list[AppMessage]) -> None: + """Ensure the last user message carries the tool wrap hint.""" + for msg in reversed(messages): + if msg.role != "user" or msg.content is None: + continue + + if isinstance(msg.content, str): + if TOOL_HINT_STRIPPED not in msg.content: + msg.content = f"{msg.content}\n{TOOL_WRAP_HINT}" + return + + if isinstance(msg.content, list): + for part in reversed(msg.content): + if getattr(part, "type", None) != "text": + continue + text_value = getattr(part, "text", "") or "" + if TOOL_HINT_STRIPPED in text_value: + return + part.text = f"{text_value}\n{TOOL_WRAP_HINT}" + return + + messages_text = TOOL_WRAP_HINT.strip() + msg.content.append(AppContentItem(type="text", text=messages_text)) + return diff --git a/app/utils/logging.py b/app/utils/logging.py index 87fcc7f..79b2727 100644 --- a/app/utils/logging.py +++ b/app/utils/logging.py @@ -57,7 +57,7 @@ def emit(self, record: logging.LogRecord) -> None: filename = frame.f_code.co_filename is_logging = filename == logging.__file__ is_frozen = "importlib" in filename and "_bootstrap" in filename - if depth > 0 and not (is_logging or is_frozen): + if depth > 0 and not is_logging and not is_frozen: break frame = frame.f_back depth += 1 @@ -65,4 +65,4 @@ def emit(self, record: logging.LogRecord) -> None: logger.opt(depth=depth, exception=record.exc_info).log(level, record.getMessage()) # Remove all existing handlers and add our interceptor - logging.basicConfig(handlers=[InterceptHandler()], level="INFO", force=True) + logging.basicConfig(handlers=[InterceptHandler()], level="DEBUG", force=True) diff --git a/config/config.yaml b/config/config.yaml index 462167f..a135ca4 100644 --- a/config/config.yaml +++ b/config/config.yaml @@ -1,16 +1,21 @@ # Gemini FastAPI Configuration File server: - host: "0.0.0.0" # Server bind address - port: 8000 # Server port - api_key: null # API key for authentication (null for no auth) + host: "0.0.0.0" # Server bind address + port: 8000 # Server port + api_key: null # API key for authentication (null for no auth) + # Local transport safety ceiling only; Gemini Web remains authoritative for its actual input limit. + max_request_body_bytes: 268435456 # 256 MiB; set to 0 to disable the wrapper-side safeguard + # Regex budget for validating one response against a client-supplied JSON Schema. + # Exhausting it leaves the reply unverified rather than failing it. + schema_validation_budget_seconds: 1.0 https: - enabled: false # Enable HTTPS - key_file: "certs/privkey.pem" # SSL private key file path - cert_file: "certs/fullchain.pem" # SSL certificate file path + enabled: false # Enable HTTPS + key_file: "certs/privkey.pem" # SSL private key file path + cert_file: "certs/fullchain.pem" # SSL certificate file path cors: - enabled: true # Enable CORS + enabled: true # Enable CORS allow_origins: ["*"] allow_credentials: true allow_methods: ["*"] @@ -18,26 +23,34 @@ cors: gemini: clients: - - id: "example-id-1" # Arbitrary client ID - secure_1psid: "YOUR_SECURE_1PSID_HERE" - secure_1psidts: "YOUR_SECURE_1PSIDTS_HERE" - proxy: null # Optional proxy URL (null/empty means direct connection) - timeout: 600 # Init timeout in seconds (Not less than 30s) - watchdog_timeout: 300 # Watchdog timeout in seconds (Not less than 30s) - auto_refresh: true # Auto-refresh session cookies - refresh_interval: 600 # Refresh interval in seconds (Not less than 60s) - verbose: false # Enable verbose logging for Gemini requests - max_chars_per_request: 1000000 # Maximum characters Gemini Web accepts per request. Non-pro users might have a lower limit - oversized_context_strategy: "compaction" # "compaction" summarizes older turns when oversized; "file" sends oversized context as attached file - chat_mode: "normal" # "normal" reuses Google chat metadata; "temporary" sends with Google's temporary mode (not saved to account) and uses a 90% effective input limit - model_strategy: "append" # Strategy: 'append' (default + custom) or 'overwrite' (custom only) - models: [] + - id: "client-id-1" # Arbitrary client ID + secure_1psid: "YOUR_SECURE_1PSID_HERE" # Gemini Secure 1PSID + secure_1psidts: "YOUR_SECURE_1PSIDTS_HERE" # Gemini Secure 1PSIDTS + proxy: null # Optional proxy URL (null/empty means direct connection) + impersonate: null # Optional browser impersonation target (null uses library default) + timeout: 450 # Init timeout in seconds (Not less than 30s) + watchdog_timeout: 120 # Watchdog timeout in seconds (Not less than 30s) + auto_refresh: true # Auto-refresh session cookies + refresh_interval: 600 # Refresh interval in seconds (Not less than 60s) + auto_close: false # Automatically close Gemini session after inactivity + close_delay: 900 # Inactivity delay in seconds before auto-closing (Not less than 30s) + verbose: true # Enable verbose logging for Gemini requests + extended_thinking: false # Enable Gemini extended thinking mode for message generation + max_chars_per_request: 1000000 # Maximum characters Gemini Web accepts per request. Non-pro users might have a lower limit + allow_private_url_fetch: false # Allow server-side fetching of private/loopback image URLs (SSRF risk; default blocks them) + url_fetch_timeout: 15 # Timeout in seconds for server-side URL image fetches + # "normal" uses standard Google chats; "temporary" uses Google's temporary mode (not saved to the account) with a tighter input limit. + # WARNING: Google may close a temporary window at any time mid-conversation. The reply can then come back without the earlier + # context instead of erroring, so the loss may be silent. Prefer "normal" for long or context-sensitive conversations. + chat_mode: "normal" + # Guest client health policy: "strict" fails if any client is unhealthy; "adaptive" fails only if all are unhealthy; "permissive" only logs a warning. + guest_mode: "adaptive" storage: - path: "data/lmdb" # Database storage path - images_path: "data/images" # Image storage path - max_size: 268435456 # Maximum database size (256 MB) - retention_days: 14 # Number of days to retain conversations before cleanup + path: "data/lmdb" # Database storage path + media_path: "data/media" # Media storage path + max_size: 268435456 # Maximum database size (256 MB) + retention_days: 14 # Number of days to retain conversations before cleanup logging: - level: "INFO" # Log level: DEBUG, INFO, WARNING, ERROR + level: "DEBUG" # Log level: DEBUG, INFO, WARNING, ERROR diff --git a/pyproject.toml b/pyproject.toml index 90a2d5c..946a6d9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -3,33 +3,30 @@ name = "gemini-fastapi" version = "1.0.0" description = "FastAPI Server built on Gemini Web API" readme = "README.md" -requires-python = "==3.13.*" +requires-python = ">=3.13" dependencies = [ - "curl-cffi>=0.14.0", - "fastapi>=0.135.0", - "gemini-webapi>=1.19.2", - "httptools>=0.7.1", - "lmdb>=1.7.5", - "loguru>=0.7.3", - "orjson>=3.11.7", - "pydantic-settings[yaml]>=2.13.1", - "uvicorn>=0.41.0", - "uvloop>=0.22.1; sys_platform != 'win32'", + "curl-cffi>=0.16.0", + "fastapi>=0.141.1", + "gemini-webapi>=2.1.0,<3", + "httptools>=0.8.0", + "jsonschema>=4.26.0", + "lmdb>=2.3.0", + "loguru>=0.7.3", + "orjson>=3.11.9", + "pydantic-settings[yaml]>=2.15.0", + "regex>=2026.7.19", + "uvicorn>=0.52.3", + "uvloop>=0.22.1; sys_platform != 'win32'", ] [project.urls] Repository = "https://github.com/Nativu5/Gemini-FastAPI" [project.optional-dependencies] -dev = [ - "pytest>=9.0.2", - "ruff>=0.15.4", -] +dev = ["httpx2", "pyright", "pytest", "ruff", "ty"] [dependency-groups] -dev = [ - "gemini-fastapi[dev]", -] +dev = ["gemini-fastapi[dev]"] [tool.ruff] line-length = 100 @@ -37,28 +34,41 @@ target-version = "py313" [tool.ruff.lint] select = [ - "E", # pycodestyle errors - "F", # pyflakes - "W", # pycodestyle warnings - "I", # isort - "UP", # pyupgrade - "B", # flake8-bugbear - "C4", # flake8-comprehensions - "SIM", # flake8-simplify - "RUF", # ruff-specific rules - "TID", # flake8-tidy-imports + "B", # flake8-bugbear + "C4", # flake8-comprehensions + "E", # pycodestyle errors + "F", # pyflakes + "G", # flake8-logging-format + "I", # isort + "LOG", # flake8-logging + "N", # pep8-naming + "PIE", # flake8-pie + "PT", # flake8-pytest-style + "RET", # flake8-return + "RUF", # ruff-specific rules + "SIM", # flake8-simplify + "TID", # flake8-tidy-imports + "UP", # pyupgrade + "W", # pycodestyle warnings ] ignore = [ - "E501", # line too long + "E501", # line too long, enforced by the formatter for code ] [tool.ruff.lint.flake8-bugbear] extend-immutable-calls = [ - "fastapi.Depends", - "fastapi.Query", - "fastapi.security.HTTPBearer", + "fastapi.Depends", + "fastapi.Query", + "fastapi.security.HTTPBearer", ] [tool.ruff.format] quote-style = "double" indent-style = "space" + +[tool.pyright] +typeCheckingMode = "standard" + +[tool.pytest.ini_options] +testpaths = ["tests"] +pythonpath = ["."] diff --git a/scripts/dump_lmdb.py b/scripts/dump_lmdb.py index 889af4f..b9400a4 100644 --- a/scripts/dump_lmdb.py +++ b/scripts/dump_lmdb.py @@ -5,25 +5,29 @@ import lmdb import orjson +from lmdb import Transaction -def _decode_value(value: bytes) -> Any: +def _decode_value(value: bytes | memoryview) -> Any: """Decode a value from LMDB to Python data.""" + value = bytes(value) try: return orjson.loads(value) except orjson.JSONDecodeError: return value.decode("utf-8", errors="replace") -def _dump_all(txn: lmdb.Transaction) -> list[dict[str, Any]]: +def _dump_all(txn: Transaction) -> list[dict[str, Any]]: """Return all records from the database.""" result: list[dict[str, Any]] = [] - for key, value in txn.cursor(): - result.append({"key": key.decode("utf-8"), "value": _decode_value(value)}) + result.extend( + {"key": bytes(key).decode("utf-8"), "value": _decode_value(value)} + for key, value in txn.cursor() + ) return result -def _dump_selected(txn: lmdb.Transaction, keys: Iterable[str]) -> list[dict[str, Any]]: +def _dump_selected(txn: Transaction, keys: Iterable[str]) -> list[dict[str, Any]]: """Return records for the provided keys.""" result: list[dict[str, Any]] = [] for key in keys: diff --git a/scripts/rotate_lmdb.py b/scripts/rotate_lmdb.py index b9b3457..cda6c38 100644 --- a/scripts/rotate_lmdb.py +++ b/scripts/rotate_lmdb.py @@ -1,10 +1,22 @@ import argparse +import os +import sys from datetime import datetime, timedelta from pathlib import Path -from typing import Any -import lmdb -import orjson +# Run as a plain script as well as `python -m scripts.rotate_lmdb`: only the latter puts the +# repository root on the path, and `app` has to be importable either way. +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) + +# Importing the store pulls in `app.utils`, which builds the application config at import time +# and exits when it cannot. This command touches nothing the Gemini section configures, so when +# there is no config file to read it seeds the one required field rather than refusing to run - +# rotating a detached or backup database must not require the server's configuration. A real +# config file still takes effect, because this only fills in what is otherwise missing. +if not Path(os.getenv("CONFIG_PATH", "config/config.yaml")).is_file(): + os.environ.setdefault("CONFIG_GEMINI", '{"clients": []}') + +from app.services.lmdb import LMDBConversationStore def _parse_duration(value: str) -> timedelta: @@ -16,42 +28,34 @@ def _parse_duration(value: str) -> timedelta: raise ValueError("Invalid duration format. Use Nd or Nh") -def _should_delete(record: dict[str, Any], threshold: datetime) -> bool: - """Check if the record is older than the threshold.""" - timestamp = record.get("updated_at") or record.get("created_at") - if not timestamp: - return False +DEFAULT_MAP_SIZE = 1024 * 1024 * 1024 + + +def rotate_lmdb(path: Path, keep: str, map_size: int = DEFAULT_MAP_SIZE) -> int: + """Delete conversations last updated before the retention window, or all of them. + + Returns the number removed. `keep` is a duration like `14d`/`24h`, or `all` to empty + the store. `map_size` is passed explicitly rather than read from the application config, + so this command can rotate a detached or backup database whose size has nothing to do + with the running server's settings. + """ + # Opening an absent path would create an empty database and report a successful rotation of + # nothing, so a mistyped path has to fail instead. + if not (path / "data.mdb").is_file(): + raise SystemExit(f"No LMDB database at {path}") + + store = LMDBConversationStore.open_isolated( + db_path=str(path), max_db_size=map_size, retention_days=0 + ) try: - ts = datetime.fromisoformat(timestamp) - except ValueError: - return False - return ts < threshold - - -def rotate_lmdb(path: Path, keep: str) -> None: - """Remove records older than the specified duration.""" - env = lmdb.open(str(path), writemap=True, readahead=False, meminit=False) - if keep == "all": - with env.begin(write=True) as txn: - cursor = txn.cursor() - for key, _ in cursor: - txn.delete(key) - env.close() - return - - delta = _parse_duration(keep) - threshold = datetime.now() - delta - - with env.begin(write=True) as txn: - cursor = txn.cursor() - for key, value in cursor: - try: - record = orjson.loads(value) - except orjson.JSONDecodeError: - continue - if _should_delete(record, threshold): - txn.delete(key) - env.close() + if keep == "all": + return store.clear() + + delta = _parse_duration(keep) + threshold = datetime.now() - delta + return store.cleanup_before(threshold) + finally: + store.close() def main() -> None: @@ -61,9 +65,18 @@ def main() -> None: "keep", help="Retention period, e.g. 14d or 24h. Use 'all' to delete every record", ) + parser.add_argument( + "--map-size", + type=int, + default=DEFAULT_MAP_SIZE, + help=( + f"LMDB map size in bytes for opening the target database (default: {DEFAULT_MAP_SIZE})" + ), + ) args = parser.parse_args() - rotate_lmdb(args.path, args.keep) + removed = rotate_lmdb(args.path, args.keep, args.map_size) + print(f"Removed {removed} conversation(s) from {args.path}") if __name__ == "__main__": diff --git a/scripts/start-gemini-api.sh b/scripts/start-gemini-api.sh new file mode 100755 index 0000000..63b55d8 --- /dev/null +++ b/scripts/start-gemini-api.sh @@ -0,0 +1,62 @@ +#!/bin/bash +# start-gemini-api.sh — launch/stop the local Gemini-FastAPI server for opencode2. +# Usage: ./start-gemini-api.sh start | stop | status +set -u +REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +PORT="${GEMINI_API_PORT:-8000}" +HEALTH_URL="http://127.0.0.1:${PORT}/health" +PID_FILE="${TMPDIR:-/tmp}/gemini-api.pid" +LOG_FILE="${TMPDIR:-/tmp}/gemini-api.log" + +start() { + if [ -f "$PID_FILE" ] && kill -0 "$(cat "$PID_FILE")" 2>/dev/null; then + echo "gemini-api already running (pid $(cat "$PID_FILE"), $HEALTH_URL)" + exit 0 + fi + echo "Starting gemini-api from $REPO ..." + ( cd "$REPO" && exec uv run python run.py ) >"$LOG_FILE" 2>&1 & + echo $! > "$PID_FILE" + for _ in $(seq 1 "${GEMINI_START_TIMEOUT_LOOPS:-40}"); do + code=$(curl -s -m 2 -o /dev/null -w "%{http_code}" "$HEALTH_URL" 2>/dev/null) + if [ "$code" = "200" ] && kill -0 "$(cat "$PID_FILE")" 2>/dev/null; then + echo "gemini-api healthy at $HEALTH_URL (pid $(cat "$PID_FILE"))" + exit 0 + fi + sleep 3 + done + if [ -f "$PID_FILE" ] && kill -0 "$(cat "$PID_FILE")" 2>/dev/null; then + kill "$(cat "$PID_FILE")" 2>/dev/null + fi + echo "ERROR: gemini-api did not become healthy within the timeout (${GEMINI_START_TIMEOUT_LOOPS:-40} x 3s). Log: $LOG_FILE" >&2 + tail -5 "$LOG_FILE" >&2 + exit 1 +} + +stop() { + if [ -f "$PID_FILE" ] && kill -0 "$(cat "$PID_FILE")" 2>/dev/null; then + kill "$(cat "$PID_FILE")" + for _ in $(seq 1 10); do + kill -0 "$(cat "$PID_FILE")" 2>/dev/null || break + sleep 1 + done + rm -f "$PID_FILE" + echo "gemini-api stopped" + else + echo "gemini-api not running" + fi +} + +status() { + if [ -f "$PID_FILE" ] && kill -0 "$(cat "$PID_FILE")" 2>/dev/null; then + curl -s -m 3 "$HEALTH_URL" && echo && echo "pid $(cat "$PID_FILE")" + else + echo "gemini-api not running" + fi +} + +case "${1:-start}" in + start) start ;; + stop) stop ;; + status) status ;; + *) echo "usage: $0 start|stop|status" >&2; exit 2 ;; +esac diff --git a/tests/test_api_compatibility.py b/tests/test_api_compatibility.py new file mode 100644 index 0000000..cff1f03 --- /dev/null +++ b/tests/test_api_compatibility.py @@ -0,0 +1,895 @@ +"""Wire-format compatibility tests for the OpenAI- and Gemini-shaped surfaces. + +The contract these lock down: a request naming only standard attributes must never be rejected +just because Gemini Web cannot honour one of them. Options that cannot be forwarded are accepted +and dropped; only malformed or unrepresentable *content* is refused. +""" + +import base64 +import time + +import orjson +import pytest +from pydantic import ValidationError + +from app.models.core import AppContentItem, AppToolCall, AppToolCallFunction +from app.models.gemini_models import GeminiGenerateContentRequest, GeminiGenerationConfig +from app.models.models import ( + ChatCompletionNamedToolChoice, + FunctionCallOutput, + ResponseCreateRequest, + ResponseFormatTextJSONSchemaConfig, + ResponseInputMessage, + ResponseUsage, + StructuredOutputRequirement, + ToolChoiceFunction, + ToolChoiceTypes, +) +from app.server.chat import ( + _build_structured_requirement, + _create_responses_standard_payload, + _responses_response_format, + _sse_error, + _tool_choice_declaration_error, + _tool_choice_failure, + _validate_responses_input, +) +from app.server.gemini import ( + _gemini_response_schema, + _gemini_structured_requirement, + _gemini_tools_to_internal, + _validate_gemini_request, +) +from app.utils.helper import ( + SCHEMA_ADHERENCE_PROMPT, + STRICT_SCHEMA_ADHERENCE_PROMPT, + StructuredOutputValidationError, + canonicalize_structured_output, + decode_base64_data, + guess_extension_for_mime, + normalize_openapi_schema, + process_llm_output, +) + +TOOL_CALL_OUTPUT = ( + "[ToolCalls][Call:get_weather][CallParameter:city]Hanoi[/CallParameter][/Call][/ToolCalls]" +) +OBJECT_SCHEMA = {"type": "object", "properties": {"a": {"type": "string"}}, "required": ["a"]} +_EMPTY_USAGE = ResponseUsage(input_tokens=0, output_tokens=0, total_tokens=0) + + +def _requirement(schema: dict, *, strict: bool = True) -> StructuredOutputRequirement: + return StructuredOutputRequirement( + schema_name="r", schema=schema, instruction="", raw_format={}, strict=strict + ) + + +def _gemini_request(**overrides) -> GeminiGenerateContentRequest: + payload = {"contents": [{"role": "user", "parts": [{"text": "hi"}]}], **overrides} + return GeminiGenerateContentRequest.model_validate(payload) + + +def _generation_config(request: GeminiGenerateContentRequest) -> GeminiGenerationConfig: + """Narrow the optional generationConfig the caller just supplied.""" + config = request.generationConfig + assert config is not None + return config + + +# --------------------------------------------------------------------------- response_format + + +@pytest.mark.parametrize("format_type", ["text", "json_object"]) +def test_non_json_schema_response_formats_are_accepted(format_type): + """`text` is the API default and `json_object` is JSON mode; neither may 400.""" + requirement = _build_structured_requirement({"type": format_type}) + if format_type == "text": + assert requirement is None + else: + assert requirement is not None + # JSON mode promises valid JSON only, so it must not be enforced as strict. + assert requirement.strict is False + + +def test_json_schema_sets_strict_from_the_request(): + for strict in (True, False): + requirement = _build_structured_requirement( + {"type": "json_schema", "json_schema": {"schema": OBJECT_SCHEMA, "strict": strict}} + ) + assert requirement is not None + assert requirement.strict is strict + + +@pytest.mark.parametrize("strict", ["false", "true", 0, 1, None]) +def test_chat_json_schema_rejects_non_boolean_strict_values(strict): + with pytest.raises(ValueError, match="strict must be a boolean"): + _build_structured_requirement( + {"type": "json_schema", "json_schema": {"schema": OBJECT_SCHEMA, "strict": strict}} + ) + + +@pytest.mark.parametrize( + "response_format", + [ + {"type": "json_schema"}, + {"type": "json_schema", "json_schema": {}}, + ], +) +def test_malformed_json_schema_is_still_rejected(response_format): + with pytest.raises(ValueError, match="schema"): + _build_structured_requirement(response_format) + + +@pytest.mark.parametrize( + "schema", + [ + {"type": "not-a-type"}, + {"type": "integer", "exclusiveMinimum": True}, # draft-4 spelling + ], +) +def test_an_unrepresentable_schema_is_asked_for_but_not_enforced(schema): + """A schema we cannot evaluate must not 400: that loses an answer over a gap on our side.""" + requirement = _build_structured_requirement( + {"type": "json_schema", "json_schema": {"schema": schema, "strict": True}} + ) + assert requirement is not None + # Still shown to the model, but it cannot be used to judge the reply. + assert orjson.dumps(schema, option=orjson.OPT_SORT_KEYS).decode() in requirement.instruction + assert requirement.schema == {} + assert requirement.strict is False + + +@pytest.mark.parametrize("response_format", [None, {}, "json_schema", ["json_schema"]]) +def test_absent_or_non_object_response_format_is_ignored(response_format): + assert _build_structured_requirement(response_format) is None + + +def test_json_schema_defaults_to_best_effort_and_a_generated_name(): + """OpenAI defaults `strict` to false, and so must we. + + The flag is not decorative here: a strict miss costs the caller the whole answer, and this + wrapper prompts for schema adherence rather than constraining decoding. A caller who never + asked for strict enforcement must not be opted into losing replies. + """ + requirement = _build_structured_requirement( + {"type": "json_schema", "json_schema": {"schema": OBJECT_SCHEMA, "name": ""}} + ) + assert requirement is not None + assert requirement.schema_name == "response" + assert requirement.strict is False + assert STRICT_SCHEMA_ADHERENCE_PROMPT not in requirement.instruction + # The schema is still asked for; only the failure mode softens. + assert SCHEMA_ADHERENCE_PROMPT in requirement.instruction + + +def test_non_strict_schema_omits_the_exact_conformance_line(): + requirement = _build_structured_requirement( + {"type": "json_schema", "json_schema": {"schema": OBJECT_SCHEMA, "strict": False}} + ) + assert requirement is not None + assert STRICT_SCHEMA_ADHERENCE_PROMPT not in requirement.instruction + + +# --------------------------------------------------------------------------- output enforcement + + +def test_tool_call_turn_is_not_failed_by_a_response_format(): + """The schema constrains the final answer, not a turn that asks for a tool.""" + _, visible, _, tool_calls = process_llm_output( + None, TOOL_CALL_OUTPUT, _requirement(OBJECT_SCHEMA) + ) + assert [call.function.name for call in tool_calls] == ["get_weather"] + assert visible == "" + + +def test_strict_violation_raises_and_best_effort_violation_degrades(): + with pytest.raises(StructuredOutputValidationError): + process_llm_output(None, '{"b": 1}', _requirement(OBJECT_SCHEMA)) + + _, visible, _, _ = process_llm_output( + None, '{"b": 1}', _requirement(OBJECT_SCHEMA, strict=False) + ) + assert visible == '{"b": 1}' + + +@pytest.mark.parametrize("raw_text", ["", " \n "]) +def test_a_reply_with_no_text_is_not_a_schema_violation(raw_text): + """An image-only or empty turn has nothing to validate; failing it would invent an error.""" + _, visible, storage, _ = process_llm_output(None, raw_text, _requirement(OBJECT_SCHEMA)) + assert visible == storage == "" + + +def test_text_alongside_a_tool_call_is_still_canonicalized(): + raw = f'```json\n{{"a": "x"}}\n```\n{TOOL_CALL_OUTPUT}' + _, visible, _, tool_calls = process_llm_output(None, raw, _requirement(OBJECT_SCHEMA)) + assert [call.function.name for call in tool_calls] == ["get_weather"] + assert visible == '{"a":"x"}' + + +def test_conforming_output_is_canonicalized(): + _, visible, storage, _ = process_llm_output( + None, '```json\n{"a": "x"}\n```', _requirement(OBJECT_SCHEMA) + ) + assert visible == storage == '{"a":"x"}' + + +@pytest.mark.parametrize( + "schema", + [ + {"$ref": "http://169.254.169.254/latest/meta-data"}, # unresolvable remote reference + {"$ref": "#/definitions/missing"}, # dangling local reference + {"type": "OBJECT"}, # foreign dialect that slipped through + ], +) +def test_unusable_schemas_leave_the_reply_unverified_rather_than_failing_it(schema): + """Failing to run the check is our problem, not the model's, so it cannot be a violation.""" + assert canonicalize_structured_output('{"a": 1}', _requirement(schema)) == '{"a":1}' + # And it must not reach the caller as an error, even under strict. + _, visible, _, _ = process_llm_output(None, '{"a": 1}', _requirement(schema)) + assert visible == '{"a":1}' + + +def test_json_mode_requires_only_that_the_payload_parses(): + requirement = _requirement({}, strict=False) + assert canonicalize_structured_output('{"anything": [1]}', requirement) == '{"anything":[1]}' + assert canonicalize_structured_output("not json", requirement) is None + + +def test_pathological_schema_regex_is_bounded(monkeypatch): + """A client-controlled pattern must not monopolize the async server thread.""" + monkeypatch.setattr("app.utils.helper.SCHEMA_REGEX_BUDGET_SECONDS", 0.005) + requirement = _requirement( + { + "type": "object", + "properties": {"value": {"type": "string", "pattern": "^(a+)+$"}}, + "required": ["value"], + } + ) + started = time.perf_counter() + result = canonicalize_structured_output('{"value":"' + "a" * 100 + 'b"}', requirement) + assert time.perf_counter() - started < 0.5 + assert result is None + + +def test_an_exhausted_budget_leaves_the_reply_unverified_rather_than_failing_it(monkeypatch): + """Running out of time is our limit, not a schema violation, so strict must not 502.""" + monkeypatch.setattr("app.utils.helper.SCHEMA_REGEX_BUDGET_SECONDS", 1e-9) + requirement = _requirement({"type": "object", "properties": {"a": {"pattern": "^x$"}}}) + assert canonicalize_structured_output('{"a": "x"}', requirement) == '{"a":"x"}' + + +def test_an_ordinary_large_payload_fits_the_regex_budget(): + """The budget is cumulative, so it has to cover realistic volume, not just one pattern.""" + requirement = _requirement( + { + "type": "array", + "items": { + "type": "object", + "properties": { + "email": {"type": "string", "pattern": r"^[^@]+@[^@]+\.[A-Za-z]{2,}$"}, + "sku": {"type": "string", "pattern": "^[A-Z]{3}-[0-9]{6}$"}, + "slug": {"type": "string", "pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*$"}, + }, + "required": ["email", "sku", "slug"], + }, + } + ) + rows = [ + {"email": f"u{i}@example.com", "sku": f"ABC-{i:06d}", "slug": f"row-{i}"} + for i in range(2000) + ] + assert canonicalize_structured_output(orjson.dumps(rows).decode(), requirement) is not None + + +def test_bounded_validator_preserves_pattern_properties_and_additional_properties(): + requirement = _requirement( + { + "type": "object", + "patternProperties": {"^item_[0-9]+$": {"type": "integer"}}, + "additionalProperties": False, + } + ) + assert canonicalize_structured_output('{"item_1": 1}', requirement) == '{"item_1":1}' + assert canonicalize_structured_output('{"other": 1}', requirement) is None + + +# --------------------------------------------------------------------------- Responses text.format + + +@pytest.mark.parametrize( + ("format_payload", "expected_type"), + [ + ({"type": "text"}, None), + ({"type": "json_object"}, "json_object"), + ({"type": "json_schema", "name": "r", "schema": OBJECT_SCHEMA}, "json_schema"), + ], +) +def test_text_format_accepts_every_standard_variant(format_payload, expected_type): + request = ResponseCreateRequest.model_validate( + {"model": "m", "input": "hi", "text": {"format": format_payload}} + ) + resolved = _responses_response_format(request) + assert (resolved or {}).get("type") == expected_type + + +def test_default_text_block_does_not_conflict_with_the_legacy_extension(): + request = ResponseCreateRequest.model_validate( + { + "model": "m", + "input": "hi", + "text": {"format": {"type": "text"}}, + "response_format": {"type": "json_object"}, + } + ) + assert _responses_response_format(request) == {"type": "json_object"} + + +def test_neither_format_block_yields_no_requirement(): + assert ( + _responses_response_format( + ResponseCreateRequest.model_validate({"model": "m", "input": "hi"}) + ) + is None + ) + + +def test_legacy_response_format_alone_is_honored(): + request = ResponseCreateRequest.model_validate( + {"model": "m", "input": "hi", "response_format": {"type": "json_object"}} + ) + assert _responses_response_format(request) == {"type": "json_object"} + + +def test_text_format_json_schema_defaults_name_and_strict(): + request = ResponseCreateRequest.model_validate( + { + "model": "m", + "input": "hi", + "text": {"format": {"type": "json_schema", "schema": OBJECT_SCHEMA}}, + } + ) + resolved = _responses_response_format(request) or {} + assert resolved["json_schema"]["name"] == "response" + # Same default as Chat Completions and as OpenAI: an omitted `strict` is best-effort. + assert resolved["json_schema"]["strict"] is False + + +@pytest.mark.parametrize("strict", ["false", "true", 0, 1]) +def test_responses_json_schema_rejects_non_boolean_strict_values(strict): + with pytest.raises(ValidationError): + ResponseCreateRequest.model_validate( + { + "model": "m", + "input": "hi", + "text": { + "format": { + "type": "json_schema", + "schema": OBJECT_SCHEMA, + "strict": strict, + } + }, + } + ) + + +def test_text_format_json_schema_without_a_schema_is_rejected(): + request = ResponseCreateRequest.model_validate( + {"model": "m", "input": "hi", "text": {"format": {"type": "json_schema", "name": "r"}}} + ) + with pytest.raises(ValueError, match="schema is required"): + _responses_response_format(request) + + +def test_two_conflicting_formats_are_rejected(): + request = ResponseCreateRequest.model_validate( + { + "model": "m", + "input": "hi", + "text": {"format": {"type": "json_schema", "name": "r", "schema": OBJECT_SCHEMA}}, + "response_format": {"type": "json_object"}, + } + ) + with pytest.raises(ValueError, match="not both"): + _responses_response_format(request) + + +def test_both_surfaces_resolve_an_omitted_strict_the_same_way(): + """One schema must not hard-fail on Responses and degrade on Chat Completions.""" + responses_request = ResponseCreateRequest.model_validate( + { + "model": "m", + "input": "hi", + "text": {"format": {"type": "json_schema", "name": "r", "schema": OBJECT_SCHEMA}}, + } + ) + chat = _build_structured_requirement( + {"type": "json_schema", "json_schema": {"name": "r", "schema": OBJECT_SCHEMA}} + ) + responses = _build_structured_requirement(_responses_response_format(responses_request)) + assert chat is not None + assert responses is not None + assert chat.strict is responses.strict is False + + +@pytest.mark.parametrize(("requested", "applied"), [(True, True), (False, False), (None, False)]) +def test_the_echoed_strict_reports_what_was_enforced(requested, applied): + json_schema: dict = {"name": "r", "schema": OBJECT_SCHEMA} + if requested is not None: + json_schema["strict"] = requested + request = ResponseCreateRequest.model_validate( + { + "model": "m", + "input": "hi", + "response_format": {"type": "json_schema", "json_schema": json_schema}, + } + ) + requirement = _build_structured_requirement(_responses_response_format(request)) + assert requirement is not None + assert requirement.strict is applied + + payload = _create_responses_standard_payload( + "resp_1", 0, "m", None, [], [], _EMPTY_USAGE, request, requirement + ) + text_format = payload.text.format if payload.text else None + assert isinstance(text_format, ResponseFormatTextJSONSchemaConfig) + assert text_format.strict is applied + + +# --------------------------------------------------------------------------- Gemini generationConfig + + +def test_openapi_response_schema_is_translated_not_rejected(): + """`responseSchema` is the OpenAPI subset: uppercase types and `nullable`.""" + request = _gemini_request( + generationConfig={ + "responseMimeType": "application/json", + "responseSchema": { + "type": "OBJECT", + "properties": { + "name": {"type": "STRING"}, + "age": {"type": "INTEGER", "nullable": True}, + }, + "required": ["name"], + "propertyOrdering": ["name", "age"], + }, + } + ) + assert _validate_gemini_request(request) is None + assert _gemini_response_schema(_generation_config(request)) == { + "type": "object", + "properties": {"name": {"type": "string"}, "age": {"type": ["integer", "null"]}}, + "required": ["name"], + } + + +def test_invalid_response_json_schema_is_rejected(): + """`responseJsonSchema` really is JSON Schema, so it can be judged as such.""" + request = _gemini_request( + generationConfig={ + "responseMimeType": "application/json", + "responseJsonSchema": {"type": "not-a-type"}, + } + ) + assert "Invalid JSON Schema" in (_validate_gemini_request(request) or "") + + +def test_untranslatable_response_schema_drops_enforcement_rather_than_failing(): + request = _gemini_request( + generationConfig={ + "responseMimeType": "application/json", + "responseSchema": {"type": "WHAT"}, + } + ) + assert _validate_gemini_request(request) is None + assert _gemini_response_schema(_generation_config(request)) is None + + +def test_the_gemini_surface_asks_for_the_schema_without_enforcing_it(): + """Google guarantees conformance by constrained decoding; this wrapper can only ask. + + The native surface has no `strict` flag for a caller to turn off, so failing the request on + a near-miss would throw away an answer with no way to opt out of that. + """ + request = _gemini_request( + generationConfig={"responseMimeType": "application/json", "responseSchema": OBJECT_SCHEMA} + ) + schema, requirement = _gemini_structured_requirement(request) + + assert schema == OBJECT_SCHEMA + assert requirement is not None + assert requirement.strict is False + assert STRICT_SCHEMA_ADHERENCE_PROMPT not in requirement.instruction + + # A violation therefore comes back as text rather than costing the caller the reply. + _, visible, _, _ = process_llm_output(None, '{"b": 1}', requirement) + assert visible == '{"b": 1}' + + +@pytest.mark.parametrize( + "generation_config", + [ + {"responseMimeType": "application/json"}, + {"responseMimeType": "application/json", "responseJsonSchema": {}}, + {"responseMimeType": "application/json", "responseSchema": {}}, + ], +) +def test_gemini_json_mode_survives_absent_or_empty_schemas(generation_config): + schema, requirement = _gemini_structured_requirement( + _gemini_request(generationConfig=generation_config) + ) + assert schema in (None, {}) + assert requirement is not None + assert requirement.schema == {} + assert requirement.strict is False + assert canonicalize_structured_output('{"valid": true}', requirement) == '{"valid":true}' + + +def test_no_response_schema_yields_no_requirement(): + schema, requirement = _gemini_structured_requirement(_gemini_request()) + assert schema is None + assert requirement is None + + +def test_normalize_openapi_schema_leaves_json_schema_alone(): + assert normalize_openapi_schema(OBJECT_SCHEMA) == OBJECT_SCHEMA + + +def test_normalize_openapi_schema_recurses_through_containers(): + assert normalize_openapi_schema( + { + "type": "ARRAY", + "items": { + "type": "OBJECT", + "properties": {"x": {"anyOf": [{"type": "STRING"}, {"type": "INTEGER"}]}}, + }, + } + ) == { + "type": "array", + "items": { + "type": "object", + "properties": {"x": {"anyOf": [{"type": "string"}, {"type": "integer"}]}}, + }, + } + + +def test_normalize_openapi_schema_treats_property_names_as_names(): + """A property called `type` or `nullable` must not be mistaken for the keyword.""" + assert normalize_openapi_schema( + { + "type": "OBJECT", + "properties": {"type": {"type": "STRING"}, "nullable": {"type": "BOOLEAN"}}, + } + ) == { + "type": "object", + "properties": {"type": {"type": "string"}, "nullable": {"type": "boolean"}}, + } + + +def test_normalize_openapi_schema_drops_nullable_false_without_widening(): + assert normalize_openapi_schema({"type": "STRING", "nullable": False}) == {"type": "string"} + + +@pytest.mark.parametrize("value", ["text", None, 7, True]) +def test_normalize_openapi_schema_passes_non_schemas_through(value): + assert normalize_openapi_schema(value) == value + + +@pytest.mark.parametrize( + ("overrides", "expected"), + [ + ({"contents": []}, "contents is required"), + ( + {"contents": [{"role": "user", "parts": []}]}, + "must contain at least one part", + ), + ( + { + "contents": [ + { + "role": "user", + "parts": [{"fileData": {"mimeType": "text/plain", "fileUri": "gs://x"}}], + } + ] + }, + "fileData is not supported", + ), + ({"cachedContent": "cachedContents/abc"}, "cachedContent is not supported"), + ], +) +def test_unrepresentable_content_is_refused(overrides, expected): + """Content the wrapper cannot resolve is refused; dropping it would change the question.""" + payload = {"contents": [{"role": "user", "parts": [{"text": "hi"}]}], **overrides} + request = GeminiGenerateContentRequest.model_validate(payload) + assert expected in (_validate_gemini_request(request) or "") + + +def test_file_data_in_system_instruction_is_refused(): + request = _gemini_request( + systemInstruction={"parts": [{"fileData": {"mimeType": "text/plain", "fileUri": "gs://x"}}]} + ) + assert "fileData is not supported" in (_validate_gemini_request(request) or "") + + +# --------------------------------------------------------------------------- Gemini toolConfig + +_TOOLS = [ + {"functionDeclarations": [{"name": "a", "description": "d"}, {"name": "b", "description": "d"}]} +] + + +@pytest.mark.parametrize( + ("mode", "expected_tools", "expected_choice"), + [ + ("AUTO", ["a", "b"], "auto"), + ("NONE", ["a", "b"], "none"), + ("VALIDATED", ["a"], "auto"), + ], +) +def test_allowed_function_names_only_narrows_the_modes_that_use_it( + mode, expected_tools, expected_choice +): + request = _gemini_request( + tools=_TOOLS, + toolConfig={"functionCallingConfig": {"mode": mode, "allowedFunctionNames": ["a"]}}, + ) + tools, choice = _gemini_tools_to_internal(request.tools, request.toolConfig) + assert [tool.name for tool in tools or []] == expected_tools + assert choice == expected_choice + # Names it does not act on cannot be a validation error either. + assert _validate_gemini_request(request) is None + + +def test_any_mode_with_one_allowed_name_forces_that_function(): + request = _gemini_request( + tools=_TOOLS, + toolConfig={"functionCallingConfig": {"mode": "ANY", "allowedFunctionNames": ["a"]}}, + ) + tools, choice = _gemini_tools_to_internal(request.tools, request.toolConfig) + assert [tool.name for tool in tools or []] == ["a"] + assert getattr(choice, "name", None) == "a" + + +def test_any_mode_rejects_undeclared_allowed_names(): + request = _gemini_request( + tools=_TOOLS, + toolConfig={"functionCallingConfig": {"mode": "ANY", "allowedFunctionNames": ["zzz"]}}, + ) + assert "undeclared functions" in (_validate_gemini_request(request) or "") + + +@pytest.mark.parametrize( + "tool_config", + [ + None, + {"functionCallingConfig": {"mode": "ANY"}}, + {"functionCallingConfig": {"mode": "ANY", "allowedFunctionNames": ["a", "b"]}}, + ], + ids=["no-config", "any-unrestricted", "any-multiple-names"], +) +def test_any_mode_without_a_single_target_forces_only_that_a_tool_is_called(tool_config): + request = _gemini_request(tools=_TOOLS, toolConfig=tool_config) + tools, choice = _gemini_tools_to_internal(request.tools, request.toolConfig) + assert [tool.name for tool in tools or []] == ["a", "b"] + assert choice == (None if tool_config is None else "required") + + +def test_no_tools_yields_no_tool_choice(): + assert _gemini_tools_to_internal(None, None) == (None, None) + + +# --------------------------------------------------------------------------- forced tool_choice + +_CALL = AppToolCall(id="1", type="function", function=AppToolCallFunction(name="a", arguments="{}")) +_NAMED = ChatCompletionNamedToolChoice.model_validate( + {"type": "function", "function": {"name": "a"}} +) +_FUNCTION = ToolChoiceFunction(type="function", name="a") +_IMAGE = ToolChoiceTypes(type="image_generation") + + +@pytest.mark.parametrize( + ("tool_choice", "tool_calls", "has_images", "has_image_tool", "expected"), + [ + (None, [], False, False, None), + ("auto", [], False, False, None), + ("none", [], False, False, None), + ("required", [_CALL], False, False, None), + ("required", [], False, False, "required tool result"), + # An image satisfies `required` only when an image tool was declared; one Gemini + # volunteers on its own cannot stand in for the function call that was forced. + ("required", [], True, True, None), + ("required", [], True, False, "required tool result"), + (_NAMED, [_CALL], False, False, None), + (_NAMED, [], False, False, "required function 'a'"), + (_FUNCTION, [_CALL], False, False, None), + (_FUNCTION, [], False, False, "required function 'a'"), + (_IMAGE, [], True, True, None), + (_IMAGE, [], False, True, "image generation result"), + ], +) +def test_forced_tool_choice_failure_detection( + tool_choice, tool_calls, has_images, has_image_tool, expected +): + result = _tool_choice_failure( + tool_choice, tool_calls, has_images=has_images, has_image_tool=has_image_tool + ) + if expected is None: + assert result is None + else: + assert expected in (result or "") + + +@pytest.mark.parametrize( + ("names", "has_image_tool", "tool_choice", "expected"), + [ + (set(), False, "auto", None), + ({"a"}, False, "required", None), + (set(), True, "required", None), + (set(), False, "required", "requires at least one tool"), + ({"a"}, False, _NAMED, None), + ({"b"}, False, _NAMED, "undeclared function 'a'"), + ({"b"}, False, _FUNCTION, "undeclared function 'a'"), + (set(), True, _IMAGE, None), + (set(), False, _IMAGE, "requires an image_generation tool"), + ], +) +def test_forced_tool_choice_must_name_a_declared_tool(names, has_image_tool, tool_choice, expected): + result = _tool_choice_declaration_error(names, has_image_tool, tool_choice) + if expected is None: + assert result is None + else: + assert expected in (result or "") + + +# --------------------------------------------------------------------------- Responses input + + +def _input_message(*parts) -> ResponseInputMessage: + return ResponseInputMessage.model_validate({"role": "user", "content": list(parts)}) + + +@pytest.mark.parametrize( + ("items", "expected"), + [ + ("a plain string prompt", None), + ([_input_message({"type": "input_text", "text": "hi"})], None), + ([_input_message({"type": "input_image", "image_url": "https://x/y.png"})], None), + ([_input_message({"type": "input_file", "file_url": "https://x/a.pdf"})], None), + ([_input_message({"type": "input_file", "file_data": "aGk="})], None), + ([_input_message({"type": "input_file", "file_id": "file-1"})], "file_id inputs"), + ([_input_message({"type": "input_image"})], "input_image must contain image_url"), + ( + [ + _input_message( + {"type": "input_file", "file_url": "https://x/a.pdf", "file_data": "aGk="} + ) + ], + "exactly one of file_url or file_data", + ), + ( + [_input_message({"type": "input_file", "filename": "a.pdf"})], + "exactly one of file_url or file_data", + ), + ], +) +def test_responses_input_refuses_only_unusable_content(items, expected): + result = _validate_responses_input(items) + if expected is None: + assert result is None + else: + assert expected in (result or "") + + +@pytest.mark.parametrize( + ("output", "expected"), + [ + ("done", None), + ([{"type": "input_text", "text": "done"}], None), + ([{"type": "input_file", "file_id": "file-1"}], "file_id inputs"), + ], +) +def test_tool_result_parts_are_validated_too(output, expected): + items = [FunctionCallOutput.model_validate({"call_id": "c", "output": output})] + result = _validate_responses_input(items) + if expected is None: + assert result is None + else: + assert expected in (result or "") + + +# --------------------------------------------------------------------------- content digests + + +def test_non_ascii_data_url_does_not_abort_model_construction(): + item = AppContentItem(type="image_url", url="data:text/plain;charset=utf-8,Hé") + assert item.content_digest + + +def test_digest_distinguishes_inline_payloads(): + first = base64.b64encode(b"one").decode() + second = base64.b64encode(b"two").decode() + assert ( + AppContentItem(type="file", file_data=first, filename="a.bin").content_digest + != AppContentItem(type="file", file_data=second, filename="a.bin").content_digest + ) + + +@pytest.mark.parametrize( + ("mime_type", "expected"), + [ + ("image/jpeg", ".jpg"), + ("application/pdf", ".pdf"), + # Unregistered but widely sent: the subtype is the fallback, not ".bin". + ("audio/mp3", ".mp3"), + ("application/x-foo", ".x-foo"), + ("image/png; charset=binary", ".png"), + (None, ".bin"), + ("nosubtype", ".bin"), + # A separator here would place the temp file outside its directory. + ("image/../../etc/passwd", ".etcpasswd"), + ], +) +def test_mime_extensions_fall_back_to_a_scrubbed_subtype(mime_type, expected): + suffix = guess_extension_for_mime(mime_type) + assert suffix == expected + assert not set(suffix) & set("/\\") + + +def test_both_base64_alphabets_decode(): + """Clients built on `urlsafe_b64encode` send `-` and `_`.""" + payload = bytes(range(256)) + assert decode_base64_data(base64.b64encode(payload).decode()) == payload + assert decode_base64_data(base64.urlsafe_b64encode(payload).decode()) == payload + with pytest.raises(ValueError, match="Base64"): + decode_base64_data("not base64 at all!!") + + +def test_base64_accepts_bytes_line_wrapping_and_data_urls(): + payload = b"\x00\x01binary payload\xff" + encoded = base64.b64encode(payload).decode() + assert decode_base64_data(encoded.encode()) == payload + # MIME-style encoders wrap long payloads across lines. + assert decode_base64_data(f"{encoded[:4]}\n{encoded[4:]}") == payload + assert decode_base64_data(f"data:application/octet-stream;base64,{encoded}") == payload + + +def test_data_url_without_a_base64_payload_is_rejected(): + with pytest.raises(ValueError, match="Data URL"): + decode_base64_data("data:text/plain,hello") + + +def test_non_ascii_is_an_error_rather_than_something_to_strip(): + """Dropping a stray character could make a corrupt payload decode to the wrong bytes.""" + payload = base64.b64encode(b"body").decode() + with pytest.raises(ValueError, match="non-ASCII"): + decode_base64_data(f"{payload[:2]}é{payload[2:]}") + + +def test_digest_is_identical_across_equivalent_representations(): + """The same bytes must hash alike whether sent as a data URL or as inline file data.""" + encoded = base64.b64encode(b"\x89PNG\r\n\x1a\nbody").decode() + as_data_url = AppContentItem(type="image_url", url=f"data:image/png;base64,{encoded}") + as_file = AppContentItem(type="file", file_data=encoded, filename="a.png") + assert as_data_url.content_digest == as_file.content_digest + + +def test_only_payloads_excluded_from_serialization_get_a_digest(): + """Everything else survives the round trip and is compared directly.""" + assert AppContentItem(type="text", text="hi").content_digest is None + assert AppContentItem(type="image_url", url="https://example.com/a.png").content_digest is None + assert AppContentItem(type="x", raw_data={"a": 1}).content_digest is None + + +def test_sse_errors_terminate_the_stream(): + frame = _sse_error("boom", "server_error") + assert frame.endswith("data: [DONE]\n\n") + assert '"message":"boom"' in frame + + +def test_digest_survives_a_round_trip_without_the_excluded_bytes(): + original = AppContentItem( + type="file", file_data=base64.b64encode(b"payload").decode(), filename="a.bin" + ) + restored = AppContentItem.model_validate(original.model_dump()) + assert restored.file_data is None + assert restored.content_digest == original.content_digest diff --git a/tests/test_health.py b/tests/test_health.py new file mode 100644 index 0000000..7626c45 --- /dev/null +++ b/tests/test_health.py @@ -0,0 +1,53 @@ +import asyncio + +import pytest +from fastapi import Response + +from app.server import health +from app.utils.config import GeminiConfig, GuestMode + + +class _Pool: + def __init__(self, client_status: dict[str, bool]): + self._client_status = client_status + + def status(self) -> dict[str, bool]: + return self._client_status + + +class _Store: + def stats(self) -> dict[str, int]: + return {"entries": 1} + + +@pytest.mark.parametrize( + ("guest_mode", "client_status", "expected_status", "expected_ok"), + [ + (GuestMode.STRICT, {"healthy": True, "guest": False}, 503, False), + (GuestMode.ADAPTIVE, {"healthy": True, "guest": False}, 200, True), + (GuestMode.ADAPTIVE, {"guest-a": False, "guest-b": False}, 503, False), + (GuestMode.PERMISSIVE, {"guest-a": False, "guest-b": False}, 200, True), + ], +) +def test_guest_mode_controls_client_health_status( + monkeypatch, + guest_mode: GuestMode, + client_status: dict[str, bool], + expected_status: int, + expected_ok: bool, +): + monkeypatch.setattr(health, "GeminiClientPool", lambda: _Pool(client_status)) + monkeypatch.setattr(health, "LMDBConversationStore", _Store) + monkeypatch.setattr(health.g_config.gemini, "guest_mode", guest_mode) + response = Response() + + result = asyncio.run(health.health_check(response)) + + assert response.status_code == expected_status + assert result.ok is expected_ok + + +def test_guest_mode_defaults_to_adaptive(): + config = GeminiConfig(clients=[], auto_refresh=True, verbose=True) + + assert config.guest_mode == GuestMode.ADAPTIVE diff --git a/tests/test_middleware.py b/tests/test_middleware.py new file mode 100644 index 0000000..3ca4983 --- /dev/null +++ b/tests/test_middleware.py @@ -0,0 +1,167 @@ +"""Transport-layer behaviour: the request body ceiling, and serving generated media. + +The limit is enforced twice over: once from a declared `content-length`, and again by counting +a chunked body as it arrives. Both have to produce the error shape of the surface they were +addressed to, and both have to travel back out through CORS. + +Media resolution is the other half: the name has to be one this server generated - which rules +out traversal - without being so narrow that a legitimate extension becomes an unreachable file. +""" + +import os + +import pytest +from fastapi import FastAPI, Request +from fastapi.middleware.cors import CORSMiddleware +from fastapi.testclient import TestClient + +from app.server.media import _resolve_media_file +from app.server.middleware import RequestBodyLimitMiddleware + +LIMIT = 100 + + +def _build_app(max_body_bytes: int = LIMIT, *, with_cors: bool = False) -> FastAPI: + app = FastAPI() + + @app.post("/v1/echo") + async def echo(request: Request): + return {"received": len(await request.body())} + + @app.post("/v1beta/models/x:generateContent") + async def gemini_echo(request: Request): + return {"received": len(await request.body())} + + # Registration order is reversed at runtime, so this mirrors app.main.create_app: the + # limiter goes on first precisely so CORS ends up wrapping it. + app.add_middleware(RequestBodyLimitMiddleware, max_body_bytes=max_body_bytes) + if with_cors: + app.add_middleware( + CORSMiddleware, allow_origins=["*"], allow_methods=["*"], allow_headers=["*"] + ) + return app + + +def _chunks(total: int, size: int = 40): + """Send without a content-length, so only the running count can catch the overflow.""" + sent = 0 + while sent < total: + step = min(size, total - sent) + yield b"x" * step + sent += step + + +def test_a_body_within_the_ceiling_reaches_the_route(): + with TestClient(_build_app()) as client: + response = client.post("/v1/echo", content=b"x" * (LIMIT - 1)) + assert response.status_code == 200 + assert response.json() == {"received": LIMIT - 1} + + +def test_a_body_exactly_at_the_ceiling_is_allowed(): + with TestClient(_build_app()) as client: + response = client.post("/v1/echo", content=b"x" * LIMIT) + assert response.status_code == 200 + + +def test_a_declared_oversize_body_is_refused(): + with TestClient(_build_app()) as client: + response = client.post("/v1/echo", content=b"x" * (LIMIT + 1)) + assert response.status_code == 413 + assert "safety ceiling" in response.json()["error"]["message"] + + +def test_a_chunked_oversize_body_is_refused(): + with TestClient(_build_app()) as client: + response = client.post("/v1/echo", content=_chunks(LIMIT * 5)) + assert response.status_code == 413 + assert "safety ceiling" in response.json()["error"]["message"] + + +@pytest.mark.parametrize("content", [b"x" * (LIMIT + 1), None], ids=["declared", "chunked"]) +def test_the_gemini_surface_gets_googles_error_envelope(content): + body = content if content is not None else _chunks(LIMIT * 5) + with TestClient(_build_app()) as client: + response = client.post("/v1beta/models/x:generateContent", content=body) + assert response.status_code == 413 + error = response.json()["error"] + assert error["code"] == 413 + assert error["status"] == "RESOURCE_EXHAUSTED" + + +def test_a_zero_ceiling_disables_the_guard(): + with TestClient(_build_app(0)) as client: + response = client.post("/v1/echo", content=b"x" * (LIMIT * 100)) + assert response.status_code == 200 + + +def test_a_refusal_still_carries_cors_headers(): + """Without this the browser reports an opaque CORS failure instead of the 413.""" + with TestClient(_build_app(with_cors=True)) as client: + allowed = client.post("/v1/echo", content=b"x", headers={"Origin": "https://example.com"}) + refused = client.post( + "/v1/echo", content=b"x" * (LIMIT + 1), headers={"Origin": "https://example.com"} + ) + assert allowed.headers["access-control-allow-origin"] == "*" + assert refused.status_code == 413 + assert refused.headers["access-control-allow-origin"] == "*" + + +def test_an_unparsable_content_length_falls_back_to_counting(): + """A bogus header must not be trusted as 0 and wave an oversize body through.""" + app = _build_app() + with TestClient(app) as client: + response = client.post( + "/v1/echo", + content=b"x" * (LIMIT + 1), + headers={"Content-Length": str(LIMIT + 1)}, + ) + assert response.status_code == 413 + + +def test_requests_without_a_body_are_untouched(): + app = _build_app() + + @app.get("/v1/ping") + async def ping(): + return {"ok": True} + + with TestClient(app) as client: + assert client.get("/v1/ping").status_code == 200 + + +# --------------------------------------------------------------------------------- media serving + +STEM = "img_" + "0" * 32 + + +@pytest.mark.parametrize("suffix", [".png", ".mp4", ".m4a", ".3gp", ".x-m4a", ".tar.gz", ".JPG"]) +def test_every_extension_this_server_can_produce_is_servable(tmp_path, suffix): + """The extension comes from whatever the upstream saved, not from a fixed list. + + Rejecting an unusual one would 404 a file whose token verifies, so the pattern has to be + permissive about the extension while still admitting no path separator. + """ + target = tmp_path / f"{STEM}{suffix}" + target.write_bytes(b"data") + assert _resolve_media_file(tmp_path, target.name) == target.resolve() + + +@pytest.mark.parametrize( + "filename", + [ + "../config/config.yaml", + f"..{os.sep}{STEM}.png", + f"{STEM}.png{os.sep}..{os.sep}secret", + "secret.png", + "img_notahexdigest.png", + f"{STEM}.", + f"{STEM}.png/../../etc/passwd", + ], +) +def test_names_this_server_never_generates_are_refused(tmp_path, filename): + assert _resolve_media_file(tmp_path, filename) is None + + +def test_a_matching_name_with_no_file_behind_it_is_refused(tmp_path): + assert _resolve_media_file(tmp_path, f"{STEM}.png") is None diff --git a/tests/test_storage.py b/tests/test_storage.py new file mode 100644 index 0000000..2d38228 --- /dev/null +++ b/tests/test_storage.py @@ -0,0 +1,210 @@ +"""Conversation-store behaviour: lookup, retention, and index versioning. + +Every test opens its own store under `tmp_path` via `open_isolated`, so nothing touches the +singleton or the configured data directory. +""" + +from datetime import datetime, timedelta + +import lmdb +import orjson +import pytest + +from app.models.core import AppContentItem, AppMessage +from app.services.lmdb import LMDBConversationStore + +# `find` only searches the configured clients, so a stored conversation has to claim one. +CLIENT_ID = "client-id-1" +MODEL = "gemini-3-pro" + + +@pytest.fixture +def store(tmp_path): + opened = LMDBConversationStore.open_isolated(db_path=str(tmp_path / "lmdb")) + try: + yield opened + finally: + opened.close() + + +def _exchange(prompt: str = "hello") -> list[AppMessage]: + return [ + AppMessage(role="user", content=prompt), + AppMessage(role="assistant", content="hi there"), + ] + + +def _raw_keys(opened: LMDBConversationStore) -> list[str]: + with opened._get_transaction() as txn: + return [bytes(key).decode("utf-8") for key, _ in txn.cursor()] + + +def test_a_stored_conversation_is_found_again(store): + messages = _exchange() + store.store(CLIENT_ID, MODEL, messages, metadata=["c", "r", "rc"]) + + found = store.find(MODEL, messages) + assert found is not None + assert found.client_id == CLIENT_ID + assert found.metadata == ["c", "r", "rc"] + + +def test_a_different_model_does_not_match(store): + messages = _exchange() + store.store(CLIENT_ID, MODEL, messages, metadata=["c", "r", "rc"]) + assert store.find("gemini-3-flash", messages) is None + + +def test_echoed_reasoning_still_matches_the_stored_turn(store): + """Reuse must survive a client replaying the reasoning this server emitted. + + `_persist_conversation` stores every assistant turn it produces with `reasoning_content=None`, + but both request converters populate it from whatever the client sends back - and replaying + the previous output is the normal pattern on the Responses API. If the hash counted reasoning, + the newest stored prefix could never match and session reuse would collapse. + """ + stored = [ + AppMessage(role="user", content="hello"), + AppMessage(role="assistant", content="hi there", reasoning_content=None), + ] + store.store(CLIENT_ID, MODEL, stored, metadata=["c", "r", "rc"]) + + echoed = [ + AppMessage(role="user", content="hello"), + AppMessage(role="assistant", content="hi there", reasoning_content="let me think..."), + ] + found = store.find(MODEL, echoed) + assert found is not None + assert found.metadata == ["c", "r", "rc"] + + +def test_inline_media_with_the_same_bytes_matches_and_different_bytes_does_not(store): + """The content digest is what keeps two distinct images from colliding.""" + + def with_image(payload: str) -> list[AppMessage]: + return [ + AppMessage( + role="user", + content=[ + AppContentItem(type="text", text="describe"), + AppContentItem(type="image_url", url=f"data:image/png;base64,{payload}"), + ], + ), + AppMessage(role="assistant", content="a picture"), + ] + + stored = with_image("aGVsbG8=") + store.store(CLIENT_ID, MODEL, stored, metadata=["c", "r", "rc"]) + + assert store.find(MODEL, with_image("aGVsbG8=")) is not None + assert store.find(MODEL, with_image("d29ybGQ=")) is None + + +def test_raw_data_still_discriminates_and_ignores_key_order(store): + """It is hashed inline rather than digested, so the outer sort has to canonicalize it.""" + + def with_raw(raw: dict) -> list[AppMessage]: + return [ + AppMessage(role="user", content=[AppContentItem(type="x", raw_data=raw)]), + AppMessage(role="assistant", content="ok"), + ] + + store.store(CLIENT_ID, MODEL, with_raw({"b": 1, "a": 2}), metadata=["c", "r", "rc"]) + + assert store.find(MODEL, with_raw({"a": 2, "b": 1})) is not None + assert store.find(MODEL, with_raw({"a": 2, "b": 99})) is None + + +def test_keys_reports_conversations_without_index_entries(store): + store.store(CLIENT_ID, MODEL, _exchange(), metadata=["c", "r", "rc"]) + + assert len(store.keys()) == 1 + # The indexes exist, they are just not conversations. + assert len(_raw_keys(store)) > 1 + + +def test_eviction_removes_the_record_and_its_indexes(store): + messages = _exchange() + store.store(CLIENT_ID, MODEL, messages, metadata=["c", "r", "rc"]) + conv = store.find(MODEL, messages) + assert conv is not None + + assert store.evict(conv) is True + assert store.find(MODEL, messages) is None + assert _raw_keys(store) == [] + + +def test_clear_empties_the_store(store): + store.store(CLIENT_ID, MODEL, _exchange("one"), metadata=["c", "r", "rc"]) + store.store(CLIENT_ID, MODEL, _exchange("two"), metadata=["c", "r", "rc"]) + + assert store.clear() == 2 + assert store.keys() == [] + assert _raw_keys(store) == [] + + +def test_retention_keeps_a_conversation_that_is_still_in_use(store): + """Retention follows last use; a long-running conversation is not old just because it began early.""" + messages = _exchange() + store.store(CLIENT_ID, MODEL, messages, metadata=["c", "r", "rc"]) + + conv = store.find(MODEL, messages) + assert conv is not None + key = store.keys()[0] + conv.created_at = datetime.now() - timedelta(days=90) + conv.updated_at = datetime.now() + with store._get_transaction(write=True) as txn: + txn.put(key.encode("utf-8"), orjson.dumps(conv.model_dump(mode="json")), overwrite=True) + + assert store.cleanup_before(datetime.now() - timedelta(days=14)) == 0 + assert store.find(MODEL, messages) is not None + + +def test_retention_removes_a_conversation_last_touched_before_the_cutoff(store): + messages = _exchange() + store.store(CLIENT_ID, MODEL, messages, metadata=["c", "r", "rc"]) + + assert store.cleanup_before(datetime.now() + timedelta(seconds=1)) == 1 + assert store.find(MODEL, messages) is None + assert _raw_keys(store) == [] + + +def test_lookup_entries_are_written_under_the_current_index_version(store): + store.store(CLIENT_ID, MODEL, _exchange(), metadata=["c", "r", "rc"]) + + index_keys = [key for key in _raw_keys(store) if store._is_index_key(key)] + assert index_keys + assert all( + key.startswith((store.HASH_LOOKUP_PREFIX, store.FUZZY_LOOKUP_PREFIX)) for key in index_keys + ) + assert LMDBConversationStore.INDEX_VERSION in store.HASH_LOOKUP_PREFIX + + +def test_indexes_from_a_superseded_version_are_pruned(tmp_path): + """A hash-shape change strands old entries that eviction can no longer find by hash.""" + db_path = tmp_path / "lmdb" + env = lmdb.open(str(db_path), map_size=10_000_000, max_dbs=3, writemap=True) + with env.begin(write=True) as txn: + txn.put(b"hash:v1:deadbeef", b'["conv1"]') + txn.put(b"fuzzy:v1:deadbeef", b'["conv1"]') + txn.put(b"hash:legacy-unversioned", b'["conv1"]') + txn.put(b"conv1", b'{"client_id":"a","model":"m","messages":[],"metadata":[]}') + env.close() + + opened = LMDBConversationStore.open_isolated(db_path=str(db_path)) + try: + assert opened.prune_stale_indexes() == 3 + # The conversation itself survives; only the unreachable lookup rows go. + assert _raw_keys(opened) == ["conv1", opened._INDEX_VERSION_KEY] + # The marker makes the next startup skip the scan entirely. + assert opened.prune_stale_indexes() == 0 + finally: + opened.close() + + +def test_pruning_leaves_current_version_entries_alone(store): + messages = _exchange() + store.store(CLIENT_ID, MODEL, messages, metadata=["c", "r", "rc"]) + + assert store.prune_stale_indexes() == 0 + assert store.find(MODEL, messages) is not None diff --git a/tests/test_streaming.py b/tests/test_streaming.py new file mode 100644 index 0000000..68d3783 --- /dev/null +++ b/tests/test_streaming.py @@ -0,0 +1,425 @@ +"""Chat Completions SSE assembly. + +Drives the streaming generator directly over a fake upstream so the wire contract can be +asserted without a network: what the client sees, in what order, and how the stream ends. +""" + +import asyncio +from types import SimpleNamespace +from typing import Any, cast + +import orjson +import pytest +from gemini_webapi.types import Candidate, ModelOutput, WebImage + +from app.models.core import AppMessage +from app.models.models import ResponseCreateRequest, StructuredOutputRequirement +from app.server.chat import ( + _create_real_streaming_response, + _create_responses_real_streaming_response, +) +from app.services.lmdb import LMDBConversationStore + +CLIENT_ID = "client-id-1" +MODEL = "gemini-3-pro" +TOOL_CALL_OUTPUT = ( + "[ToolCalls][Call:get_weather][CallParameter:city]Hanoi[/CallParameter][/Call][/ToolCalls]" +) +OBJECT_SCHEMA = {"type": "object", "properties": {"a": {"type": "string"}}, "required": ["a"]} + + +def _requirement() -> StructuredOutputRequirement: + return StructuredOutputRequirement( + schema_name="r", schema=OBJECT_SCHEMA, instruction="", raw_format={}, strict=True + ) + + +def _output(text: str, *, delta: str | None = None, thoughts_delta: str | None = None): + return ModelOutput( + metadata=["c", "r", "rc"], + chosen=0, + candidates=[ + Candidate( + rcid="rc", + text=text, + text_delta=delta if delta is not None else text, + thoughts_delta=thoughts_delta, + ) + ], + ) + + +def _stream(*outputs: ModelOutput): + async def generator(): + for output in outputs: + yield output + + return generator() + + +@pytest.fixture +def db(tmp_path): + opened = LMDBConversationStore.open_isolated(db_path=str(tmp_path / "lmdb")) + try: + yield opened + finally: + opened.close() + + +def _collect(db, stream, *, structured_requirement=None, tool_choice=None) -> list[str]: + """Run the streaming response to completion and return its raw SSE frames.""" + # Only the few attributes the generator actually touches; a real client would need a live + # browser session behind it. + client = cast( + Any, + SimpleNamespace(id=CLIENT_ID, latest_chat_cid=None, chat_scope=lambda _temporary: None), + ) + session = cast(Any, SimpleNamespace(metadata=["c", "r", "rc"])) + response = _create_real_streaming_response( + stream, + "chatcmpl-test", + 0, + MODEL, + [AppMessage(role="user", content="hi")], + db, + MODEL, + client, + session, + "http://testserver/", + structured_requirement, + tool_choice, + ) + + async def drain() -> list[str]: + chunks: list[str] = [] + async for chunk in response.body_iterator: + chunks.append(chunk if isinstance(chunk, str) else bytes(chunk).decode("utf-8")) + return chunks + + return asyncio.run(drain()) + + +def _payloads(frames: list[str]) -> list[dict]: + return [ + orjson.loads(line[len("data: ") :]) + for frame in frames + for line in frame.strip().splitlines() + if line.startswith("data: ") and line != "data: [DONE]" + ] + + +def test_a_plain_stream_opens_with_a_role_delta_and_ends_with_done(db): + frames = _collect(db, _stream(_output("Hello"), _output("Hello world", delta=" world"))) + + assert frames[-1] == "data: [DONE]\n\n" + payloads = _payloads(frames) + assert payloads[0]["choices"][0]["delta"] == {"role": "assistant", "content": ""} + text = "".join( + payload["choices"][0]["delta"].get("content", "") + for payload in payloads + if payload.get("choices") + ) + assert text == "Hello world" + assert payloads[-1]["choices"][0]["finish_reason"] == "stop" + + +def test_reasoning_is_streamed_on_its_own_delta_field(db): + frames = _collect(db, _stream(_output("answer", thoughts_delta="thinking"))) + reasoning = [ + payload["choices"][0]["delta"]["reasoning_content"] + for payload in _payloads(frames) + if payload.get("choices") and "reasoning_content" in payload["choices"][0]["delta"] + ] + assert reasoning == ["thinking"] + + +def test_a_tool_call_is_reported_and_finishes_as_tool_calls(db): + frames = _collect(db, _stream(_output(TOOL_CALL_OUTPUT))) + payloads = _payloads(frames) + + tool_calls = [ + payload["choices"][0]["delta"]["tool_calls"] + for payload in payloads + if payload.get("choices") and payload["choices"][0]["delta"].get("tool_calls") + ] + assert tool_calls + assert tool_calls[0][0]["function"]["name"] == "get_weather" + assert payloads[-1]["choices"][0]["finish_reason"] == "tool_calls" + # The protocol markers themselves must never reach the client. + assert "[ToolCalls]" not in "".join(frames) + + +def test_structured_output_is_withheld_until_it_has_been_validated(db): + """Deltas are suppressed so a schema violation cannot arrive half-rendered.""" + frames = _collect( + db, + _stream(_output('```json\n{"a": "x"}\n```')), + structured_requirement=_requirement(), + ) + contents = [ + payload["choices"][0]["delta"].get("content", "") + for payload in _payloads(frames) + if payload.get("choices") + ] + assert "".join(contents) == '{"a":"x"}' + assert frames[-1] == "data: [DONE]\n\n" + + +def test_a_strict_schema_violation_ends_the_stream_with_an_error_and_a_terminator(db): + frames = _collect( + db, + _stream(_output('{"wrong": true}')), + structured_requirement=_requirement(), + ) + + assert frames[-1].endswith("data: [DONE]\n\n") + error = _payloads(frames)[-1]["error"] + assert error["type"] == "invalid_model_output" + assert error["code"] == "schema_validation_failed" + + +def test_an_unmet_forced_tool_choice_ends_the_stream_with_an_error(db): + frames = _collect(db, _stream(_output("just prose")), tool_choice="required") + + assert frames[-1].endswith("data: [DONE]\n\n") + error = _payloads(frames)[-1]["error"] + assert error["param"] == "tool_choice" + assert error["code"] == "required_tool_missing" + + +def test_a_volunteered_image_does_not_satisfy_a_forced_tool_choice(db, monkeypatch): + """Chat Completions has no image tool, so an image cannot stand in for a forced call. + + The image also covers cleanup: media downloads spawned while chunks arrive have to be + cancelled on an early error return, not left writing files nothing will reference. + """ + started: list[asyncio.Task] = [] + + async def slow_download(_img): + started.append(cast(asyncio.Task, asyncio.current_task())) + await asyncio.sleep(5) + + monkeypatch.setattr("app.server.chat._process_image_item", slow_download) + + first = _output("just prose") + first.candidates[0].web_images = [WebImage(url="http://127.0.0.1:1/x.png")] + + async def stream_with_a_scheduling_gap(): + yield first + # Let the spawned task start, so cancelling it is observable. + await asyncio.sleep(0) + yield _output("just prose", delta="") + + frames = _collect(db, stream_with_a_scheduling_gap(), tool_choice="required") + + error = _payloads(frames)[-1]["error"] + assert error["code"] == "required_tool_missing" + assert frames[-1].endswith("data: [DONE]\n\n") + assert started + assert all(task.cancelled() for task in started) + + +def test_an_upstream_failure_mid_stream_is_reported_and_terminated(db): + async def failing(): + yield _output("partial") + raise RuntimeError("upstream went away") + + frames = _collect(db, failing()) + + assert frames[-1].endswith("data: [DONE]\n\n") + error = _payloads(frames)[-1]["error"] + assert error["type"] == "server_error" + assert "upstream went away" in error["message"] + + +def test_a_completed_turn_is_persisted_for_reuse(db): + """The answer is stored with the prompt, so the next turn can resume this chat.""" + _collect(db, _stream(_output("Hello"))) + + stored = db.find( + MODEL, + [AppMessage(role="user", content="hi"), AppMessage(role="assistant", content="Hello")], + ) + assert stored is not None + assert stored.client_id == CLIENT_ID + assert stored.metadata == ["c", "r", "rc"] + + +def test_a_structured_turn_is_reusable_by_replaying_what_the_client_received(db): + """What is streamed has to equal what is stored, or the next turn cannot match the prefix. + + Withholding the deltas is what makes this hold: the client is sent the validated document, + which is exactly the form persisted, rather than the raw fenced text around it. + """ + frames = _collect( + db, + _stream(_output('```json\n{"a": "x"}\n```')), + structured_requirement=_requirement(), + ) + received = "".join( + payload["choices"][0]["delta"].get("content") or "" + for payload in _payloads(frames) + if payload.get("choices") + ) + assert received == '{"a":"x"}' + + stored = db.find( + MODEL, + [AppMessage(role="user", content="hi"), AppMessage(role="assistant", content=received)], + ) + assert stored is not None + + +def test_a_failed_turn_is_not_persisted(db): + _collect( + db, + _stream(_output('{"wrong": true}')), + structured_requirement=_requirement(), + ) + assert db.keys() == [] + + +def test_responses_schema_failure_cancels_pending_media_tasks(db, monkeypatch): + started: list[asyncio.Task] = [] + + async def slow_download(_img): + started.append(cast(asyncio.Task, asyncio.current_task())) + await asyncio.sleep(5) + + monkeypatch.setattr("app.server.chat._process_image_item", slow_download) + first = _output('{"wrong": true}') + first.candidates[0].web_images = [WebImage(url="http://127.0.0.1:1/x.png")] + + async def stream_with_a_scheduling_gap(): + yield first + await asyncio.sleep(0) + + client = cast( + Any, + SimpleNamespace(id=CLIENT_ID, latest_chat_cid=None, chat_scope=lambda _temporary: None), + ) + session = cast(Any, SimpleNamespace(metadata=["c", "r", "rc"])) + response = _create_responses_real_streaming_response( + stream_with_a_scheduling_gap(), + "resp-test", + 0, + MODEL, + [AppMessage(role="user", content="hi")], + db, + MODEL, + client, + session, + ResponseCreateRequest(model=MODEL, input="hi", stream=True), + "http://testserver/", + _requirement(), + ) + + async def drain(): + frames = [] + async for chunk in response.body_iterator: + frames.append(chunk if isinstance(chunk, str) else bytes(chunk).decode("utf-8")) + return frames, [task.cancelled() for task in started] + + frames, cancelled = asyncio.run(drain()) + assert any("schema_validation_failed" in frame for frame in frames) + assert started + assert all(cancelled) + + +def test_stream_disconnect_cancels_pending_media_tasks(db, monkeypatch): + started: list[asyncio.Task] = [] + + async def slow_download(_img): + started.append(cast(asyncio.Task, asyncio.current_task())) + await asyncio.sleep(5) + + monkeypatch.setattr("app.server.chat._process_image_item", slow_download) + first = _output("Here is the image: ") + first.candidates[0].web_images = [WebImage(url="http://127.0.0.1:1/x.png")] + + async def infinite_stream(): + yield first + while True: + await asyncio.sleep(0.1) + yield _output("more") + + client = cast( + Any, + SimpleNamespace(id=CLIENT_ID, latest_chat_cid=None, chat_scope=lambda _temporary: None), + ) + session = cast(Any, SimpleNamespace(metadata=["c", "r", "rc"])) + response = _create_real_streaming_response( + infinite_stream(), + "chatcmpl-test", + 0, + MODEL, + [AppMessage(role="user", content="hi")], + db, + MODEL, + client, + session, + "http://testserver/", + ) + + async def abort_early(): + it = cast(Any, response.body_iterator) + await it.__anext__() + await it.__anext__() + await it.__anext__() + await asyncio.sleep(0) + await it.aclose() + return [task.cancelled() for task in started] + + cancelled = asyncio.run(abort_early()) + assert started + assert all(cancelled) + + +def test_responses_disconnect_cancels_pending_media_tasks(db, monkeypatch): + started: list[asyncio.Task] = [] + + async def slow_download(_img): + started.append(cast(asyncio.Task, asyncio.current_task())) + await asyncio.sleep(5) + + monkeypatch.setattr("app.server.chat._process_image_item", slow_download) + first = _output("Here is the image: ") + first.candidates[0].web_images = [WebImage(url="http://127.0.0.1:1/x.png")] + + async def infinite_stream(): + yield first + while True: + await asyncio.sleep(0.1) + yield _output("more") + + client = cast( + Any, + SimpleNamespace(id=CLIENT_ID, latest_chat_cid=None, chat_scope=lambda _temporary: None), + ) + session = cast(Any, SimpleNamespace(metadata=["c", "r", "rc"])) + response = _create_responses_real_streaming_response( + infinite_stream(), + "resp-test", + 0, + MODEL, + [AppMessage(role="user", content="hi")], + db, + MODEL, + client, + session, + ResponseCreateRequest(model=MODEL, input="hi", stream=True), + "http://testserver/", + ) + + async def abort_early(): + it = cast(Any, response.body_iterator) + # Consume events until media tasks are scheduled + for _ in range(6): + await it.__anext__() + await asyncio.sleep(0) + await it.aclose() + return [task.cancelled() for task in started] + + cancelled = asyncio.run(abort_early()) + assert started + assert all(cancelled) diff --git a/uv.lock b/uv.lock index 8446c52..891d94c 100644 --- a/uv.lock +++ b/uv.lock @@ -1,79 +1,138 @@ version = 1 revision = 3 -requires-python = "==3.13.*" +requires-python = ">=3.13" [[package]] name = "annotated-doc" -version = "0.0.4" +version = "0.0.5" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/57/ba/046ceea27344560984e26a590f90bc7f4a75b06701f653222458922b558c/annotated_doc-0.0.4.tar.gz", hash = "sha256:fbcda96e87e9c92ad167c2e53839e57503ecfda18804ea28102353485033faa4", size = 7288, upload-time = "2025-11-10T22:07:42.062Z" } +sdist = { url = "https://files.pythonhosted.org/packages/5a/8e/38aa427ed5402449e226975b649c5dc73ccadfefeb95e6aecb8f8ea4b6b6/annotated_doc-0.0.5.tar.gz", hash = "sha256:c7e58ce09192557605d8bbd92836d7e1d520ac9580096042c0bfd197efacf1bb", size = 10758, upload-time = "2026-07-28T13:50:58.129Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/1e/d3/26bf1008eb3d2daa8ef4cacc7f3bfdc11818d111f7e2d0201bc6e3b49d45/annotated_doc-0.0.4-py3-none-any.whl", hash = "sha256:571ac1dc6991c450b25a9c2d84a3705e2ae7a53467b5d111c24fa8baabbed320", size = 5303, upload-time = "2025-11-10T22:07:40.673Z" }, + { url = "https://files.pythonhosted.org/packages/3e/30/e900b21425a860e195f32e37657aa1f7c7f2b1bfb26f03ca209b90933c06/annotated_doc-0.0.5-py3-none-any.whl", hash = "sha256:117bac03a25ede5df5440e855b32d556049ca169ead221505badf432fed4b101", size = 5302, upload-time = "2026-07-28T13:50:57.239Z" }, ] [[package]] name = "annotated-types" -version = "0.7.0" +version = "0.8.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" } +sdist = { url = "https://files.pythonhosted.org/packages/5f/56/a8120250d128bed162cd73c76d45f6ef9991f3e068f62a8ee060afa3104a/annotated_types-0.8.0.tar.gz", hash = "sha256:13b2beaad985e05e2d6407ee4c4f35590b11f8d693a258a561055cac8f64cab7", size = 15893, upload-time = "2026-07-23T20:16:13.995Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" }, + { url = "https://files.pythonhosted.org/packages/99/91/8acff4f5e50511b911bbccb72b8628a49c68ce14148cd9f6431094859a90/annotated_types-0.8.0-py3-none-any.whl", hash = "sha256:f072f4d804ea359e4eaf198b1af7a8b0943881a87f31bb764f8bf219bb9419e0", size = 13427, upload-time = "2026-07-23T20:16:12.938Z" }, ] [[package]] name = "anyio" -version = "4.13.0" +version = "4.14.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "idna" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/19/14/2c5dd9f512b66549ae92767a9c7b330ae88e1932ca57876909410251fe13/anyio-4.13.0.tar.gz", hash = "sha256:334b70e641fd2221c1505b3890c69882fe4a2df910cba14d97019b90b24439dc", size = 231622, upload-time = "2026-03-24T12:59:09.671Z" } +sdist = { url = "https://files.pythonhosted.org/packages/61/cc/a381afa6efea9f496eff839d4a6a1aed3bfafc7b3ab4b0d1b243a12573dd/anyio-4.14.2.tar.gz", hash = "sha256:cfa139f3ed1a23ee8f88a145ddb5ac7605b8bbfd8592baacd7ce3d8bb4313c7f", size = 260176, upload-time = "2026-07-12T20:29:07.082Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/da/42/e921fccf5015463e32a3cf6ee7f980a6ed0f395ceeaa45060b61d86486c2/anyio-4.13.0-py3-none-any.whl", hash = "sha256:08b310f9e24a9594186fd75b4f73f4a4152069e3853f1ed8bfbf58369f4ad708", size = 114353, upload-time = "2026-03-24T12:59:08.246Z" }, + { url = "https://files.pythonhosted.org/packages/da/35/f2287558c17e29fafc8ef3daf819bb9834061cfa43bff8014f7df7f63bdc/anyio-4.14.2-py3-none-any.whl", hash = "sha256:9f505dda5ac9f0c8309b5e8bd445a8c2bf7246f3ce950121e45ea15bc41d1494", size = 125813, upload-time = "2026-07-12T20:29:05.763Z" }, +] + +[[package]] +name = "attrs" +version = "26.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9a/8e/82a0fe20a541c03148528be8cac2408564a6c9a0cc7e9171802bc1d26985/attrs-26.1.0.tar.gz", hash = "sha256:d03ceb89cb322a8fd706d4fb91940737b6642aa36998fe130a9bc96c985eff32", size = 952055, upload-time = "2026-03-19T14:22:25.026Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/64/b4/17d4b0b2a2dc85a6df63d1157e028ed19f90d4cd97c36717afef2bc2f395/attrs-26.1.0-py3-none-any.whl", hash = "sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309", size = 67548, upload-time = "2026-03-19T14:22:23.645Z" }, ] [[package]] name = "certifi" -version = "2026.2.25" +version = "2026.7.22" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/af/2d/7bf41579a8986e348fa033a31cdd0e4121114f6bce2457e8876010b092dd/certifi-2026.2.25.tar.gz", hash = "sha256:e887ab5cee78ea814d3472169153c2d12cd43b14bd03329a39a9c6e2e80bfba7", size = 155029, upload-time = "2026-02-25T02:54:17.342Z" } +sdist = { url = "https://files.pythonhosted.org/packages/a3/c2/24167ea9858356b47a87a50d39908bfdb72ceeefe0041586e704e5376b3a/certifi-2026.7.22.tar.gz", hash = "sha256:741e2c3b351ddf169a738da9f2c048608ff7f2c5cc02f1ebc6b118bb090d5d55", size = 138112, upload-time = "2026-07-22T03:35:12.644Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/9a/3c/c17fb3ca2d9c3acff52e30b309f538586f9f5b9c9cf454f3845fc9af4881/certifi-2026.2.25-py3-none-any.whl", hash = "sha256:027692e4402ad994f1c42e52a4997a9763c646b73e4096e4d5d6db8af1d6f0fa", size = 153684, upload-time = "2026-02-25T02:54:15.766Z" }, + { url = "https://files.pythonhosted.org/packages/0b/a7/71ac2cff56fec219ed242bb11b8efb69fcc4bec75db06fb7bfe35de520e6/certifi-2026.7.22-py3-none-any.whl", hash = "sha256:62f22742b58a1a33014a2b6b706588a8d7e2a88ae7bd1a6ebe8c992928483775", size = 136983, upload-time = "2026-07-22T03:35:11.276Z" }, ] [[package]] name = "cffi" -version = "2.0.0" +version = "2.1.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pycparser", marker = "implementation_name != 'PyPy'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/eb/56/b1ba7935a17738ae8453301356628e8147c79dbb825bcbc73dc7401f9846/cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529", size = 523588, upload-time = "2025-09-08T23:24:04.541Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/4b/8d/a0a47a0c9e413a658623d014e91e74a50cdd2c423f7ccfd44086ef767f90/cffi-2.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:00bdf7acc5f795150faa6957054fbbca2439db2f775ce831222b66f192f03beb", size = 185230, upload-time = "2025-09-08T23:23:00.879Z" }, - { url = "https://files.pythonhosted.org/packages/4a/d2/a6c0296814556c68ee32009d9c2ad4f85f2707cdecfd7727951ec228005d/cffi-2.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:45d5e886156860dc35862657e1494b9bae8dfa63bf56796f2fb56e1679fc0bca", size = 181043, upload-time = "2025-09-08T23:23:02.231Z" }, - { url = "https://files.pythonhosted.org/packages/b0/1e/d22cc63332bd59b06481ceaac49d6c507598642e2230f201649058a7e704/cffi-2.0.0-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:07b271772c100085dd28b74fa0cd81c8fb1a3ba18b21e03d7c27f3436a10606b", size = 212446, upload-time = "2025-09-08T23:23:03.472Z" }, - { url = "https://files.pythonhosted.org/packages/a9/f5/a2c23eb03b61a0b8747f211eb716446c826ad66818ddc7810cc2cc19b3f2/cffi-2.0.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d48a880098c96020b02d5a1f7d9251308510ce8858940e6fa99ece33f610838b", size = 220101, upload-time = "2025-09-08T23:23:04.792Z" }, - { url = "https://files.pythonhosted.org/packages/f2/7f/e6647792fc5850d634695bc0e6ab4111ae88e89981d35ac269956605feba/cffi-2.0.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f93fd8e5c8c0a4aa1f424d6173f14a892044054871c771f8566e4008eaa359d2", size = 207948, upload-time = "2025-09-08T23:23:06.127Z" }, - { url = "https://files.pythonhosted.org/packages/cb/1e/a5a1bd6f1fb30f22573f76533de12a00bf274abcdc55c8edab639078abb6/cffi-2.0.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:dd4f05f54a52fb558f1ba9f528228066954fee3ebe629fc1660d874d040ae5a3", size = 206422, upload-time = "2025-09-08T23:23:07.753Z" }, - { url = "https://files.pythonhosted.org/packages/98/df/0a1755e750013a2081e863e7cd37e0cdd02664372c754e5560099eb7aa44/cffi-2.0.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c8d3b5532fc71b7a77c09192b4a5a200ea992702734a2e9279a37f2478236f26", size = 219499, upload-time = "2025-09-08T23:23:09.648Z" }, - { url = "https://files.pythonhosted.org/packages/50/e1/a969e687fcf9ea58e6e2a928ad5e2dd88cc12f6f0ab477e9971f2309b57c/cffi-2.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d9b29c1f0ae438d5ee9acb31cadee00a58c46cc9c0b2f9038c6b0b3470877a8c", size = 222928, upload-time = "2025-09-08T23:23:10.928Z" }, - { url = "https://files.pythonhosted.org/packages/36/54/0362578dd2c9e557a28ac77698ed67323ed5b9775ca9d3fe73fe191bb5d8/cffi-2.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6d50360be4546678fc1b79ffe7a66265e28667840010348dd69a314145807a1b", size = 221302, upload-time = "2025-09-08T23:23:12.42Z" }, - { url = "https://files.pythonhosted.org/packages/eb/6d/bf9bda840d5f1dfdbf0feca87fbdb64a918a69bca42cfa0ba7b137c48cb8/cffi-2.0.0-cp313-cp313-win32.whl", hash = "sha256:74a03b9698e198d47562765773b4a8309919089150a0bb17d829ad7b44b60d27", size = 172909, upload-time = "2025-09-08T23:23:14.32Z" }, - { url = "https://files.pythonhosted.org/packages/37/18/6519e1ee6f5a1e579e04b9ddb6f1676c17368a7aba48299c3759bbc3c8b3/cffi-2.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:19f705ada2530c1167abacb171925dd886168931e0a7b78f5bffcae5c6b5be75", size = 183402, upload-time = "2025-09-08T23:23:15.535Z" }, - { url = "https://files.pythonhosted.org/packages/cb/0e/02ceeec9a7d6ee63bb596121c2c8e9b3a9e150936f4fbef6ca1943e6137c/cffi-2.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:256f80b80ca3853f90c21b23ee78cd008713787b1b1e93eae9f3d6a7134abd91", size = 177780, upload-time = "2025-09-08T23:23:16.761Z" }, +sdist = { url = "https://files.pythonhosted.org/packages/9e/ef/008a1939e372c06329a3fce4279c02f328488f3526744906eeec3da7ad5f/cffi-2.1.1.tar.gz", hash = "sha256:dd31f52ea1086513bb9df30f8fcee9b8918323ae067a3d5b78bc826a000712be", size = 530807, upload-time = "2026-08-03T21:21:18.939Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9d/f4/035513d4117049066b4779dc3b7c0c0fdad175fa13731c9f4003f1cd1478/cffi-2.1.1-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:b5bdfd1c873d4e093aabc0ca84c4ca6dbc4f752afb5c86f146d9742580c9da2e", size = 194248, upload-time = "2026-08-03T21:19:59.399Z" }, + { url = "https://files.pythonhosted.org/packages/76/af/2aeb4dbb5fc41a04161ae9ff1518de7cec08e164f44a8ce6a4cf7fd2cd1d/cffi-2.1.1-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:31348097ff5bbe827ccc41795d4dd099d9f0625e7def00ee653c137a490c2a6c", size = 196908, upload-time = "2026-08-03T21:20:00.746Z" }, + { url = "https://files.pythonhosted.org/packages/a7/46/2e5fdde8555706dd98139a910ca11be02809f3f605ce956f655d0214e100/cffi-2.1.1-cp313-cp313-macosx_10_15_x86_64.whl", hash = "sha256:9d2055050ea716bd38b7f7f1579c275386646b4894c155a3e2f3cd62ed41b7c6", size = 184805, upload-time = "2026-08-03T21:20:02.02Z" }, + { url = "https://files.pythonhosted.org/packages/55/41/4c7042f317b9217502988f0873af87e16ad606dc20f84e546e3e6ce9764c/cffi-2.1.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:19ee6127ee34de7d83ce3d371ebc5ed91addbdcc39f9ab15ce4eb35a4e534971", size = 184764, upload-time = "2026-08-03T21:20:03.141Z" }, + { url = "https://files.pythonhosted.org/packages/43/1f/1c3d90d91811c8f86ced9ed637956c54bfe5b79ca98fe976d7f8c8979f6b/cffi-2.1.1-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:6a8dddef476fab96d066d578fc88526767b836ab5ab21754e1d5bf3879c31c7c", size = 214722, upload-time = "2026-08-03T21:20:04.377Z" }, + { url = "https://files.pythonhosted.org/packages/37/6f/3b5ce4c3b2192d250f04908f2bfd91ef34552ec8f7716a5d4abdb8d67bb2/cffi-2.1.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f16c709686a78c727bbbf059f92b0bf41c6fc60deec706d2dc19f529175a6125", size = 222369, upload-time = "2026-08-03T21:20:05.544Z" }, + { url = "https://files.pythonhosted.org/packages/02/10/4b3c75dde3d9663c9e02ba05c2668b954f671d4bbe346413ca8c696b295a/cffi-2.1.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:fcd22650c908d7b7da162bbfaab594a1227a15d1643a98c68b122ac642fa2264", size = 210175, upload-time = "2026-08-03T21:20:06.75Z" }, + { url = "https://files.pythonhosted.org/packages/df/62/14f74b9543e605d17701dc797b815958b8bb70b7624ce1b832ddad48ed6c/cffi-2.1.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:aa9511c62d14da7aacc9b4bf51f3f697a621e83b2d6919008243c3aad168eea3", size = 208670, upload-time = "2026-08-03T21:20:08.04Z" }, + { url = "https://files.pythonhosted.org/packages/95/95/86342356ff5953b3fb06f7ef7c5bee212d45e770abc7218d451b9148313c/cffi-2.1.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a931079504ecc49efed7744c476a5c343a92fabf66dec2db95edb1b2fdc770e2", size = 221824, upload-time = "2026-08-03T21:20:09.274Z" }, + { url = "https://files.pythonhosted.org/packages/eb/ff/7b3429ff53aafe931ed8a5fc69f481bbef7ba6de87ddcbb63d08f483f613/cffi-2.1.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a2d7755bef5a12ed488f4ef1f1b69ee9191d7396083b755a5d2295f6edb4768b", size = 225148, upload-time = "2026-08-03T21:20:10.7Z" }, + { url = "https://files.pythonhosted.org/packages/34/34/a95870b9221e09cf4f2ce3178b1a210abdfe63a1bd357da940418d7b8d15/cffi-2.1.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e0bcb7e0f677f543555d2adff3bf19c05f66cdb4796e5ff602442ab2fe3c4ef7", size = 223564, upload-time = "2026-08-03T21:20:12.165Z" }, + { url = "https://files.pythonhosted.org/packages/70/ea/839b50531021a647fb5e929f72cf97bc1ff702b5472166164b5b6e76b851/cffi-2.1.1-cp313-cp313-win32.whl", hash = "sha256:334644fbac4eff73d985a17a91226df55d0f394160c4cfb880e084c8f7161cac", size = 175263, upload-time = "2026-08-03T21:20:13.559Z" }, + { url = "https://files.pythonhosted.org/packages/60/a6/8b149b2c3f2e11aaa1618ef64500b45f50f22c57a977a4dff1aff1f91042/cffi-2.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:1aa5645c30469b09530c4ebca77ebf8f17618293c58f8549cb1a543a50236e7d", size = 185688, upload-time = "2026-08-03T21:20:14.69Z" }, + { url = "https://files.pythonhosted.org/packages/01/9a/11f687cb39d6a3504060d5242f04f48c735afb4d3d533958a20594890cb2/cffi-2.1.1-cp313-cp313-win_arm64.whl", hash = "sha256:63bbfd5ded17c4840ac07cd8f1c21ba9d9708141f840b324f422f41b207e3973", size = 180078, upload-time = "2026-08-03T21:20:15.917Z" }, + { url = "https://files.pythonhosted.org/packages/d3/7b/d6bbf82b8b96e7391438898c42f5bd96dd02030fd5b64937d248220003e2/cffi-2.1.1-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:7dbb61fe3a7699468030f71bbe5f8a0e326a151daa91beb11a6fc1f980c55e1c", size = 194064, upload-time = "2026-08-03T21:20:17.148Z" }, + { url = "https://files.pythonhosted.org/packages/94/e6/bcc91b283be94735e268487a054004f0aa19947b6348fa367db53230abc8/cffi-2.1.1-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:f24fb43132a4c6b4cb4eb029492919b2db645be6808d738f244fd146c03c32cb", size = 196720, upload-time = "2026-08-03T21:20:18.268Z" }, + { url = "https://files.pythonhosted.org/packages/d9/99/c4b0c17cacdc9c3b8f280026286a9826d6a208c0f047591a3c3ce99b91fd/cffi-2.1.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:d28630f5854ab07ab1fd4aba756de52326c82e6be15d414b12793f1975048b54", size = 184964, upload-time = "2026-08-03T21:20:19.708Z" }, + { url = "https://files.pythonhosted.org/packages/b3/a9/9db617d05d7367c1ad0ab00b3aa6e6f9281edd689b4ee9ea0e5a84e89c97/cffi-2.1.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:661c298b4821edebead0c91edd2b00374d67ad7c5a1f7a91d4442633b79d6a72", size = 184962, upload-time = "2026-08-03T21:20:20.833Z" }, + { url = "https://files.pythonhosted.org/packages/67/b8/b42132ca113dc567d37684437b46ca1dafc885902b02a110a02d5b511857/cffi-2.1.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:58acb8ab8e295e6c5ea12f888cbb13cf21511ef2a3303a23f4325c29d17fe5c1", size = 222328, upload-time = "2026-08-03T21:20:22.118Z" }, + { url = "https://files.pythonhosted.org/packages/80/10/c5c0cbf0a657aecf59ef511409734230bf556f05a0d6c9eed7aa5c0a0166/cffi-2.1.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:456a61fa52d579ebf9df2e9552ead5129855dbaff6c1e5a9b1bc408809bdc062", size = 209985, upload-time = "2026-08-03T21:20:23.401Z" }, + { url = "https://files.pythonhosted.org/packages/d5/6c/bfa0b87b03b9238148beca990292843c9396ba069b54496596594173de7b/cffi-2.1.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:a4f00aa42f75d6e4595e8866e748cc1705adc0cddfeb2ca86d0d03993d63ba03", size = 208530, upload-time = "2026-08-03T21:20:24.628Z" }, + { url = "https://files.pythonhosted.org/packages/e9/02/4e7d553a7ac4b4238b38b3c1b80d486e9d4436f8d2acbf87a0997fe3f402/cffi-2.1.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b0431303acaea1089ad4b3e9ce4e6518193def1118d4073ca848635ee4ea2e96", size = 221525, upload-time = "2026-08-03T21:20:25.758Z" }, + { url = "https://files.pythonhosted.org/packages/82/1d/a4aaf9babd75acb4d5f223bff71533bee748dd770a382619a798960ee9ba/cffi-2.1.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:64faea20f4e2613363a1a9b9c7dd73058f3ecd00133a511e72ad7c511658f527", size = 225053, upload-time = "2026-08-03T21:20:26.985Z" }, + { url = "https://files.pythonhosted.org/packages/81/10/5dc0e7bdd18e22107054288283380fc97a06ae3f1656a106908d666a3c88/cffi-2.1.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5c58fe613dc5e5336357eff555824a314d8e43282600435c8d1cb6a7a2fedd13", size = 223213, upload-time = "2026-08-03T21:20:28.277Z" }, + { url = "https://files.pythonhosted.org/packages/0b/e9/d0061c364cde06ee43168a0d076ac1da512cbc380d44767b844ba34fe2b6/cffi-2.1.1-cp314-cp314-win32.whl", hash = "sha256:1a18a57b58cfb21fc28d72e876acf10eaed67a1ed96226f92af4df681d571c4c", size = 177682, upload-time = "2026-08-03T21:20:44.288Z" }, + { url = "https://files.pythonhosted.org/packages/a7/06/1c3e01e3ba14c39f6d10bfbac52753b7e22259e38088e5cfe1d704918690/cffi-2.1.1-cp314-cp314-win_amd64.whl", hash = "sha256:3222ba5d678f80a030e6afbcc33dc1ae5cb45facabb61cee2c7016b8432fde48", size = 187949, upload-time = "2026-08-03T21:20:45.623Z" }, + { url = "https://files.pythonhosted.org/packages/87/5b/da4e39efe18eeb89cf580ea9cfc66b6a7c3eadb808fc0cc1d3a295cb5a5d/cffi-2.1.1-cp314-cp314-win_arm64.whl", hash = "sha256:ab36d55f9ed2d067327667c2fea18dda018eb628dd6347aa01dda6cf1f5d3836", size = 182947, upload-time = "2026-08-03T21:20:46.955Z" }, + { url = "https://files.pythonhosted.org/packages/23/59/40338bf421c5accea1d45158170c87006ef1cd371b05c077e76476949728/cffi-2.1.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:7750c6449dff7864bb9bb27ddfb0267756189201a3afc911d82b3caacd70dfc3", size = 188504, upload-time = "2026-08-03T21:20:29.495Z" }, + { url = "https://files.pythonhosted.org/packages/7d/47/5ecf1023850036e674c77ec4de86182d309ae344e39e7cba984b7df5d647/cffi-2.1.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:0beceaabe56af686895136a2de78db54ecd8e4046b236b8fd6d6cb61389e9bf2", size = 188259, upload-time = "2026-08-03T21:20:31.291Z" }, + { url = "https://files.pythonhosted.org/packages/2a/9c/92934c3bea9f785b23eba304538c0b4d37a2a96d2431eb3a1bc87a11aa19/cffi-2.1.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:49cbc70e6542d4ccccb936558d1064a8012541e78f821f955cff24e357776c94", size = 223864, upload-time = "2026-08-03T21:20:32.571Z" }, + { url = "https://files.pythonhosted.org/packages/4d/45/ba4c93527bc38616a8bd36488acb69a2212d60486794f0c1f318949bbb76/cffi-2.1.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:e2d65b31f36619cda3999b78b2aa9632e76b78448e7a56fc4240824200e7c4fc", size = 211538, upload-time = "2026-08-03T21:20:33.808Z" }, + { url = "https://files.pythonhosted.org/packages/80/e9/b6ef565e452acb932fb0cb5443f44a78efbd1233e566f02b5a83855e9115/cffi-2.1.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:28907ab9bfb6aa13184cfc17c6b8e1023c5ab6fd7076d8c20a35e59fe04f8f29", size = 210688, upload-time = "2026-08-03T21:20:34.974Z" }, + { url = "https://files.pythonhosted.org/packages/9a/95/eff5f0cee78d2eabc7eebffec40d3fc1876b5f3c95582e018bb4b99601f2/cffi-2.1.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:51b31d1c98274844cfd7838ce00bfc27c7423a4dc00fc0772fc3331c2cc90676", size = 223803, upload-time = "2026-08-03T21:20:36.564Z" }, + { url = "https://files.pythonhosted.org/packages/fa/01/579d39fb8bef00a335a23d83757b44feb24cd6345a2c451b64cb67b9c362/cffi-2.1.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5e7cecbaadb83884793e05828cee59b210b24583b9c7425d0ba6a754fe22eb4e", size = 226763, upload-time = "2026-08-03T21:20:37.816Z" }, + { url = "https://files.pythonhosted.org/packages/8d/b0/0b44f47c60b01b57b6e2bbd92343f13a85a1d93bc46ccf6e47e244acd99c/cffi-2.1.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:25792eac27877609e7bb06d42ff88278a6624fff2ba9bbb523c09616b117e80f", size = 225688, upload-time = "2026-08-03T21:20:38.959Z" }, + { url = "https://files.pythonhosted.org/packages/eb/d2/3b7176cb570a1d3e27faf67b72f591af508036e0d8b2be2ef9af9e8c84bb/cffi-2.1.1-cp314-cp314t-win32.whl", hash = "sha256:8ef53b2de9bcb9197d31854256575d59dbac0cba72ac627bb291ef5eceb74be4", size = 182868, upload-time = "2026-08-03T21:20:40.388Z" }, + { url = "https://files.pythonhosted.org/packages/56/78/31f00c1bcd97c9bbf55f1bfdf5bc809a5de8887473e90bb9960dca825e80/cffi-2.1.1-cp314-cp314t-win_amd64.whl", hash = "sha256:616f097f2fe415bc92a247f02e11f634e1f9e9a83d327e3c915c15089c87869e", size = 194104, upload-time = "2026-08-03T21:20:41.725Z" }, + { url = "https://files.pythonhosted.org/packages/7b/1b/58496f2ed0a35de575250c02a43ab3cc2c04d494a88fed31c1cabc0fd176/cffi-2.1.1-cp314-cp314t-win_arm64.whl", hash = "sha256:ad2c86c495b899d862ea0f4b42891b8713a3bd45dd4105c7fd51c2a72f39f3a5", size = 186402, upload-time = "2026-08-03T21:20:43.042Z" }, + { url = "https://files.pythonhosted.org/packages/c1/8f/9ebe220eab48a093d1a5a5e339ab0dc7316eef3bb04d63c42f0251b61f50/cffi-2.1.1-cp315-cp315-ios_13_0_arm64_iphoneos.whl", hash = "sha256:dddad92b554513a31f272570678ba307fb9f618f05e3d4a5eacafff9eae03e1d", size = 194043, upload-time = "2026-08-03T21:20:48.179Z" }, + { url = "https://files.pythonhosted.org/packages/ff/69/844bad3ece306c4782c2ecb93597035b6690d48704b803914c199da1e8b3/cffi-2.1.1-cp315-cp315-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:da0e573f9f97159390c89d9f1a9e41908b66d408cc5b58d08cf3847d844c531b", size = 196737, upload-time = "2026-08-03T21:20:49.457Z" }, + { url = "https://files.pythonhosted.org/packages/1b/8a/af668013284634733f02d683458a0728739c7d6ddb5e14cb0c20832266fe/cffi-2.1.1-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:fb92203a88b3d3053034db775110081c49d28be6551923805e039924093761e4", size = 184933, upload-time = "2026-08-03T21:20:50.639Z" }, + { url = "https://files.pythonhosted.org/packages/0c/75/2f5207ff6d1a613133b23a5203cc0c2a628313b5eb3974d7956ae3c57950/cffi-2.1.1-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:2ae64be792b8966f2c69538199728b290e34726562896df1e5dc8ffd8d8188e8", size = 185002, upload-time = "2026-08-03T21:20:52.173Z" }, + { url = "https://files.pythonhosted.org/packages/e2/31/9e1313b0a6e30e91b3b3d3fff51ae99c857c07738e3afcce1f7334e1b7ab/cffi-2.1.1-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:507a24c282e0f42f8ed737cf048572cbf580468da5555764a8331735e9c736b6", size = 222271, upload-time = "2026-08-03T21:20:53.462Z" }, + { url = "https://files.pythonhosted.org/packages/50/e3/f6234a833e6e08c7007003074723c406559eecf9b48dfc97471e5a8eb7a0/cffi-2.1.1-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:246fa40ce8645a614ff682e0b70f37134e460eaf93a775e0cbe3cca585a67a80", size = 209919, upload-time = "2026-08-03T21:20:54.783Z" }, + { url = "https://files.pythonhosted.org/packages/0d/fc/5f74e293fced6edb51af3a46c4ccf6c23c9943774ecb375ddbd522c76add/cffi-2.1.1-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:471cee653ae88de62096552e6d24ccb4a5adb8c8c9f10b5054d0122c15bf2779", size = 208529, upload-time = "2026-08-03T21:20:56.066Z" }, + { url = "https://files.pythonhosted.org/packages/44/16/29e6d01b388bef055ecd6ca8244b3f4d336bd09e92d5d892187b9601084e/cffi-2.1.1-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:aeae0e330c9f6acd681f647d46cefd30c29f93e3392882e792e82080c9691399", size = 221630, upload-time = "2026-08-03T21:20:57.336Z" }, + { url = "https://files.pythonhosted.org/packages/a4/18/fa7f1f6857d5eb88a4ca99ffcbfb7c387a287ccc154c64a73e86314745d7/cffi-2.1.1-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:42a494cee34437f05546455144f2b5d9ac09b1face62bcfce597d2e521066688", size = 225134, upload-time = "2026-08-03T21:20:58.675Z" }, + { url = "https://files.pythonhosted.org/packages/e0/9f/e8e3dfa04a1b4c241f8c91faacad872b4d4efd051d49764ad4e2fd4b9fea/cffi-2.1.1-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:cc572dace3f60ef98d7b12ff411d20f5362feb31a0439eab0085bbfd349982d7", size = 223197, upload-time = "2026-08-03T21:20:59.968Z" }, + { url = "https://files.pythonhosted.org/packages/f8/7e/8debeb04f1ab9fe2a6963964cd6f1aaf7192627b83926586a6a4e089c9fa/cffi-2.1.1-cp315-cp315-win32.whl", hash = "sha256:4f42141fc14250de6dde5ee7ea4432be017252d91f19c5ad043c084cea629cac", size = 177683, upload-time = "2026-08-03T21:21:14.901Z" }, + { url = "https://files.pythonhosted.org/packages/e0/31/5158704cc474ab65c1647932e88be78dc0873f47130e253be38bcaf13d01/cffi-2.1.1-cp315-cp315-win_amd64.whl", hash = "sha256:e6e8cff14d6fb0be70a09c0bdc58096f501952d04624ebf867e0e56da2df8960", size = 187897, upload-time = "2026-08-03T21:21:16.108Z" }, + { url = "https://files.pythonhosted.org/packages/cc/4b/b3a2da8570c704ffc0f9762cdc3ec0f02c8573798e0b5cf7f11c82bbb70f/cffi-2.1.1-cp315-cp315-win_arm64.whl", hash = "sha256:27350daa11d4f10c540e6e89dada4c54feb7256ad03e9a4dc075ebad7ba360d1", size = 182935, upload-time = "2026-08-03T21:21:17.271Z" }, + { url = "https://files.pythonhosted.org/packages/d0/ef/5443574510a1207e6f6bc38ba6e1f1de36cb48fef07b2728bb896a21f430/cffi-2.1.1-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:c26608d2222fb1e94487e4a387d85f13eb55d5ed725cb25a0c589ac4ee60e7bc", size = 188464, upload-time = "2026-08-03T21:21:01.163Z" }, + { url = "https://files.pythonhosted.org/packages/7e/ae/a56fa8c4686ad50e148fcbc8d3ae0d03915ff5c30d795058988c24118cef/cffi-2.1.1-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:4be96343e422f2dfcd12ab5c9f5aebe03f82f737c6bffeca6830b3875cb44aab", size = 188262, upload-time = "2026-08-03T21:21:02.382Z" }, + { url = "https://files.pythonhosted.org/packages/53/b2/6187f46f2912276a3ae284076109cc5c8680482f11f766ccf26db4a86427/cffi-2.1.1-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:937c0052c05a31ca1daf18de3158eed4dbfcb9cc107adbea227728d647be701e", size = 223779, upload-time = "2026-08-03T21:21:03.553Z" }, + { url = "https://files.pythonhosted.org/packages/8a/f6/c3ad28bd19f77047a03084424fbd4cbe997303267c14423737324be0385d/cffi-2.1.1-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:df423d40ee8654634421812bc3b196da3f9bd7d32929da813f8394c4348a5358", size = 211520, upload-time = "2026-08-03T21:21:04.863Z" }, + { url = "https://files.pythonhosted.org/packages/a0/cd/ccac9013a5bd9fd764de118674ab9c805b5ca10c19270d90ee273f8b2240/cffi-2.1.1-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:a730a083190634c65cca36ba5f489531576ebd79bcd5c8e172130f6453127231", size = 210673, upload-time = "2026-08-03T21:21:06.223Z" }, + { url = "https://files.pythonhosted.org/packages/52/86/2976131c639aead931c5bee5aba67e4b09fbeb8018b6f282f70803f923a7/cffi-2.1.1-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:363e05fa78e15116c3c32c210ee36884fd6b9afa6d440e47112c3bd511d64cb6", size = 223835, upload-time = "2026-08-03T21:21:07.539Z" }, + { url = "https://files.pythonhosted.org/packages/ac/0c/33a7aeab2f9c76918c52e084beb39c570db3588133412929e8ec06fab90b/cffi-2.1.1-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:770de9db11e84213beec501cfcaa013b019820ca881e03344dea5844f7876d94", size = 226705, upload-time = "2026-08-03T21:21:08.774Z" }, + { url = "https://files.pythonhosted.org/packages/e3/26/2cde30fdde421130bfc18f70395731a6e6b2053c6a1978a5258ff04e72fa/cffi-2.1.1-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:7da0c5eff80f0197f3b3d1232ec5a682a9325f4ae9016a78f5f5ca35f9ced1f5", size = 225539, upload-time = "2026-08-03T21:21:09.911Z" }, + { url = "https://files.pythonhosted.org/packages/6d/cd/a361394c94b2129d604bb846f624a8e88255a3ee33129c434a00d715e64f/cffi-2.1.1-cp315-cp315t-win32.whl", hash = "sha256:06c72bb76605a4b0cd0aad6930b69d4baf7dd5d806cfc409b824191099700e66", size = 182707, upload-time = "2026-08-03T21:21:11.226Z" }, + { url = "https://files.pythonhosted.org/packages/9b/b5/ba2b299993c26577d529b6ae29841f9e15b9fcf004d65f423f4fcf94ade9/cffi-2.1.1-cp315-cp315t-win_amd64.whl", hash = "sha256:d9c275eaacd24aa73f94ffd6de08fc3f932424d8b6c376f4bed7cde376fe7bc3", size = 193772, upload-time = "2026-08-03T21:21:12.39Z" }, + { url = "https://files.pythonhosted.org/packages/aa/29/35e016098c814cd93de9cd320c66b5bfba14dc6ecedd3cb518fa7c408c69/cffi-2.1.1-cp315-cp315t-win_arm64.whl", hash = "sha256:d18e5ac0f2f03f4f518d3e23db0f0cad7faa1da8620e9c09461d443bbf6e6692", size = 186360, upload-time = "2026-08-03T21:21:13.636Z" }, ] [[package]] name = "click" -version = "8.3.2" +version = "8.4.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "colorama", marker = "sys_platform == 'win32'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/57/75/31212c6bf2503fdf920d87fee5d7a86a2e3bcf444984126f13d8e4016804/click-8.3.2.tar.gz", hash = "sha256:14162b8b3b3550a7d479eafa77dfd3c38d9dc8951f6f69c78913a8f9a7540fd5", size = 302856, upload-time = "2026-04-03T19:14:45.118Z" } +sdist = { url = "https://files.pythonhosted.org/packages/76/d4/81420972a676e8ffea40450d8c8c92943e7218a78fe9b64359836cc9876b/click-8.4.2.tar.gz", hash = "sha256:9a6cea6e60b17ebe0a44c5cc636d94f09bd66142c1cd7d8b4cd731c4917a15f6", size = 338000, upload-time = "2026-06-24T17:45:15.148Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e4/20/71885d8b97d4f3dde17b1fdb92dbd4908b00541c5a3379787137285f602e/click-8.3.2-py3-none-any.whl", hash = "sha256:1924d2c27c5653561cd2cae4548d1406039cb79b858b747cfea24924bbc1616d", size = 108379, upload-time = "2026-04-03T19:14:43.505Z" }, + { url = "https://files.pythonhosted.org/packages/fb/e2/79c688af8b210d232694e31e59da9f6ec747bae31c3f5946e4e9b98860d5/click-8.4.2-py3-none-any.whl", hash = "sha256:e6f9f66136c816745b9d65817da91d61d957fb16e02e4dcd0552553c5a197b76", size = 119243, upload-time = "2026-06-24T17:45:13.73Z" }, ] [[package]] @@ -87,32 +146,39 @@ wheels = [ [[package]] name = "curl-cffi" -version = "0.15.0" +version = "0.16.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "certifi" }, { name = "cffi" }, - { name = "rich" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/48/5b/89fcfebd3e5e85134147ac99e9f2b2271165fd4d71984fc65da5f17819b7/curl_cffi-0.15.0.tar.gz", hash = "sha256:ea0c67652bf6893d34ee0f82c944f37e488f6147e9421bef1771cc6545b02ded", size = 196437, upload-time = "2026-04-03T11:12:31.525Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/5e/42/54ddd442c795f30ce5dd4e49f87ce77505958d3777cd96a91567a3975d2a/curl_cffi-0.15.0-cp310-abi3-macosx_10_9_x86_64.whl", hash = "sha256:bda66404010e9ed743b1b83c20c86f24fe21a9a6873e17479d6e67e29d8ded28", size = 2795267, upload-time = "2026-04-03T11:11:46.48Z" }, - { url = "https://files.pythonhosted.org/packages/83/2d/3915e238579b3c5a92cead5c79130c3b8d20caaba7616cc4d894650e1d6b/curl_cffi-0.15.0-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:a25620d9bf989c9c029a7d1642999c4c265abb0bad811deb2f77b0b5b2b12e5b", size = 2573544, upload-time = "2026-04-03T11:11:47.951Z" }, - { url = "https://files.pythonhosted.org/packages/2a/b3/9d2f1057749a1b07ba1989db3c1503ce8bed998310bae9aea2c43aa64f20/curl_cffi-0.15.0-cp310-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:582e570aa2586b96ed47cf4a17586b9a3c462cbe43f780487c3dc245c6ef1527", size = 10515369, upload-time = "2026-04-03T11:11:50.126Z" }, - { url = "https://files.pythonhosted.org/packages/b5/1d/6d10dded5ce3fd8157e558ebd97d09e551b77a62cdc1c31e93d0a633cee5/curl_cffi-0.15.0-cp310-abi3-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:838e48212447d9c81364b04707a5c861daf08f8320f9ecb3406a8919d1d5c3b3", size = 10160045, upload-time = "2026-04-03T11:11:52.664Z" }, - { url = "https://files.pythonhosted.org/packages/5c/12/c70b835487ace3b9ba1502631912e3440082b8ae3a162f60b59cb0b6444d/curl_cffi-0.15.0-cp310-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:2b6c847d86283b07ae69bb72c82eb8a59242277142aa35b89850f89e792a02fc", size = 11090433, upload-time = "2026-04-03T11:11:55.049Z" }, - { url = "https://files.pythonhosted.org/packages/ea/0d/78edcc4f71934225db99df68197a107386d59080742fc7bf6bb4d007924f/curl_cffi-0.15.0-cp310-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:9e5e69eee735f659287e2c84444319d68a1fa68dd37abf228943a4074864283a", size = 10479178, upload-time = "2026-04-03T11:11:57.685Z" }, - { url = "https://files.pythonhosted.org/packages/5b/84/1e101c1acb1ea2f0b4992f5c3024f596d8e21db0d53540b9d583f673c4e7/curl_cffi-0.15.0-cp310-abi3-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:aa1323950224db24f4c510d010b3affa02196ca853fb424191fa917a513d3f4b", size = 10317051, upload-time = "2026-04-03T11:12:00.295Z" }, - { url = "https://files.pythonhosted.org/packages/28/42/8ef236b22a6c23d096c85a1dc507efe37bfdfc7a2f8a4b34efb590197369/curl_cffi-0.15.0-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:41f80170ba844009273b2660da1964ec31e99e5719d16b3422ada87177e32e13", size = 11299660, upload-time = "2026-04-03T11:12:02.791Z" }, - { url = "https://files.pythonhosted.org/packages/1d/01/56aeb055d962da87a1be0d74c6c644e251c7e88129b5471dc44ac724e678/curl_cffi-0.15.0-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:1977e1e12cfb5c11352cbb74acef1bed24eb7d226dab61ca57c168c21acd4d61", size = 11945049, upload-time = "2026-04-03T11:12:05.912Z" }, - { url = "https://files.pythonhosted.org/packages/d8/8c/2abf99a38d6340d66cf0557e0c750ef3f8883dfc5d450087e01c85861343/curl_cffi-0.15.0-cp310-abi3-win_amd64.whl", hash = "sha256:5a0c1896a0d5a5ac1eb89cd24b008d2b718dd1df6fd2f75451b59ca66e49e572", size = 1661649, upload-time = "2026-04-03T11:12:07.948Z" }, - { url = "https://files.pythonhosted.org/packages/3d/39/dfd54f2240d3a9b96d77bacc62b97813b35e2aa8ecf5cd5013c683f1ba96/curl_cffi-0.15.0-cp310-abi3-win_arm64.whl", hash = "sha256:a6d57f8389273a3a1f94370473c74897467bcc36af0a17336989780c507fa43d", size = 1410741, upload-time = "2026-04-03T11:12:10.073Z" }, - { url = "https://files.pythonhosted.org/packages/19/6a/c24df8a4fc22fa84070dcd94abeba43c15e08cc09e35869565c0bad196fd/curl_cffi-0.15.0-cp313-abi3-android_24_arm64_v8a.whl", hash = "sha256:4682dc38d4336e0eb0b185374db90a760efde63cbea994b4e63f3521d44c4c92", size = 7190427, upload-time = "2026-04-03T11:12:12.142Z" }, +sdist = { url = "https://files.pythonhosted.org/packages/b4/23/d32e113b16dbfb458bea408871ed98dd12f306a366a04215e84537e0af7e/curl_cffi-0.16.0.tar.gz", hash = "sha256:b00b423da8028eb6221e3b63bcd63d681150c07cee8b16000d1f7ea292731895", size = 238344, upload-time = "2026-08-01T13:45:12.372Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fd/fe/0c330de78421af13e6384ab948e3adbcd4c638b06b53b7fff108bf1db121/curl_cffi-0.16.0-cp310-abi3-macosx_10_9_x86_64.whl", hash = "sha256:6128021320f74999ec1216c1817b2c3adcb0f334d204add1ccf18e248bf7efcb", size = 3023503, upload-time = "2026-08-01T13:44:33.452Z" }, + { url = "https://files.pythonhosted.org/packages/2e/49/3b502d0d09e427b1bdec4f7339bb115c971c9b3fdaf355d02ca06e97ad61/curl_cffi-0.16.0-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:edd5f6e8f122157f4d2351b0b5e48e6a1c0677a2064da71451bb30ef57af19ba", size = 2780341, upload-time = "2026-08-01T13:44:35.131Z" }, + { url = "https://files.pythonhosted.org/packages/2e/b5/b341f96f9fa12b28d1150913a1f9007a09a36757c81b4100373c5bdbf78b/curl_cffi-0.16.0-cp310-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:93615d44f23e56c1256700c2e78de4b879310f39a5621828ea7e2a5ecc04bdda", size = 12824596, upload-time = "2026-08-01T13:44:36.556Z" }, + { url = "https://files.pythonhosted.org/packages/f5/3b/d600b20bff0c55b80b156dc9be0de7c3b3ee2d29977d0a839ea703fab978/curl_cffi-0.16.0-cp310-abi3-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:3c31e71bf68a9c02a279a184ec9c0ea7c80ce1ef4f1d35073fae3124eb3a7868", size = 12647842, upload-time = "2026-08-01T13:44:38.814Z" }, + { url = "https://files.pythonhosted.org/packages/e4/45/9208864ec429558efac168088e50f8a91b947f742ee128cff55b88c7b635/curl_cffi-0.16.0-cp310-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:182416f07d71a342240554fa62c22e591999b78c225b21d3fe27d9f807420dd6", size = 13472637, upload-time = "2026-08-01T13:44:40.893Z" }, + { url = "https://files.pythonhosted.org/packages/eb/c8/1639a1d9c8b64d0323330b219b7207a54b14fc54982c30cb08c8cc95aa16/curl_cffi-0.16.0-cp310-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:d95c0deccc2184eeee7c2aa18d07de261184b418210995aabd0d29f98717a05c", size = 12828918, upload-time = "2026-08-01T13:44:43.049Z" }, + { url = "https://files.pythonhosted.org/packages/0a/02/bcdf03ea583a445280568c9b163c10668037ee09ece62e78c15846a62df0/curl_cffi-0.16.0-cp310-abi3-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ce1f823bc5ce675291a7cf14781496775dfae9298638c25059f2565f6a58a704", size = 12604731, upload-time = "2026-08-01T13:44:45.239Z" }, + { url = "https://files.pythonhosted.org/packages/17/8b/4ddae52044c537ace13ad7e46dded8e9340a64e0704e320455c5151108a8/curl_cffi-0.16.0-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:e52586a9dc4ed5e75faa39be0f30353b10cdc7410bad276beb013085e974bb44", size = 12576433, upload-time = "2026-08-01T13:44:47.626Z" }, + { url = "https://files.pythonhosted.org/packages/5a/f5/38ee2f039db7832f07ce91f6a3d22d87ebfcfaa541130371ac1faa5caea9/curl_cffi-0.16.0-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:ce87f301b31147711c3aebc86fb7e16d8dc48f7e5df272c1bea760c687e28eef", size = 13240699, upload-time = "2026-08-01T13:44:49.727Z" }, + { url = "https://files.pythonhosted.org/packages/ad/03/b9df2973f1119f9d11d8fb3bf2682e5ffe5c52ef3ab89f720473c60fe97e/curl_cffi-0.16.0-cp310-abi3-win_amd64.whl", hash = "sha256:e22a8212d830108e977ff394237f637238e265f5f65037d6c1ee71ea8cc03bcb", size = 1976497, upload-time = "2026-08-01T13:44:51.839Z" }, + { url = "https://files.pythonhosted.org/packages/6b/8b/092beeb5fbe3b7370666708eb5618a9e593ffc80ecb2c97c3395158d270b/curl_cffi-0.16.0-cp310-abi3-win_arm64.whl", hash = "sha256:095fc36e4988736f31521d6fe0aa1f243dba22656b4818fc2fb3b7a547e7a9ba", size = 1711122, upload-time = "2026-08-01T13:44:53.242Z" }, + { url = "https://files.pythonhosted.org/packages/15/ea/81cf3858b256494b31a554cf76bbd345def3ea7e7a1a592cc515633b4e28/curl_cffi-0.16.0-cp313-abi3-android_24_arm64_v8a.whl", hash = "sha256:06b1c7e07af8ff7c4c5ce4086ea89cc582ebff9adff4a37cfffa5f5de5d5b943", size = 8603463, upload-time = "2026-08-01T13:44:55.003Z" }, + { url = "https://files.pythonhosted.org/packages/88/a5/56d25581fe34f3a6ac5470e4713907731abf7736fcb7373ef1d53db25a83/curl_cffi-0.16.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:226c038cfc85db5190c3d4ec1737a897e7651a45fc794ad384634633b1b5b92f", size = 3024017, upload-time = "2026-08-01T13:44:57.248Z" }, + { url = "https://files.pythonhosted.org/packages/41/b8/ea215edaedcc79fee1eb2557074657d3e4ed89120e9e5c8951134fcf19dc/curl_cffi-0.16.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1efd99f7df6e32cbcef7d5d1a0136141da45ff850edf5fc1845c8bed1d06fb95", size = 2780695, upload-time = "2026-08-01T13:44:58.889Z" }, + { url = "https://files.pythonhosted.org/packages/54/16/946c71c7ff94079c02438a1e53bb736651dab84e5b56991130348d821e3a/curl_cffi-0.16.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:6c540b625979b618bff1339e998058c0a36fb2ef93c336b3ef4695c3dae6decd", size = 12829729, upload-time = "2026-08-01T13:45:00.539Z" }, + { url = "https://files.pythonhosted.org/packages/29/4f/0c386128b18ee664ab3b5a44a1d08c8b841163789b77a8ccf8a95f435678/curl_cffi-0.16.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:98f98848aed5d1cb0d5393c46ab84acd73b160c21beb49d6fa612d3cff926478", size = 13481749, upload-time = "2026-08-01T13:45:02.732Z" }, + { url = "https://files.pythonhosted.org/packages/c1/11/fddf4e9bb6bec4dec12d998579a4bd563e6c107f7056aeb8a0f8aee8426d/curl_cffi-0.16.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:2c93c2f4cf5308f40b07516d57c5f499752387b12a4611420b665b1b958aaa87", size = 12583834, upload-time = "2026-08-01T13:45:05.108Z" }, + { url = "https://files.pythonhosted.org/packages/c5/f5/089e00306984ba13e593f531b44083b31ecc1b1b482e23a9976b29155b2e/curl_cffi-0.16.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:75e5898b3066b64a68fa1eb72552a2e027d61c9d2020657ee2fc3e73d4b39db0", size = 13247624, upload-time = "2026-08-01T13:45:07.38Z" }, + { url = "https://files.pythonhosted.org/packages/49/e5/16fe4881f4155afd5013e7d937fe9a204ee948db032f2adc0c34da2a7494/curl_cffi-0.16.0-cp314-cp314t-win_amd64.whl", hash = "sha256:3a1c8a7469453d09b500c47c83945179c4dd31c327906791e54292a06fec080a", size = 2029539, upload-time = "2026-08-01T13:45:09.619Z" }, + { url = "https://files.pythonhosted.org/packages/86/57/c52fc76510a9ccd5629bc981bde877403562fc643dc68c5f06cd0b4c41c8/curl_cffi-0.16.0-cp314-cp314t-win_arm64.whl", hash = "sha256:c3dd33eaf267d017bfac09b4f71af1d94045dee5518ce18af380462e29f92fad", size = 1778763, upload-time = "2026-08-01T13:45:11.051Z" }, ] [[package]] name = "fastapi" -version = "0.135.3" +version = "0.141.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "annotated-doc" }, @@ -121,9 +187,9 @@ dependencies = [ { name = "typing-extensions" }, { name = "typing-inspection" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/f7/e6/7adb4c5fa231e82c35b8f5741a9f2d055f520c29af5546fd70d3e8e1cd2e/fastapi-0.135.3.tar.gz", hash = "sha256:bd6d7caf1a2bdd8d676843cdcd2287729572a1ef524fc4d65c17ae002a1be654", size = 396524, upload-time = "2026-04-01T16:23:58.188Z" } +sdist = { url = "https://files.pythonhosted.org/packages/8a/02/91e3416a8fdd715abb903a952a6bec7cdd8d14eed55d415fc8595524c319/fastapi-0.141.1.tar.gz", hash = "sha256:e8822fc40db1e1858054d7a949a888695bc9bdce70139178e33bd2871a453ca1", size = 425799, upload-time = "2026-07-29T17:18:05.568Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/84/a4/5caa2de7f917a04ada20018eccf60d6cc6145b0199d55ca3711b0fc08312/fastapi-0.135.3-py3-none-any.whl", hash = "sha256:9b0f590c813acd13d0ab43dd8494138eb58e484bfac405db1f3187cfc5810d98", size = 117734, upload-time = "2026-04-01T16:23:59.328Z" }, + { url = "https://files.pythonhosted.org/packages/cb/03/10388a42375ee7e4ac9b94eb2c5c569c8b5795e377e701c9ac3ad63de890/fastapi-0.141.1-py3-none-any.whl", hash = "sha256:bfb91aa2d334c61cb35ba9a116fc123b3d3df31640b801cf57a7a78ec3f603b3", size = 131954, upload-time = "2026-07-29T17:18:04.364Z" }, ] [[package]] @@ -135,18 +201,23 @@ dependencies = [ { name = "fastapi" }, { name = "gemini-webapi" }, { name = "httptools" }, + { name = "jsonschema" }, { name = "lmdb" }, { name = "loguru" }, { name = "orjson" }, { name = "pydantic-settings", extra = ["yaml"] }, + { name = "regex" }, { name = "uvicorn" }, { name = "uvloop", marker = "sys_platform != 'win32'" }, ] [package.optional-dependencies] dev = [ + { name = "httpx2" }, + { name = "pyright" }, { name = "pytest" }, { name = "ruff" }, + { name = "ty" }, ] [package.dev-dependencies] @@ -156,17 +227,22 @@ dev = [ [package.metadata] requires-dist = [ - { name = "curl-cffi", specifier = ">=0.14.0" }, - { name = "fastapi", specifier = ">=0.135.0" }, - { name = "gemini-webapi", specifier = ">=1.19.2" }, - { name = "httptools", specifier = ">=0.7.1" }, - { name = "lmdb", specifier = ">=1.7.5" }, + { name = "curl-cffi", specifier = ">=0.16.0" }, + { name = "fastapi", specifier = ">=0.141.1" }, + { name = "gemini-webapi", specifier = ">=2.1.0,<3" }, + { name = "httptools", specifier = ">=0.8.0" }, + { name = "httpx2", marker = "extra == 'dev'" }, + { name = "jsonschema", specifier = ">=4.26.0" }, + { name = "lmdb", specifier = ">=2.3.0" }, { name = "loguru", specifier = ">=0.7.3" }, - { name = "orjson", specifier = ">=3.11.7" }, - { name = "pydantic-settings", extras = ["yaml"], specifier = ">=2.13.1" }, - { name = "pytest", marker = "extra == 'dev'", specifier = ">=9.0.2" }, - { name = "ruff", marker = "extra == 'dev'", specifier = ">=0.15.4" }, - { name = "uvicorn", specifier = ">=0.41.0" }, + { name = "orjson", specifier = ">=3.11.9" }, + { name = "pydantic-settings", extras = ["yaml"], specifier = ">=2.15.0" }, + { name = "pyright", marker = "extra == 'dev'" }, + { name = "pytest", marker = "extra == 'dev'" }, + { name = "regex", specifier = ">=2026.7.19" }, + { name = "ruff", marker = "extra == 'dev'" }, + { name = "ty", marker = "extra == 'dev'" }, + { name = "uvicorn", specifier = ">=0.52.3" }, { name = "uvloop", marker = "sys_platform != 'win32'", specifier = ">=0.22.1" }, ] provides-extras = ["dev"] @@ -176,7 +252,7 @@ dev = [{ name = "gemini-fastapi", extras = ["dev"] }] [[package]] name = "gemini-webapi" -version = "2.0.0" +version = "2.1.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "curl-cffi" }, @@ -184,9 +260,9 @@ dependencies = [ { name = "orjson" }, { name = "pydantic" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/2c/2a/1d46470ec287f978565b000bf4fd9cc3eee8f4193ceec70094dc12a724a3/gemini_webapi-2.0.0.tar.gz", hash = "sha256:5ee9d8ad9fd4c7fdc50ebfd152c9cf1dc4c30f7e9984bae6dffed9a0cb039735", size = 300490, upload-time = "2026-04-06T21:19:16.427Z" } +sdist = { url = "https://files.pythonhosted.org/packages/88/1b/895b9f018864ecbeb9bfed65c73c1f787212f175c183441c8d9f4242763f/gemini_webapi-2.1.0.tar.gz", hash = "sha256:08e1e3c659134b2b99c4c1980d4ff9c896fefbdf14b4cd3a9d8326f6cbe87eaa", size = 334969, upload-time = "2026-08-15T18:14:38.942Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/4f/9b/bae89ced30ce74ae96793c05c2eb3e92de6fe3e619f91802aba686dd8027/gemini_webapi-2.0.0-py3-none-any.whl", hash = "sha256:6f2b922a5923afbb432c3c95cb6edd66e73cf5d99c95e9e40e63912e4131aff8", size = 92815, upload-time = "2026-04-06T21:19:14.933Z" }, + { url = "https://files.pythonhosted.org/packages/09/da/5f137f06a36dfa4232122cc51acd2984f3225b02daefa4c1a5e39cc81230/gemini_webapi-2.1.0-py3-none-any.whl", hash = "sha256:ce96cef1472d3b6aab906c7a45b6d400bd737bbb05baee8d3689ef91bd6620f7", size = 115365, upload-time = "2026-08-15T18:14:37.446Z" }, ] [[package]] @@ -198,28 +274,80 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, ] +[[package]] +name = "httpcore2" +version = "2.10.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "h11" }, + { name = "truststore" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a9/83/a896fc59940fc5a6e2aff3a4be1d92fa890112936803b331cae75a993c34/httpcore2-2.10.0.tar.gz", hash = "sha256:13c0cc3d1919d4f28457f60cd2c2abe04113a8af184ccf1142811beba936f9dc", size = 67427, upload-time = "2026-08-09T09:11:32.123Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e5/4f/d149104195a35e2853a2fc203a8e3477747e58c80e17dda686dace174383/httpcore2-2.10.0-py3-none-any.whl", hash = "sha256:7df06cfb34070cae4f7c89be69dc1095eca138e9704ceffb98d25c1912ab6f01", size = 83000, upload-time = "2026-08-09T09:11:29.555Z" }, +] + [[package]] name = "httptools" -version = "0.7.1" +version = "0.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/43/e5/d471fcb0e14523fe1c3f4ba58ca52480e7bd70ad7109a3846bc75892f7fb/httptools-0.8.0.tar.gz", hash = "sha256:6b2a32f18d97e16e90827d7a819ffa8dbd8cc245fc4e1fa9d1095b54ef4bd999", size = 271342, upload-time = "2026-05-25T22:17:48.841Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5e/e5/8cfcabc5546e8022f168be28bcdaa128a240a0befdd03b59d558b4f18bd6/httptools-0.8.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:614ceea8ea606848bece2338ac03b3ce5324bcb4be8dc7d377ed708012fa4db8", size = 205148, upload-time = "2026-05-25T22:17:16.333Z" }, + { url = "https://files.pythonhosted.org/packages/2a/0e/0fb14848c19a686c8062ff9067c1a48793e3224b47bc5b201535b6036fce/httptools-0.8.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2d689918c15a013c65ef52d9fd495d766893ab831a2c8d89f2ac5940a5df847c", size = 111368, upload-time = "2026-05-25T22:17:17.586Z" }, + { url = "https://files.pythonhosted.org/packages/2e/1b/46f1cecf06b9bbde8e4b8c88034ac7908989e5ff7a3a388ef38392949c1f/httptools-0.8.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:eb3028cca2fc0a6d720e52ef61d8ebb62fcbfeb1de56874546d858d3f25a26b7", size = 486447, upload-time = "2026-05-25T22:17:18.564Z" }, + { url = "https://files.pythonhosted.org/packages/77/00/258bfc0837221f81d9725c45f9b948a6a6b2994a147a4fb66e85100c668f/httptools-0.8.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:88bdd940f2b5d487b4d032c6afa5489a7dc4694410d43de3c38c4fb3af0dc45d", size = 482448, upload-time = "2026-05-25T22:17:19.912Z" }, + { url = "https://files.pythonhosted.org/packages/04/ab/d1cef3b5523f4d272a70f42a776c3169a2dddfe3a54de4b2ce4a36341528/httptools-0.8.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:6a43c9dd399758ccc0531acb0a3c4a6c299ee893ee9400e9c893b7bdcfae0681", size = 464460, upload-time = "2026-05-25T22:17:20.882Z" }, + { url = "https://files.pythonhosted.org/packages/ce/48/5d1d072442277bb2b3434e0e60690b8e8c23840ef7de8b6ea54040a536d3/httptools-0.8.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:0770728beb05094c809b98e814edff5fef69d26ad7d21185f2f6d5884a0ba683", size = 471312, upload-time = "2026-05-25T22:17:22.085Z" }, + { url = "https://files.pythonhosted.org/packages/0d/66/b96623b27e51a68199ef4efdda0613cced9233fe3062ac74e50749c5ad37/httptools-0.8.0-cp313-cp313-win_amd64.whl", hash = "sha256:7685df791fad561384bfb139e77fde27a1ffd93134e016f95a0db424ffbf77b1", size = 90117, upload-time = "2026-05-25T22:17:23.074Z" }, + { url = "https://files.pythonhosted.org/packages/1a/12/fa3fbf5f9517b273edea2dc982aa82a8c634091e67c590792b729017bc6f/httptools-0.8.0-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:de242a49b5d18e0a8776e654e9f6bf6d89f3875a5c35b425a0e7ce940feb3fd6", size = 206183, upload-time = "2026-05-25T22:17:24.004Z" }, + { url = "https://files.pythonhosted.org/packages/30/fc/5e7c4cb443370f2090a3aba0453a07384d29ff66b7435bb90e77e1037599/httptools-0.8.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:159e9ab5f701ccd42e555a12f1ad8ff69702910fc1c996cf2bb66e5fcb7a231b", size = 112079, upload-time = "2026-05-25T22:17:25.216Z" }, + { url = "https://files.pythonhosted.org/packages/ba/53/771bd891eb0f236f32145d6a1775777ec85745f3cc983a1f23d1a3b8ddfe/httptools-0.8.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:c4a9f1707e4823d54dfec6c33fa3697d302aed536ed352a7ebb5a061ddb869d0", size = 481596, upload-time = "2026-05-25T22:17:26.186Z" }, + { url = "https://files.pythonhosted.org/packages/62/42/94e15bc68ce3d423243c45d7f1b0c7561f13844f97dc52ae23182fb65628/httptools-0.8.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d76ad7b951387e3632c8716a9bb03ac5b45c5f16119aa409db0459520887944e", size = 480865, upload-time = "2026-05-25T22:17:27.542Z" }, + { url = "https://files.pythonhosted.org/packages/1c/7c/fe2980fc03723272e30f135b62360b075f513dfe7cc73aef36c7f04012bd/httptools-0.8.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a3b7387147361c3fd47a0bde763c5c91b5b4cd4dc9989b8ece84ff436c99843b", size = 463189, upload-time = "2026-05-25T22:17:28.546Z" }, + { url = "https://files.pythonhosted.org/packages/15/1b/47fc5fff68acd1bfa20b4734059c9a06cadb88119dcd5258b5b0d21d91c8/httptools-0.8.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f256d6ce930c52ca1cb2a960b7da03548c454e7d28b06059ad41bfe789036ce0", size = 466610, upload-time = "2026-05-25T22:17:29.816Z" }, + { url = "https://files.pythonhosted.org/packages/60/bd/07b13c93ffd9bec9546e0d43f8e19378dd696dbd278511406bc07371ef1f/httptools-0.8.0-cp314-cp314-win_amd64.whl", hash = "sha256:19d1ee275bb59ba2643ba9a3a1e51cc0c788caf2b8df506368e03f56fdd08527", size = 92705, upload-time = "2026-05-25T22:17:31.133Z" }, + { url = "https://files.pythonhosted.org/packages/fd/c4/121648f68ce066d7bd762d6b6d97e620847642d38d54f3d90ff11d947629/httptools-0.8.0-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:de1ed58a974e75d56560acc7e7fed01a454994429456f65209789992e41f2568", size = 215023, upload-time = "2026-05-25T22:17:32.401Z" }, + { url = "https://files.pythonhosted.org/packages/b9/b0/312a062ae741ae3e8baa8c8bf20be81b2e67337b259ab4349bebc7b6142e/httptools-0.8.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:e93c227b595c6926c1acee96891dd9da4be338cfbe82e5cd3bb9d8dd7dc4ac0b", size = 117405, upload-time = "2026-05-25T22:17:33.742Z" }, + { url = "https://files.pythonhosted.org/packages/fc/37/fccd705f795386bb05bf413012fecff2a33e5aa8c2f069096de3e9fd8702/httptools-0.8.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2a021c3a8e65cc125390d72f59b968afca3bdcaff25bd67965e0a055a14946ca", size = 558497, upload-time = "2026-05-25T22:17:34.732Z" }, + { url = "https://files.pythonhosted.org/packages/bd/39/f172e8003576de35f5ba77ff417cf0e34429d35dc014deef15afa337a72c/httptools-0.8.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:48774d39cbb70e2b1f71f88852a3087ae1d3a1eb80482bb48c13067ab080c14f", size = 571585, upload-time = "2026-05-25T22:17:35.813Z" }, + { url = "https://files.pythonhosted.org/packages/3e/b9/f5564760af99f3dbbf3f9104dc00e5da27e96cf433c6bdcf77617f70bf3f/httptools-0.8.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:88eead8ec8680a9f146c655bc88445a325bd7921cfd8194c7337e9467282427d", size = 543297, upload-time = "2026-05-25T22:17:37.08Z" }, + { url = "https://files.pythonhosted.org/packages/99/67/8d9f2c313618e161b82f3873188e7196126da1d6e29688df40eb3997c77a/httptools-0.8.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:2c032fa028f46871ec7e1fc59fc15e8023eab3e6bbe6ece786a1611719a5d081", size = 539535, upload-time = "2026-05-25T22:17:38.032Z" }, + { url = "https://files.pythonhosted.org/packages/48/63/b906c01e53f50d432c0defe43ce52764a111dc1bdd028bafbeb54dcfd008/httptools-0.8.0-cp314-cp314t-win_amd64.whl", hash = "sha256:384c17174464c8e873398b7af24f0b1f44d992c820328413951a625323155d77", size = 108209, upload-time = "2026-05-25T22:17:39.473Z" }, +] + +[[package]] +name = "httpx2" +version = "2.10.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/b5/46/120a669232c7bdedb9d52d4aeae7e6c7dfe151e99dc70802e2fc7a5e1993/httptools-0.7.1.tar.gz", hash = "sha256:abd72556974f8e7c74a259655924a717a2365b236c882c3f6f8a45fe94703ac9", size = 258961, upload-time = "2025-10-10T03:55:08.559Z" } +dependencies = [ + { name = "anyio", marker = "sys_platform != 'emscripten'" }, + { name = "httpcore2", marker = "sys_platform != 'emscripten'" }, + { name = "httpx2-jsfetch", marker = "sys_platform == 'emscripten'" }, + { name = "idna" }, + { name = "truststore", marker = "sys_platform != 'emscripten'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/bd/3d/f9a8c07a3884f3e5b26205e8436a18b3af61c5d53192c3bea235574dbbec/httpx2-2.10.0.tar.gz", hash = "sha256:8741d7329fe2c7885fc9ceb61c8217acfb87a85f75723714b89ebf7ad7196338", size = 98749, upload-time = "2026-08-09T09:11:33.24Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/09/8f/c77b1fcbfd262d422f12da02feb0d218fa228d52485b77b953832105bb90/httptools-0.7.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:6babce6cfa2a99545c60bfef8bee0cc0545413cb0018f617c8059a30ad985de3", size = 202889, upload-time = "2025-10-10T03:54:47.089Z" }, - { url = "https://files.pythonhosted.org/packages/0a/1a/22887f53602feaa066354867bc49a68fc295c2293433177ee90870a7d517/httptools-0.7.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:601b7628de7504077dd3dcb3791c6b8694bbd967148a6d1f01806509254fb1ca", size = 108180, upload-time = "2025-10-10T03:54:48.052Z" }, - { url = "https://files.pythonhosted.org/packages/32/6a/6aaa91937f0010d288d3d124ca2946d48d60c3a5ee7ca62afe870e3ea011/httptools-0.7.1-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:04c6c0e6c5fb0739c5b8a9eb046d298650a0ff38cf42537fc372b28dc7e4472c", size = 478596, upload-time = "2025-10-10T03:54:48.919Z" }, - { url = "https://files.pythonhosted.org/packages/6d/70/023d7ce117993107be88d2cbca566a7c1323ccbaf0af7eabf2064fe356f6/httptools-0.7.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:69d4f9705c405ae3ee83d6a12283dc9feba8cc6aaec671b412917e644ab4fa66", size = 473268, upload-time = "2025-10-10T03:54:49.993Z" }, - { url = "https://files.pythonhosted.org/packages/32/4d/9dd616c38da088e3f436e9a616e1d0cc66544b8cdac405cc4e81c8679fc7/httptools-0.7.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:44c8f4347d4b31269c8a9205d8a5ee2df5322b09bbbd30f8f862185bb6b05346", size = 455517, upload-time = "2025-10-10T03:54:51.066Z" }, - { url = "https://files.pythonhosted.org/packages/1d/3a/a6c595c310b7df958e739aae88724e24f9246a514d909547778d776799be/httptools-0.7.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:465275d76db4d554918aba40bf1cbebe324670f3dfc979eaffaa5d108e2ed650", size = 458337, upload-time = "2025-10-10T03:54:52.196Z" }, - { url = "https://files.pythonhosted.org/packages/fd/82/88e8d6d2c51edc1cc391b6e044c6c435b6aebe97b1abc33db1b0b24cd582/httptools-0.7.1-cp313-cp313-win_amd64.whl", hash = "sha256:322d00c2068d125bd570f7bf78b2d367dad02b919d8581d7476d8b75b294e3e6", size = 85743, upload-time = "2025-10-10T03:54:53.448Z" }, + { url = "https://files.pythonhosted.org/packages/b9/6d/a637d52449d98a6892d9a4dc0262587afdb6a66f201871842dce5a97b1c1/httpx2-2.10.0-py3-none-any.whl", hash = "sha256:5e3194a432701e1cc6f69a8b1b2fa199ef907013fede8d9a09a2c5b7b8141a18", size = 94355, upload-time = "2026-08-09T09:11:30.882Z" }, +] + +[[package]] +name = "httpx2-jsfetch" +version = "1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cd/c4/0e5636363151a2a1795e0a77617168b9ca438e1748ec05fc9b5687f93d64/httpx2_jsfetch-1.0.tar.gz", hash = "sha256:70a0e3eabfef7cce5ad9c629f7d01ca05e418f586646f4ddf14782e4c1454c60", size = 6872, upload-time = "2026-08-07T00:13:07.492Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9b/43/832f631d32e4f1211caa2ba368317739fe71f0b8530e4c9d15dc454bac2a/httpx2_jsfetch-1.0-py3-none-any.whl", hash = "sha256:cb916b707601e69a07721aabc8f3f6659be3a6893bc1ff5c6f9e02241df2da32", size = 6382, upload-time = "2026-08-07T00:13:06.567Z" }, ] [[package]] name = "idna" -version = "3.11" +version = "3.18" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582, upload-time = "2025-10-12T14:55:20.501Z" } +sdist = { url = "https://files.pythonhosted.org/packages/cd/63/9496c57188a2ee585e0f1db071d75089a11e98aa86eb99d9d7618fc1edce/idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848", size = 196711, upload-time = "2026-06-02T14:34:07.794Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" }, + { url = "https://files.pythonhosted.org/packages/1e/5e/d4e9f1a599fb8e573b7b87160658329fbf28d19eac2718f51fc3def3aa5a/idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2", size = 65455, upload-time = "2026-06-02T14:34:06.319Z" }, ] [[package]] @@ -232,83 +360,119 @@ wheels = [ ] [[package]] -name = "lmdb" -version = "2.2.0" +name = "jsonschema" +version = "4.26.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/21/44/d94934efaf8f887b6959f131fde740fcaa831edfd13eb5425574637cddd5/lmdb-2.2.0.tar.gz", hash = "sha256:53020e20305c043ea6e68089bc242d744fba6073cdb268332299ba6dda2886d4", size = 933189, upload-time = "2026-03-30T01:26:19.049Z" } +dependencies = [ + { name = "attrs" }, + { name = "jsonschema-specifications" }, + { name = "referencing" }, + { name = "rpds-py" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b3/fc/e067678238fa451312d4c62bf6e6cf5ec56375422aee02f9cb5f909b3047/jsonschema-4.26.0.tar.gz", hash = "sha256:0c26707e2efad8aa1bfc5b7ce170f3fccc2e4918ff85989ba9ffa9facb2be326", size = 366583, upload-time = "2026-01-07T13:41:07.246Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/64/43/543af71e8fa4c56623bb89c358121ab806426f26685f11539fe5452deffa/lmdb-2.2.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:36e0cbe6b7d59f6e19b448942c5f9e91674f596a802743258f82e926a9a09632", size = 113550, upload-time = "2026-03-30T01:25:55.727Z" }, - { url = "https://files.pythonhosted.org/packages/22/2c/4702d36c0073737554b20d1d62e879a066df963482f8e514866588ddd82d/lmdb-2.2.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e5d7a9dfd279a5884806fd478244961e4483cc6d7eb769caed1d7019a8608c20", size = 112135, upload-time = "2026-03-30T01:25:56.809Z" }, - { url = "https://files.pythonhosted.org/packages/2f/43/d015fea326ed0a634107f29740b002170a462b6d2481e509105c685520f5/lmdb-2.2.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d0dbe7902b2cdb60bf6c893f307ef2b2a5039afd22f029515b86183f05ab1353", size = 332108, upload-time = "2026-03-30T01:25:57.907Z" }, - { url = "https://files.pythonhosted.org/packages/bb/c9/503e7f173994b514936badcbcb7fa9f89a07a3cfe596c6fb95b1b91b8d70/lmdb-2.2.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9c576cdb163ae61a7ef6eecbc20a6025a4abe085491c1dc0c667d726f4926b53", size = 336017, upload-time = "2026-03-30T01:25:59.234Z" }, - { url = "https://files.pythonhosted.org/packages/3e/94/b3b064acfd2f8acf5aaa53fff2c43963dbc1932ba8b8df4e27d75bf6a34a/lmdb-2.2.0-cp313-cp313-win_amd64.whl", hash = "sha256:746eebcd4c0aeaf0eb2f897028929d270c5bc80ef4918500eec16db6f26f3fcc", size = 109574, upload-time = "2026-03-30T01:26:00.324Z" }, - { url = "https://files.pythonhosted.org/packages/b9/10/dc7488d1effc339cd9470f9d22ec0fd7052a3d4fdfae87765ecd41cb2e59/lmdb-2.2.0-cp313-cp313-win_arm64.whl", hash = "sha256:006153aac9fb0415a5f3e8ac88789e5730dba3dd0743cd84c95e3951ff68bc3a", size = 103810, upload-time = "2026-03-30T01:26:01.559Z" }, + { url = "https://files.pythonhosted.org/packages/69/90/f63fb5873511e014207a475e2bb4e8b2e570d655b00ac19a9a0ca0a385ee/jsonschema-4.26.0-py3-none-any.whl", hash = "sha256:d489f15263b8d200f8387e64b4c3a75f06629559fb73deb8fdfb525f2dab50ce", size = 90630, upload-time = "2026-01-07T13:41:05.306Z" }, ] [[package]] -name = "loguru" -version = "0.7.3" +name = "jsonschema-specifications" +version = "2025.9.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "colorama", marker = "sys_platform == 'win32'" }, - { name = "win32-setctime", marker = "sys_platform == 'win32'" }, + { name = "referencing" }, ] -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" } +sdist = { url = "https://files.pythonhosted.org/packages/19/74/a633ee74eb36c44aa6d1095e7cc5569bebf04342ee146178e2d36600708b/jsonschema_specifications-2025.9.1.tar.gz", hash = "sha256:b540987f239e745613c7a9176f3edb72b832a4ac465cf02712288397832b5e8d", size = 32855, upload-time = "2025-09-08T01:34:59.186Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/0c/29/0348de65b8cc732daa3e33e67806420b2ae89bdce2b04af740289c5c6c8c/loguru-0.7.3-py3-none-any.whl", hash = "sha256:31a33c10c8e1e10422bfd431aeb5d351c7cf7fa671e3c4df004162264b28220c", size = 61595, upload-time = "2024-12-06T11:20:54.538Z" }, + { url = "https://files.pythonhosted.org/packages/41/45/1a4ed80516f02155c51f51e8cedb3c1902296743db0bbc66608a0db2814f/jsonschema_specifications-2025.9.1-py3-none-any.whl", hash = "sha256:98802fee3a11ee76ecaca44429fda8a41bff98b00a0f2838151b113f210cc6fe", size = 18437, upload-time = "2025-09-08T01:34:57.871Z" }, +] + +[[package]] +name = "lmdb" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/aa/0b/17f271b2d2d314da9c9bc7620676ede1feca5f565f3a46045319351f7bc2/lmdb-2.3.0.tar.gz", hash = "sha256:260f443640ee2da3cfd059a84258659319ff39ca912c16bf9748c324076b9d09", size = 954381, upload-time = "2026-07-12T15:46:30.534Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8b/e8/d8594c13c81652d313e5df0cbade98a3fa47de6c14e46aec5eed02a2ca5c/lmdb-2.3.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:93de120dec1ec982b80852c958d11242b78c0972a526a839cc5a65b44d22c8f7", size = 120486, upload-time = "2026-07-12T15:46:06.493Z" }, + { url = "https://files.pythonhosted.org/packages/d7/d3/c91996eb4ffeb1537710c4bf3492249b01abd56c75ec34a5b2179de4b91b/lmdb-2.3.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:d94cd8515ea767115ac2ee302ae83cde3843425901fdc3bfa9fb08980299c023", size = 120032, upload-time = "2026-07-12T15:46:07.718Z" }, + { url = "https://files.pythonhosted.org/packages/b2/0f/536d296dd90418533ccd277a20836c753f2f51d127a6e32e53c8653fbd45/lmdb-2.3.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1633f4700664436f2d71bb029bce9bdcaedcd2294c97ba853c6870c973de0bdd", size = 344681, upload-time = "2026-07-12T15:46:09.017Z" }, + { url = "https://files.pythonhosted.org/packages/79/45/1dc1ff9c998d08051728fef5f60eb31f8f9294a7bd80a786a6ae6917f2db/lmdb-2.3.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f8d815c4f1ad38d048efeee395c935b8839ed244a1d74526c83e31c05faab3ec", size = 346786, upload-time = "2026-07-12T15:46:10.535Z" }, + { url = "https://files.pythonhosted.org/packages/f3/d4/1bd0b10a0d7408f2d6a4c49be9176287bdccc8c3951a96abfd8f61eb5ec1/lmdb-2.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:f45e10949d0fc7a0cc4bc9b3bfe34d824b5a71330312289e43dc2638c26a9f13", size = 115087, upload-time = "2026-07-12T15:46:11.779Z" }, + { url = "https://files.pythonhosted.org/packages/80/f9/a4d82dedaf2a9090bb44c5ab300158a22f4c26a30355c7ffb40f52503bc6/lmdb-2.3.0-cp313-cp313-win_arm64.whl", hash = "sha256:de099ca35b010fd0c5eed957e7ffda0aee32639fb7d12c0dadbb850be9c62dee", size = 114437, upload-time = "2026-07-12T15:46:12.867Z" }, + { url = "https://files.pythonhosted.org/packages/94/6c/0c582a5c1333836ca990e81449494cea45f0a87946e05ca6291284a48eeb/lmdb-2.3.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:6369469befaf66ac8d599d0627e5190147eea2d81b735c6c5e64e8b629b1f10f", size = 120656, upload-time = "2026-07-12T15:46:14.075Z" }, + { url = "https://files.pythonhosted.org/packages/d4/02/4d9332c1c0914578de46b0b0e7e29b0f5a551b6221d7333add33f20aa843/lmdb-2.3.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:42c616d283be4c370d0cf89c83f0b6da77667f9edda62741f4b8bad95c9ba056", size = 120040, upload-time = "2026-07-12T15:46:15.24Z" }, + { url = "https://files.pythonhosted.org/packages/32/ff/0f6b3ac56e1ddcf2bad89b76608d6c1200b9e1c432739a9b6155965e3e3e/lmdb-2.3.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:90b96fec0ef2d6ecfd225b9b10020b819699bf0e214ebe635f224d5cced20cf7", size = 344674, upload-time = "2026-07-12T15:46:16.441Z" }, + { url = "https://files.pythonhosted.org/packages/66/0d/81df0bb4297a549d2cfc0c658f321d15a47f460f4f9c0331b5ead2fcb366/lmdb-2.3.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5cbf2f5eaf3db866345cb1139e0c6fef873a3c0758f01915ba6ed6ba12b24fe0", size = 346381, upload-time = "2026-07-12T15:46:17.682Z" }, + { url = "https://files.pythonhosted.org/packages/3d/96/9e5b9eb951751f50931a0a8d4bb5d3ccda7ad5e377c8743a0846c14ce11b/lmdb-2.3.0-cp314-cp314-win_amd64.whl", hash = "sha256:83225e119c799c911295d5a6f83bf25e52746609fd6907fc95bb8ae9d0f5a6d0", size = 116721, upload-time = "2026-07-12T15:46:19.057Z" }, + { url = "https://files.pythonhosted.org/packages/69/72/35ae2ebdf91e084857ec7f162397f2fd10feccac9b770e3f7d83c005d9be/lmdb-2.3.0-cp314-cp314-win_arm64.whl", hash = "sha256:40ebeea45a92dfd5b67bf47fc66b5b026cf19e365ac9bab3a3140b6b1b9766f6", size = 116812, upload-time = "2026-07-12T15:46:20.132Z" }, ] [[package]] -name = "markdown-it-py" -version = "4.0.0" +name = "loguru" +version = "0.7.3" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "mdurl" }, + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "win32-setctime", marker = "sys_platform == 'win32'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/5b/f5/4ec618ed16cc4f8fb3b701563655a69816155e79e24a17b651541804721d/markdown_it_py-4.0.0.tar.gz", hash = "sha256:cb0a2b4aa34f932c007117b194e945bd74e0ec24133ceb5bac59009cda1cb9f3", size = 73070, upload-time = "2025-08-11T12:57:52.854Z" } +sdist = { url = "https://files.pythonhosted.org/packages/3a/05/a1dae3dffd1116099471c643b8924f5aa6524411dc6c63fdae648c4f1aca/loguru-0.7.3.tar.gz", hash = "sha256:19480589e77d47b8d85b2c827ad95d49bf31b0dcde16593892eb51dd18706eb6", size = 63559, upload-time = "2024-12-06T11:20:56.608Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/94/54/e7d793b573f298e1c9013b8c4dade17d481164aa517d1d7148619c2cedbf/markdown_it_py-4.0.0-py3-none-any.whl", hash = "sha256:87327c59b172c5011896038353a81343b6754500a08cd7a4973bb48c6d578147", size = 87321, upload-time = "2025-08-11T12:57:51.923Z" }, + { url = "https://files.pythonhosted.org/packages/0c/29/0348de65b8cc732daa3e33e67806420b2ae89bdce2b04af740289c5c6c8c/loguru-0.7.3-py3-none-any.whl", hash = "sha256:31a33c10c8e1e10422bfd431aeb5d351c7cf7fa671e3c4df004162264b28220c", size = 61595, upload-time = "2024-12-06T11:20:54.538Z" }, ] [[package]] -name = "mdurl" -version = "0.1.2" +name = "nodeenv" +version = "1.10.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" } +sdist = { url = "https://files.pythonhosted.org/packages/24/bf/d1bda4f6168e0b2e9e5958945e01910052158313224ada5ce1fb2e1113b8/nodeenv-1.10.0.tar.gz", hash = "sha256:996c191ad80897d076bdfba80a41994c2b47c68e224c542b48feba42ba00f8bb", size = 55611, upload-time = "2025-12-20T14:08:54.006Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" }, + { url = "https://files.pythonhosted.org/packages/88/b2/d0896bdcdc8d28a7fc5717c305f1a861c26e18c05047949fb371034d98bd/nodeenv-1.10.0-py2.py3-none-any.whl", hash = "sha256:5bb13e3eed2923615535339b3c620e76779af4cb4c6a90deccc9e36b274d3827", size = 23438, upload-time = "2025-12-20T14:08:52.782Z" }, ] [[package]] name = "orjson" -version = "3.11.8" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/9d/1b/2024d06792d0779f9dbc51531b61c24f76c75b9f4ce05e6f3377a1814cea/orjson-3.11.8.tar.gz", hash = "sha256:96163d9cdc5a202703e9ad1b9ae757d5f0ca62f4fa0cc93d1f27b0e180cc404e", size = 5603832, upload-time = "2026-03-31T16:16:27.878Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/66/7f/95fba509bb2305fab0073558f1e8c3a2ec4b2afe58ed9fcb7d3b8beafe94/orjson-3.11.8-cp313-cp313-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:3f23426851d98478c8970da5991f84784a76682213cd50eb73a1da56b95239dc", size = 229180, upload-time = "2026-03-31T16:15:36.426Z" }, - { url = "https://files.pythonhosted.org/packages/f6/9d/b237215c743ca073697d759b5503abd2cb8a0d7b9c9e21f524bcf176ab66/orjson-3.11.8-cp313-cp313-macosx_15_0_arm64.whl", hash = "sha256:ebaed4cef74a045b83e23537b52ef19a367c7e3f536751e355a2a394f8648559", size = 128754, upload-time = "2026-03-31T16:15:38.049Z" }, - { url = "https://files.pythonhosted.org/packages/42/3d/27d65b6d11e63f133781425f132807aef793ed25075fec686fc8e46dd528/orjson-3.11.8-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:97c8f5d3b62380b70c36ffacb2a356b7c6becec86099b177f73851ba095ef623", size = 131877, upload-time = "2026-03-31T16:15:39.484Z" }, - { url = "https://files.pythonhosted.org/packages/dd/cc/faee30cd8f00421999e40ef0eba7332e3a625ce91a58200a2f52c7fef235/orjson-3.11.8-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:436c4922968a619fb7fef1ccd4b8b3a76c13b67d607073914d675026e911a65c", size = 130361, upload-time = "2026-03-31T16:15:41.274Z" }, - { url = "https://files.pythonhosted.org/packages/5c/bb/a6c55896197f97b6d4b4e7c7fd77e7235517c34f5d6ad5aadd43c54c6d7c/orjson-3.11.8-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1ab359aff0436d80bfe8a23b46b5fea69f1e18aaf1760a709b4787f1318b317f", size = 135521, upload-time = "2026-03-31T16:15:42.758Z" }, - { url = "https://files.pythonhosted.org/packages/9c/7c/ca3a3525aa32ff636ebb1778e77e3587b016ab2edb1b618b36ba96f8f2c0/orjson-3.11.8-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f89b6d0b3a8d81e1929d3ab3d92bbc225688bd80a770c49432543928fe09ac55", size = 146862, upload-time = "2026-03-31T16:15:44.341Z" }, - { url = "https://files.pythonhosted.org/packages/3c/0c/18a9d7f18b5edd37344d1fd5be17e94dc652c67826ab749c6e5948a78112/orjson-3.11.8-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:29c009e7a2ca9ad0ed1376ce20dd692146a5d9fe4310848904b6b4fee5c5c137", size = 132847, upload-time = "2026-03-31T16:15:46.368Z" }, - { url = "https://files.pythonhosted.org/packages/23/91/7e722f352ad67ca573cee44de2a58fb810d0f4eb4e33276c6a557979fd8a/orjson-3.11.8-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:705b895b781b3e395c067129d8551655642dfe9437273211d5404e87ac752b53", size = 133637, upload-time = "2026-03-31T16:15:48.123Z" }, - { url = "https://files.pythonhosted.org/packages/af/04/32845ce13ac5bd1046ddb02ac9432ba856cc35f6d74dde95864fe0ad5523/orjson-3.11.8-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:88006eda83858a9fdf73985ce3804e885c2befb2f506c9a3723cdeb5a2880e3e", size = 141906, upload-time = "2026-03-31T16:15:49.626Z" }, - { url = "https://files.pythonhosted.org/packages/02/5e/c551387ddf2d7106d9039369862245c85738b828844d13b99ccb8d61fd06/orjson-3.11.8-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:55120759e61309af7fcf9e961c6f6af3dde5921cdb3ee863ef63fd9db126cae6", size = 423722, upload-time = "2026-03-31T16:15:51.176Z" }, - { url = "https://files.pythonhosted.org/packages/00/a3/ecfe62434096f8a794d4976728cb59bcfc4a643977f21c2040545d37eb4c/orjson-3.11.8-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:98bdc6cb889d19bed01de46e67574a2eab61f5cc6b768ed50e8ac68e9d6ffab6", size = 147801, upload-time = "2026-03-31T16:15:52.939Z" }, - { url = "https://files.pythonhosted.org/packages/18/6d/0dce10b9f6643fdc59d99333871a38fa5a769d8e2fc34a18e5d2bfdee900/orjson-3.11.8-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:708c95f925a43ab9f34625e45dcdadf09ec8a6e7b664a938f2f8d5650f6c090b", size = 136460, upload-time = "2026-03-31T16:15:54.431Z" }, - { url = "https://files.pythonhosted.org/packages/01/d6/6dde4f31842d87099238f1f07b459d24edc1a774d20687187443ab044191/orjson-3.11.8-cp313-cp313-win32.whl", hash = "sha256:01c4e5a6695dc09098f2e6468a251bc4671c50922d4d745aff1a0a33a0cf5b8d", size = 131956, upload-time = "2026-03-31T16:15:56.081Z" }, - { url = "https://files.pythonhosted.org/packages/c1/f9/4e494a56e013db957fb77186b818b916d4695b8fa2aa612364974160e91b/orjson-3.11.8-cp313-cp313-win_amd64.whl", hash = "sha256:c154a35dd1330707450bb4d4e7dd1f17fa6f42267a40c1e8a1daa5e13719b4b8", size = 127410, upload-time = "2026-03-31T16:15:57.54Z" }, - { url = "https://files.pythonhosted.org/packages/57/7f/803203d00d6edb6e9e7eef421d4e1adbb5ea973e40b3533f3cfd9aeb374e/orjson-3.11.8-cp313-cp313-win_arm64.whl", hash = "sha256:4861bde57f4d253ab041e374f44023460e60e71efaa121f3c5f0ed457c3a701e", size = 127338, upload-time = "2026-03-31T16:15:59.106Z" }, +version = "3.11.9" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7e/0c/964746fcafbd16f8ff53219ad9f6b412b34f345c75f384ad434ceaadb538/orjson-3.11.9.tar.gz", hash = "sha256:4fef17e1f8722c11587a6ef18e35902450221da0028e65dbaaa543619e68e48f", size = 5599163, upload-time = "2026-05-06T15:11:08.309Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/32/33/93fcc25907235c344ae73122f8a4e01d2d393ef062b4af7d2e2487a32c37/orjson-3.11.9-cp313-cp313-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:4bab1b2d6141fe7b32ae71dac905666ece4f94936efbfb13d55bb7739a3a6021", size = 228458, upload-time = "2026-05-06T15:10:20.079Z" }, + { url = "https://files.pythonhosted.org/packages/8f/27/b1e6dadb3c080313c03fdd8067b85e6a0460c7d8d6a1c3984ef77b904e4d/orjson-3.11.9-cp313-cp313-macosx_15_0_arm64.whl", hash = "sha256:844417969855fc7a41be124aafe83dc424592a7f77cd4501900c67307122b92c", size = 128368, upload-time = "2026-05-06T15:10:21.549Z" }, + { url = "https://files.pythonhosted.org/packages/21/0f/c9ede0bf052f6b4051e64a7d4fa91b725cccf8321a6a786e86eb03519f00/orjson-3.11.9-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ffe02797b5e9f3a9d8292ddcd289b474ad13e81ad83cd1891a240811f1d2cb81", size = 132070, upload-time = "2026-05-06T15:10:23.371Z" }, + { url = "https://files.pythonhosted.org/packages/fd/26/d398e28048dc18205bbe812f2c88cb9b40313db2470778e25964796458fe/orjson-3.11.9-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:0e4eed3b200023042814d2fc8a5d2e880f13b52e1ed2485e83da4f3962f7dc1a", size = 127892, upload-time = "2026-05-06T15:10:24.714Z" }, + { url = "https://files.pythonhosted.org/packages/66/60/52b0054c4c700d5aa7fc5b7ca96917400d8f061307778578e67a10e25852/orjson-3.11.9-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8aff7da9952a5ad1cef8e68017724d96c7b9a66e99e91d6252e1b133d67a7b10", size = 135217, upload-time = "2026-05-06T15:10:26.084Z" }, + { url = "https://files.pythonhosted.org/packages/d5/97/1e3dc2b2a28b7b2528f403d2fc1d79ec5f39af3bc143ab65d3ec26426385/orjson-3.11.9-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4d4e98d6f3b8afed8bc8cd9718ec0cdf46661826beefb53fe8eafb37f2bf0362", size = 145980, upload-time = "2026-05-06T15:10:28.062Z" }, + { url = "https://files.pythonhosted.org/packages/fc/39/31fbfe7850f2de32dee7e7e5c09f26d403ab01e440ac96001c6b01ad3c99/orjson-3.11.9-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3a81d52442a7c99b3662333235b3adf96a1715864658b35bb797212be7bddb97", size = 132738, upload-time = "2026-05-06T15:10:29.727Z" }, + { url = "https://files.pythonhosted.org/packages/a1/08/dca0082dd2a194acb93e5457e73455388e2e2ca464a2672449a9ddbb679d/orjson-3.11.9-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4e39364e726a8fff737309aff059ff67d8a8c8d5b677be7bb49a8b3e84b7e218", size = 134033, upload-time = "2026-05-06T15:10:31.152Z" }, + { url = "https://files.pythonhosted.org/packages/11/d4/5bdb0626801230139987385554c5d4c42255218ac906525bf4347f22cd95/orjson-3.11.9-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4fd66214623f1b17501df9f0543bef0b833979ab5b6ded1e1d123222866aa8c9", size = 141492, upload-time = "2026-05-06T15:10:32.641Z" }, + { url = "https://files.pythonhosted.org/packages/fa/88/a21fb53b3ede6703aede6dce4710ed4111e5b201cfa6bbff5e544f9d47d7/orjson-3.11.9-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:8ecc30f10465fa1e0ce13fd01d9e22c316e5053a719a8d915d4545a09a5ff677", size = 415087, upload-time = "2026-05-06T15:10:34.438Z" }, + { url = "https://files.pythonhosted.org/packages/3d/57/1b30daf70f0d8180e9a73cefbfbdd99e4bf19eb020466502b01fba7e0e50/orjson-3.11.9-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:97db4c94a7db398a5bd636273324f0b3fd58b350bbbac8bb380ceb825a9b40f4", size = 148031, upload-time = "2026-05-06T15:10:36.358Z" }, + { url = "https://files.pythonhosted.org/packages/04/83/45fbb6d962e260807f99441db9613cee868ceda4baceda59b3720a563f97/orjson-3.11.9-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9f78cf8fec5bd627f4082b8dfeac7871b43d7f3274904492a43dab39f18a19a0", size = 136915, upload-time = "2026-05-06T15:10:38.013Z" }, + { url = "https://files.pythonhosted.org/packages/5f/cc/2d10025f9056d376e4127ec05a5808b218d46f035fdc08178a5411b34250/orjson-3.11.9-cp313-cp313-win32.whl", hash = "sha256:d4087e5c0209a0a8efe4de3303c234b9c44d1174161dcd851e8eea07c7560b32", size = 131613, upload-time = "2026-05-06T15:10:39.569Z" }, + { url = "https://files.pythonhosted.org/packages/67/bd/2775ff28bfe883b9aa1ff348300542eb2ef1ee18d8ae0e3a49846817a865/orjson-3.11.9-cp313-cp313-win_amd64.whl", hash = "sha256:051b102c93b4f634e89f3866b07b9a9a98915ada541f4ec30f177067b2694979", size = 127086, upload-time = "2026-05-06T15:10:41.262Z" }, + { url = "https://files.pythonhosted.org/packages/91/2b/d26799e580939e32a7da9a39531bc9e58e15ca32ffaa6a8cb3e9bb0d22cd/orjson-3.11.9-cp313-cp313-win_arm64.whl", hash = "sha256:cce9127885941bd28f080cecf1f1d288336b7e0d812c345b08be88b572796254", size = 126696, upload-time = "2026-05-06T15:10:42.651Z" }, + { url = "https://files.pythonhosted.org/packages/8e/eb/5da01e356015aee6ecfa1187ced87aef51364e306f5e695dd52719bf0e78/orjson-3.11.9-cp314-cp314-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:b6ef1979adc4bc243523f1a2ba91418030a8e29b0a99cbe7e0e2d6807d4dce6e", size = 228465, upload-time = "2026-05-06T15:10:44.097Z" }, + { url = "https://files.pythonhosted.org/packages/64/62/3e0e0c14c957133bcd855395c62b55ed4e3b0af23ffea11b032cb1dcbdb1/orjson-3.11.9-cp314-cp314-macosx_15_0_arm64.whl", hash = "sha256:f36b7f32c7c0db4a719f1fc5824db4a9c6f8bd1a354debb91faf26ebf3a4c71e", size = 128364, upload-time = "2026-05-06T15:10:45.839Z" }, + { url = "https://files.pythonhosted.org/packages/5a/5a/07d8aa117211a8ed7630bda80c8c0b14d04e0f8dcf99bcf49656e4a710eb/orjson-3.11.9-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:08f4d8ebb44925c794e535b2bebc507cebf32209df81de22ae285fb0d8d66de0", size = 132063, upload-time = "2026-05-06T15:10:47.267Z" }, + { url = "https://files.pythonhosted.org/packages/d6/ec/4acaf21483e18aa945be74a474c74b434f284b549f275a0a39b9f98956e9/orjson-3.11.9-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:6cc7923789694fd58f001cbcac7e47abc13af4d560ebbfcf3b41a8b1a0748124", size = 122356, upload-time = "2026-05-06T15:10:48.765Z" }, + { url = "https://files.pythonhosted.org/packages/13/d8/5f0555e7638801323b7a75850f92e7dfa891bc84fe27a1ba4449170d1200/orjson-3.11.9-cp314-cp314-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ea5c46eb2d3af39e806b986f4b09d5c2706a1f5afde3cbf7544ce6616127173c", size = 129592, upload-time = "2026-05-06T15:10:50.13Z" }, + { url = "https://files.pythonhosted.org/packages/b6/30/ed9860412a3603ceb3c5955bfd72d28b9d0e7ba6ed81add14f83d7114236/orjson-3.11.9-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f5d89a2ed90731df3be64bab0aa44f78bff39fdc9d71c291f4a8023aa46425b7", size = 140491, upload-time = "2026-05-06T15:10:51.582Z" }, + { url = "https://files.pythonhosted.org/packages/d0/17/adc514dea7ac7c505527febf884934b815d34f0c7b8693c1a8b39c5c4a57/orjson-3.11.9-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:25e4aed0312d292c09f61af25bba34e0b2c88546041472b09088c39a4d828af1", size = 127309, upload-time = "2026-05-06T15:10:53.329Z" }, + { url = "https://files.pythonhosted.org/packages/76/3e/c0b690253f0b82d86e99949af13533363acfb5432ecb5d53dd5b3bce9c34/orjson-3.11.9-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:aaea64f3f467d22e70eeed68bdccb3bc4f83f650446c4a03c59f2cba28a108db", size = 134030, upload-time = "2026-05-06T15:10:54.988Z" }, + { url = "https://files.pythonhosted.org/packages/c1/7a/bc82a0bb25e9faaf92dc4d9ef002732efc09737706af83e346788641d4a7/orjson-3.11.9-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a028425d1b440c5d92a6be1e1a020739dfe67ea87d96c6dbe828c1b30041728b", size = 141482, upload-time = "2026-05-06T15:10:56.663Z" }, + { url = "https://files.pythonhosted.org/packages/01/55/e69188b939f77d5d32a9833745ace31ea5ccae3ab613a1ec185d3cd2c4fb/orjson-3.11.9-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:5b192c6cf397e4455b11523c5cf2b18ed084c1bbd61b6c0926344d2129481972", size = 415178, upload-time = "2026-05-06T15:10:58.446Z" }, + { url = "https://files.pythonhosted.org/packages/2e/1a/b8a5a7ac527e80b9cb11d51e3f6689b709279183264b9ec5c7bc680bb8b5/orjson-3.11.9-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:ea407d4ccf5891d667d045fecae97a7a1e5e87b3b97f97ae1803c2e741130be0", size = 148089, upload-time = "2026-05-06T15:11:00.441Z" }, + { url = "https://files.pythonhosted.org/packages/97/4e/00503f64204bf859b37213a63927028f30fb6268cd8677fb0a5ad48155e1/orjson-3.11.9-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5f63aaf97afd9f6dec5b1a68e1b8da12bfccb4cb9a9a65c3e0b6c847849e7586", size = 136921, upload-time = "2026-05-06T15:11:02.176Z" }, + { url = "https://files.pythonhosted.org/packages/0d/ba/a23b82a0a8d0ed7bed4e5f5035aae751cad4ff6a1e8d2ecd14d8860f5929/orjson-3.11.9-cp314-cp314-win32.whl", hash = "sha256:e30ab17845bb9fa54ccf67fa4f9f5282652d54faa6d17452f47d0f369d038673", size = 131638, upload-time = "2026-05-06T15:11:03.696Z" }, + { url = "https://files.pythonhosted.org/packages/f3/c3/0c6798456bade745c75c452342dabacce5798196483e77e643be1f53877d/orjson-3.11.9-cp314-cp314-win_amd64.whl", hash = "sha256:32ef5f4283a3be81913947d19608eacb7c6608026851123790cd9cc8982af34b", size = 127078, upload-time = "2026-05-06T15:11:05.123Z" }, + { url = "https://files.pythonhosted.org/packages/16/21/5a3f1e8913103b703a436a5664238e5b965ec392b555fe68943ea3691e6b/orjson-3.11.9-cp314-cp314-win_arm64.whl", hash = "sha256:eebdbdeef0094e4f5aefa20dcd4eb2368ab5e7a3b4edea27f1e7b2892e009cf9", size = 126687, upload-time = "2026-05-06T15:11:06.602Z" }, ] [[package]] name = "packaging" -version = "26.0" +version = "26.3" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/65/ee/299d360cdc32edc7d2cf530f3accf79c4fca01e96ffc950d8a52213bd8e4/packaging-26.0.tar.gz", hash = "sha256:00243ae351a257117b6a241061796684b084ed1c516a08c48a3f7e147a9d80b4", size = 143416, upload-time = "2026-01-21T20:50:39.064Z" } +sdist = { url = "https://files.pythonhosted.org/packages/7d/fa/3944b40b07da9ce895c0e6303a5ab7d53da063554f534556b134a54d6093/packaging-26.3.tar.gz", hash = "sha256:94edc256424af38762eb31306eed28beb9f0efc50a8837492c9d6fd6004aed79", size = 313412, upload-time = "2026-08-04T18:15:28.737Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/b7/b9/c538f279a4e237a006a2c98387d081e9eb060d203d8ed34467cc0f0b9b53/packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529", size = 74366, upload-time = "2026-01-21T20:50:37.788Z" }, + { url = "https://files.pythonhosted.org/packages/63/34/ba1c580383c9eada3711951fef0795c80b829a078d72188184bcab9dd527/packaging-26.3-py3-none-any.whl", hash = "sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c", size = 129956, upload-time = "2026-08-04T18:15:27.159Z" }, ] [[package]] @@ -367,20 +531,48 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/1a/d9/c248c103856f807ef70c18a4f986693a46a8ffe1602e5d361485da502d20/pydantic_core-2.41.5-cp313-cp313-win32.whl", hash = "sha256:650ae77860b45cfa6e2cdafc42618ceafab3a2d9a3811fcfbd3bbf8ac3c40d36", size = 1994679, upload-time = "2025-11-04T13:40:50.619Z" }, { url = "https://files.pythonhosted.org/packages/9e/8b/341991b158ddab181cff136acd2552c9f35bd30380422a639c0671e99a91/pydantic_core-2.41.5-cp313-cp313-win_amd64.whl", hash = "sha256:79ec52ec461e99e13791ec6508c722742ad745571f234ea6255bed38c6480f11", size = 2019766, upload-time = "2025-11-04T13:40:52.631Z" }, { url = "https://files.pythonhosted.org/packages/73/7d/f2f9db34af103bea3e09735bb40b021788a5e834c81eedb541991badf8f5/pydantic_core-2.41.5-cp313-cp313-win_arm64.whl", hash = "sha256:3f84d5c1b4ab906093bdc1ff10484838aca54ef08de4afa9de0f5f14d69639cd", size = 1981005, upload-time = "2025-11-04T13:40:54.734Z" }, + { url = "https://files.pythonhosted.org/packages/ea/28/46b7c5c9635ae96ea0fbb779e271a38129df2550f763937659ee6c5dbc65/pydantic_core-2.41.5-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:3f37a19d7ebcdd20b96485056ba9e8b304e27d9904d233d7b1015db320e51f0a", size = 2119622, upload-time = "2025-11-04T13:40:56.68Z" }, + { url = "https://files.pythonhosted.org/packages/74/1a/145646e5687e8d9a1e8d09acb278c8535ebe9e972e1f162ed338a622f193/pydantic_core-2.41.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1d1d9764366c73f996edd17abb6d9d7649a7eb690006ab6adbda117717099b14", size = 1891725, upload-time = "2025-11-04T13:40:58.807Z" }, + { url = "https://files.pythonhosted.org/packages/23/04/e89c29e267b8060b40dca97bfc64a19b2a3cf99018167ea1677d96368273/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:25e1c2af0fce638d5f1988b686f3b3ea8cd7de5f244ca147c777769e798a9cd1", size = 1915040, upload-time = "2025-11-04T13:41:00.853Z" }, + { url = "https://files.pythonhosted.org/packages/84/a3/15a82ac7bd97992a82257f777b3583d3e84bdb06ba6858f745daa2ec8a85/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:506d766a8727beef16b7adaeb8ee6217c64fc813646b424d0804d67c16eddb66", size = 2063691, upload-time = "2025-11-04T13:41:03.504Z" }, + { url = "https://files.pythonhosted.org/packages/74/9b/0046701313c6ef08c0c1cf0e028c67c770a4e1275ca73131563c5f2a310a/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4819fa52133c9aa3c387b3328f25c1facc356491e6135b459f1de698ff64d869", size = 2213897, upload-time = "2025-11-04T13:41:05.804Z" }, + { url = "https://files.pythonhosted.org/packages/8a/cd/6bac76ecd1b27e75a95ca3a9a559c643b3afcd2dd62086d4b7a32a18b169/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2b761d210c9ea91feda40d25b4efe82a1707da2ef62901466a42492c028553a2", size = 2333302, upload-time = "2025-11-04T13:41:07.809Z" }, + { url = "https://files.pythonhosted.org/packages/4c/d2/ef2074dc020dd6e109611a8be4449b98cd25e1b9b8a303c2f0fca2f2bcf7/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:22f0fb8c1c583a3b6f24df2470833b40207e907b90c928cc8d3594b76f874375", size = 2064877, upload-time = "2025-11-04T13:41:09.827Z" }, + { url = "https://files.pythonhosted.org/packages/18/66/e9db17a9a763d72f03de903883c057b2592c09509ccfe468187f2a2eef29/pydantic_core-2.41.5-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2782c870e99878c634505236d81e5443092fba820f0373997ff75f90f68cd553", size = 2180680, upload-time = "2025-11-04T13:41:12.379Z" }, + { url = "https://files.pythonhosted.org/packages/d3/9e/3ce66cebb929f3ced22be85d4c2399b8e85b622db77dad36b73c5387f8f8/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:0177272f88ab8312479336e1d777f6b124537d47f2123f89cb37e0accea97f90", size = 2138960, upload-time = "2025-11-04T13:41:14.627Z" }, + { url = "https://files.pythonhosted.org/packages/a6/62/205a998f4327d2079326b01abee48e502ea739d174f0a89295c481a2272e/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:63510af5e38f8955b8ee5687740d6ebf7c2a0886d15a6d65c32814613681bc07", size = 2339102, upload-time = "2025-11-04T13:41:16.868Z" }, + { url = "https://files.pythonhosted.org/packages/3c/0d/f05e79471e889d74d3d88f5bd20d0ed189ad94c2423d81ff8d0000aab4ff/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:e56ba91f47764cc14f1daacd723e3e82d1a89d783f0f5afe9c364b8bb491ccdb", size = 2326039, upload-time = "2025-11-04T13:41:18.934Z" }, + { url = "https://files.pythonhosted.org/packages/ec/e1/e08a6208bb100da7e0c4b288eed624a703f4d129bde2da475721a80cab32/pydantic_core-2.41.5-cp314-cp314-win32.whl", hash = "sha256:aec5cf2fd867b4ff45b9959f8b20ea3993fc93e63c7363fe6851424c8a7e7c23", size = 1995126, upload-time = "2025-11-04T13:41:21.418Z" }, + { url = "https://files.pythonhosted.org/packages/48/5d/56ba7b24e9557f99c9237e29f5c09913c81eeb2f3217e40e922353668092/pydantic_core-2.41.5-cp314-cp314-win_amd64.whl", hash = "sha256:8e7c86f27c585ef37c35e56a96363ab8de4e549a95512445b85c96d3e2f7c1bf", size = 2015489, upload-time = "2025-11-04T13:41:24.076Z" }, + { url = "https://files.pythonhosted.org/packages/4e/bb/f7a190991ec9e3e0ba22e4993d8755bbc4a32925c0b5b42775c03e8148f9/pydantic_core-2.41.5-cp314-cp314-win_arm64.whl", hash = "sha256:e672ba74fbc2dc8eea59fb6d4aed6845e6905fc2a8afe93175d94a83ba2a01a0", size = 1977288, upload-time = "2025-11-04T13:41:26.33Z" }, + { url = "https://files.pythonhosted.org/packages/92/ed/77542d0c51538e32e15afe7899d79efce4b81eee631d99850edc2f5e9349/pydantic_core-2.41.5-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:8566def80554c3faa0e65ac30ab0932b9e3a5cd7f8323764303d468e5c37595a", size = 2120255, upload-time = "2025-11-04T13:41:28.569Z" }, + { url = "https://files.pythonhosted.org/packages/bb/3d/6913dde84d5be21e284439676168b28d8bbba5600d838b9dca99de0fad71/pydantic_core-2.41.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b80aa5095cd3109962a298ce14110ae16b8c1aece8b72f9dafe81cf597ad80b3", size = 1863760, upload-time = "2025-11-04T13:41:31.055Z" }, + { url = "https://files.pythonhosted.org/packages/5a/f0/e5e6b99d4191da102f2b0eb9687aaa7f5bea5d9964071a84effc3e40f997/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3006c3dd9ba34b0c094c544c6006cc79e87d8612999f1a5d43b769b89181f23c", size = 1878092, upload-time = "2025-11-04T13:41:33.21Z" }, + { url = "https://files.pythonhosted.org/packages/71/48/36fb760642d568925953bcc8116455513d6e34c4beaa37544118c36aba6d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:72f6c8b11857a856bcfa48c86f5368439f74453563f951e473514579d44aa612", size = 2053385, upload-time = "2025-11-04T13:41:35.508Z" }, + { url = "https://files.pythonhosted.org/packages/20/25/92dc684dd8eb75a234bc1c764b4210cf2646479d54b47bf46061657292a8/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5cb1b2f9742240e4bb26b652a5aeb840aa4b417c7748b6f8387927bc6e45e40d", size = 2218832, upload-time = "2025-11-04T13:41:37.732Z" }, + { url = "https://files.pythonhosted.org/packages/e2/09/f53e0b05023d3e30357d82eb35835d0f6340ca344720a4599cd663dca599/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bd3d54f38609ff308209bd43acea66061494157703364ae40c951f83ba99a1a9", size = 2327585, upload-time = "2025-11-04T13:41:40Z" }, + { url = "https://files.pythonhosted.org/packages/aa/4e/2ae1aa85d6af35a39b236b1b1641de73f5a6ac4d5a7509f77b814885760c/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2ff4321e56e879ee8d2a879501c8e469414d948f4aba74a2d4593184eb326660", size = 2041078, upload-time = "2025-11-04T13:41:42.323Z" }, + { url = "https://files.pythonhosted.org/packages/cd/13/2e215f17f0ef326fc72afe94776edb77525142c693767fc347ed6288728d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d0d2568a8c11bf8225044aa94409e21da0cb09dcdafe9ecd10250b2baad531a9", size = 2173914, upload-time = "2025-11-04T13:41:45.221Z" }, + { url = "https://files.pythonhosted.org/packages/02/7a/f999a6dcbcd0e5660bc348a3991c8915ce6599f4f2c6ac22f01d7a10816c/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:a39455728aabd58ceabb03c90e12f71fd30fa69615760a075b9fec596456ccc3", size = 2129560, upload-time = "2025-11-04T13:41:47.474Z" }, + { url = "https://files.pythonhosted.org/packages/3a/b1/6c990ac65e3b4c079a4fb9f5b05f5b013afa0f4ed6780a3dd236d2cbdc64/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:239edca560d05757817c13dc17c50766136d21f7cd0fac50295499ae24f90fdf", size = 2329244, upload-time = "2025-11-04T13:41:49.992Z" }, + { url = "https://files.pythonhosted.org/packages/d9/02/3c562f3a51afd4d88fff8dffb1771b30cfdfd79befd9883ee094f5b6c0d8/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:2a5e06546e19f24c6a96a129142a75cee553cc018ffee48a460059b1185f4470", size = 2331955, upload-time = "2025-11-04T13:41:54.079Z" }, + { url = "https://files.pythonhosted.org/packages/5c/96/5fb7d8c3c17bc8c62fdb031c47d77a1af698f1d7a406b0f79aaa1338f9ad/pydantic_core-2.41.5-cp314-cp314t-win32.whl", hash = "sha256:b4ececa40ac28afa90871c2cc2b9ffd2ff0bf749380fbdf57d165fd23da353aa", size = 1988906, upload-time = "2025-11-04T13:41:56.606Z" }, + { url = "https://files.pythonhosted.org/packages/22/ed/182129d83032702912c2e2d8bbe33c036f342cc735737064668585dac28f/pydantic_core-2.41.5-cp314-cp314t-win_amd64.whl", hash = "sha256:80aa89cad80b32a912a65332f64a4450ed00966111b6615ca6816153d3585a8c", size = 1981607, upload-time = "2025-11-04T13:41:58.889Z" }, + { url = "https://files.pythonhosted.org/packages/9f/ed/068e41660b832bb0b1aa5b58011dea2a3fe0ba7861ff38c4d4904c1c1a99/pydantic_core-2.41.5-cp314-cp314t-win_arm64.whl", hash = "sha256:35b44f37a3199f771c3eaa53051bc8a70cd7b54f333531c59e29fd4db5d15008", size = 1974769, upload-time = "2025-11-04T13:42:01.186Z" }, ] [[package]] name = "pydantic-settings" -version = "2.13.1" +version = "2.15.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pydantic" }, { name = "python-dotenv" }, { name = "typing-inspection" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/52/6d/fffca34caecc4a3f97bda81b2098da5e8ab7efc9a66e819074a11955d87e/pydantic_settings-2.13.1.tar.gz", hash = "sha256:b4c11847b15237fb0171e1462bf540e294affb9b86db4d9aa5c01730bdbe4025", size = 223826, upload-time = "2026-02-19T13:45:08.055Z" } +sdist = { url = "https://files.pythonhosted.org/packages/68/ca/31c57507b13119d7d3cfa1576dad2911a4861e3be07b579395f4e9d393f9/pydantic_settings-2.15.0.tar.gz", hash = "sha256:694b793e84f766ba76a90ebdefc01d0a9a045dab0382bee70393da93712ad117", size = 261253, upload-time = "2026-08-07T09:24:57.419Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/00/4b/ccc026168948fec4f7555b9164c724cf4125eac006e176541483d2c959be/pydantic_settings-2.13.1-py3-none-any.whl", hash = "sha256:d56fd801823dbeae7f0975e1f8c8e25c258eb75d278ea7abb5d9cebb01b56237", size = 58929, upload-time = "2026-02-19T13:45:06.034Z" }, + { url = "https://files.pythonhosted.org/packages/30/a4/2bffa9f8e804325a09867f0e9d30795c80ea9f8d62560bd1b6ad6220eb2f/pydantic_settings-2.15.0-py3-none-any.whl", hash = "sha256:0ba092c291c94baceb5eff768aa0d56400a457585bc0175925a5a5510303da42", size = 69413, upload-time = "2026-08-07T09:24:55.839Z" }, ] [package.optional-dependencies] @@ -397,9 +589,22 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, ] +[[package]] +name = "pyright" +version = "1.1.411" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "nodeenv" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7e/ab/265f7dc69d28113ebba19092e57b075f41543b2ed048429c5f56e2b88eac/pyright-1.1.411.tar.gz", hash = "sha256:d885a0551f2e763b089a02702174e7f4ba77548cddabc972ab86d1f7f1b0f998", size = 4112861, upload-time = "2026-06-25T02:14:06.37Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0a/49/385be530a6a5b78d1cbcd5c2e38debc8959a2fc6bdb716f4e581002979fc/pyright-1.1.411-py3-none-any.whl", hash = "sha256:dc7c72a8e2700c55baa127554040e067041ea53ccfd50bf96308cc4291c7d5d9", size = 6181526, upload-time = "2026-06-25T02:14:04.691Z" }, +] + [[package]] name = "pytest" -version = "9.0.3" +version = "9.1.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "colorama", marker = "sys_platform == 'win32'" }, @@ -408,18 +613,18 @@ dependencies = [ { name = "pluggy" }, { name = "pygments" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/7d/0d/549bd94f1a0a402dc8cf64563a117c0f3765662e2e668477624baeec44d5/pytest-9.0.3.tar.gz", hash = "sha256:b86ada508af81d19edeb213c681b1d48246c1a91d304c6c81a427674c17eb91c", size = 1572165, upload-time = "2026-04-07T17:16:18.027Z" } +sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9", size = 375249, upload-time = "2026-04-07T17:16:16.13Z" }, + { url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" }, ] [[package]] name = "python-dotenv" -version = "1.2.2" +version = "1.2.3" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/82/ed/0301aeeac3e5353ef3d94b6ec08bbcabd04a72018415dcb29e588514bba8/python_dotenv-1.2.2.tar.gz", hash = "sha256:2c371a91fbd7ba082c2c1dc1f8bf89ca22564a087c2c287cd9b662adde799cf3", size = 50135, upload-time = "2026-03-01T16:00:26.196Z" } +sdist = { url = "https://files.pythonhosted.org/packages/6a/53/ed9d74092561d4b01a2ef1349d52cdbc135e526c245f366b089cfca6de49/python_dotenv-1.2.3.tar.gz", hash = "sha256:a20a594dabeaa385725aa239d5244871c143ecb356add8a20fcf23773a6c3a35", size = 58945, upload-time = "2026-08-16T16:54:54.067Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101, upload-time = "2026-03-01T16:00:25.09Z" }, + { url = "https://files.pythonhosted.org/packages/0d/17/c5c6b53ddc18f297992099b3d9ec16c855c0ccc83263a21fe4d1c625ec6c/python_dotenv-1.2.3-py3-none-any.whl", hash = "sha256:904552145e8bfed22162c09dab1c2b9b54fefa7b23ba780f4f26ca0316b0f0d9", size = 22780, upload-time = "2026-08-16T16:54:52.473Z" }, ] [[package]] @@ -438,90 +643,295 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" }, { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" }, { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" }, -] - -[[package]] -name = "rich" -version = "15.0.0" + { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" }, + { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" }, + { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" }, + { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" }, + { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" }, + { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" }, + { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" }, + { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" }, + { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" }, + { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" }, + { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" }, + { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" }, + { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" }, + { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" }, + { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, +] + +[[package]] +name = "referencing" +version = "0.37.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "markdown-it-py" }, - { name = "pygments" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/c0/8f/0722ca900cc807c13a6a0c696dacf35430f72e0ec571c4275d2371fca3e9/rich-15.0.0.tar.gz", hash = "sha256:edd07a4824c6b40189fb7ac9bc4c52536e9780fbbfbddf6f1e2502c31b068c36", size = 230680, upload-time = "2026-04-12T08:24:00.75Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/82/3b/64d4899d73f91ba49a8c18a8ff3f0ea8f1c1d75481760df8c68ef5235bf5/rich-15.0.0-py3-none-any.whl", hash = "sha256:33bd4ef74232fb73fe9279a257718407f169c09b78a87ad3d296f548e27de0bb", size = 310654, upload-time = "2026-04-12T08:24:02.83Z" }, + { name = "attrs" }, + { name = "rpds-py" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/22/f5/df4e9027acead3ecc63e50fe1e36aca1523e1719559c499951bb4b53188f/referencing-0.37.0.tar.gz", hash = "sha256:44aefc3142c5b842538163acb373e24cce6632bd54bdb01b21ad5863489f50d8", size = 78036, upload-time = "2025-10-13T15:30:48.871Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2c/58/ca301544e1fa93ed4f80d724bf5b194f6e4b945841c5bfd555878eea9fcb/referencing-0.37.0-py3-none-any.whl", hash = "sha256:381329a9f99628c9069361716891d34ad94af76e461dcb0335825aecc7692231", size = 26766, upload-time = "2025-10-13T15:30:47.625Z" }, +] + +[[package]] +name = "regex" +version = "2026.7.19" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/20/98/04b13f1ddfb63158025291c02e03eb42fbb7acb51d091d541050eb4e35e8/regex-2026.7.19.tar.gz", hash = "sha256:7e77b324909c1617cbb4c668677e2c6ae13f44d7c1de0d4f15f2e3c10f3315b5", size = 416440, upload-time = "2026-07-19T00:19:48.923Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5d/3d/84165e4299ff76f3a40fe1f2abf939e976f693383a08d2beea6af62bd2c1/regex-2026.7.19-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:f035d9dc1d25eff9d361456572231c7d27b5ccd473ca7dc0adfce732bd006d40", size = 496552, upload-time = "2026-07-19T00:17:36.808Z" }, + { url = "https://files.pythonhosted.org/packages/02/a2/a65293e6e4cf28eb7ee1be5335a5386c40d6742e9f47fafc8fec785e16c7/regex-2026.7.19-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:c42572142ed0b9d5d261ba727157c426510da78e20828b66bbb855098b8a4e38", size = 296983, upload-time = "2026-07-19T00:17:38.816Z" }, + { url = "https://files.pythonhosted.org/packages/95/47/2d0564e93d87bc48618360ddca232a2ca612bbdf53ce8465d45ca5ce14ee/regex-2026.7.19-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:40b34dd88658e4fedd2fddbf0275ac970d00614b731357f425722a3ed1983d11", size = 291832, upload-time = "2026-07-19T00:17:40.726Z" }, + { url = "https://files.pythonhosted.org/packages/07/cd/42dfbabff3dfc9603c501c0e2e2c5adbb09d127b267bf5348de0af338c15/regex-2026.7.19-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0c41c63992bf1874cebb6e7f56fd7d3c007924659a604ae3d90e427d40d4fd13", size = 796775, upload-time = "2026-07-19T00:17:42.382Z" }, + { url = "https://files.pythonhosted.org/packages/df/5d/f6a4839f2b934e3eed5973fd07f5929ee97d4c98939fb275ea23c274ee16/regex-2026.7.19-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1d3372064506b94dd2c67c845f2db8062e9e9ba84d04e33cb96d7d33c11fe1ae", size = 865687, upload-time = "2026-07-19T00:17:44.185Z" }, + { url = "https://files.pythonhosted.org/packages/14/b0/b47d6c36049bc59806a50bd4c86ced70bbe058d787f80281b1d7a9b0e024/regex-2026.7.19-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:fce7760bf283405b2c7999cab3da4e72f7deca6396013115e3f7a955db9760da", size = 911962, upload-time = "2026-07-19T00:17:46.442Z" }, + { url = "https://files.pythonhosted.org/packages/2a/be/ff61f28f9273658cfe23acbbac5217221f6519960ed401e61dfdab12bc35/regex-2026.7.19-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c0d702548d89d572b2929879bc883bb7a4c4709efafe4512cadee56c55c9bd15", size = 801817, upload-time = "2026-07-19T00:17:48.25Z" }, + { url = "https://files.pythonhosted.org/packages/c3/bb/8b4f7f26b333f9f79e1b453613c39bb4776f51d38ae66dd0ba31d6b354ca/regex-2026.7.19-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d446c6ac40bb6e05025ccee55b84d80fe9bf8e93010ffc4bb9484f13d498835f", size = 776908, upload-time = "2026-07-19T00:17:50.183Z" }, + { url = "https://files.pythonhosted.org/packages/09/13/610110fc5921d380516d03c26b652555f08aa0d23ea78a771231873c3638/regex-2026.7.19-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4c3501bfa814ab07b5580741f9bf78dfdfe146a04057f82df9e2402d2a975939", size = 784426, upload-time = "2026-07-19T00:17:52.454Z" }, + { url = "https://files.pythonhosted.org/packages/ca/f5/1ef9e2a83a5947c57ebff0b377cb5727c3d5ec1992317a320d035cd0dbb6/regex-2026.7.19-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:c4585c3e64b4f9e583b4d2683f18f5d5d872b3d71dcf24594b74ecc23602fa96", size = 860600, upload-time = "2026-07-19T00:17:54.229Z" }, + { url = "https://files.pythonhosted.org/packages/a0/02/073af33a3ec149241d11c80acea91e722aa0adbf05addd50f251c4fe89c3/regex-2026.7.19-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:571fde9741eb0ccde23dd4e0c1d50fbae910e901fa7e629faf39b2dda740d220", size = 765950, upload-time = "2026-07-19T00:17:56.041Z" }, + { url = "https://files.pythonhosted.org/packages/81/a9/d1e9f819dc394a568ef370cd56cf25394e957a2235f8370f23b576e5a475/regex-2026.7.19-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:15b364b9b98d6d2fe1a85034c23a3180ff913f46caddc3895f6fd65186255ccc", size = 851794, upload-time = "2026-07-19T00:17:57.897Z" }, + { url = "https://files.pythonhosted.org/packages/03/3a/8ae83eda7579feacdf984e71fb9e70635fb6f832eeddca58427ec4fca926/regex-2026.7.19-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ffd8893ccc1c2fce6e0d6ca402d716fe1b29db70c7132609a05955e31b2aa8f2", size = 789845, upload-time = "2026-07-19T00:17:59.97Z" }, + { url = "https://files.pythonhosted.org/packages/4b/23/c195cbfe5a75fdec64d8f6554fd15237b837919d2c61bdc141d7c807b08b/regex-2026.7.19-cp313-cp313-win32.whl", hash = "sha256:f0fa4fa9c3632d708742baf2282f2055c11d888a790362670a403cbf48a2c404", size = 267135, upload-time = "2026-07-19T00:18:01.958Z" }, + { url = "https://files.pythonhosted.org/packages/b2/80/a11de8404b7272b70acb45c1c05987cce60b45d5693da2e176f0e390d564/regex-2026.7.19-cp313-cp313-win_amd64.whl", hash = "sha256:d51ffd3427640fa2da6ade574ceba932f210ad095f65fcc450a2b0a0d454868e", size = 277747, upload-time = "2026-07-19T00:18:04.121Z" }, + { url = "https://files.pythonhosted.org/packages/d1/29/0f5c8eff1b4f1f3d83276d365fccecf666afcc7d947420943bf394d07adb/regex-2026.7.19-cp313-cp313-win_arm64.whl", hash = "sha256:c670fe7be5b6020b76bc6e8d2196074657e1327595bca93a389e1a76ab130ad8", size = 277129, upload-time = "2026-07-19T00:18:05.821Z" }, + { url = "https://files.pythonhosted.org/packages/dc/4c/44b74742052cedda40f9ae469532a037112f7311a36669a891fba8984bb0/regex-2026.7.19-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:db47b561c9afd884baa1f96f797c9ca369872c4b65912bc691cfa99e68340af2", size = 501134, upload-time = "2026-07-19T00:18:07.567Z" }, + { url = "https://files.pythonhosted.org/packages/f0/45/bbd038b5e39ee5613a5a689290145b40058cc152c41de9cc23639d2b9734/regex-2026.7.19-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:65dcd28d3eba2ab7c2fd906485cc301392b47cc2234790d27d4e4814e02cdfda", size = 299418, upload-time = "2026-07-19T00:18:09.38Z" }, + { url = "https://files.pythonhosted.org/packages/65/38/c5bde94b4cedfd5850d64c3f08222d8e1600e84f6ee71d9b44b4b8163f74/regex-2026.7.19-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:f2e7f8e2ab6c2922be02c7ec45185aa5bd771e2e57b95455ee343a44d8130dff", size = 294486, upload-time = "2026-07-19T00:18:11.188Z" }, + { url = "https://files.pythonhosted.org/packages/d7/6a/2f5e107cb26c960b781967178899daf2787a7ab151844ed3c01d6fc95474/regex-2026.7.19-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fe31f28c94402043161876a258a9c6f757cb485905c7614ce8d6cd40e6b7bdc1", size = 811643, upload-time = "2026-07-19T00:18:12.975Z" }, + { url = "https://files.pythonhosted.org/packages/37/d4/a2f963406d7d73a62eed84ba05a258afb6cad1b21aa4517443ce40506b78/regex-2026.7.19-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f8f6fa298bb4f7f58a33334406218ba74716e68feddf5e4e54cd5d8082705abf", size = 871081, upload-time = "2026-07-19T00:18:14.733Z" }, + { url = "https://files.pythonhosted.org/packages/45/a3/44be546340bedb15f13063f5e7fe16793ea4d9ea2e805d09bd174ac27724/regex-2026.7.19-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:cc1b2440423a851fad781309dd87843868f4f66a6bcd1ddb9225cf4ec2c84732", size = 917372, upload-time = "2026-07-19T00:18:16.724Z" }, + { url = "https://files.pythonhosted.org/packages/f8/f6/e0870b0fd2a40dba0074e4b76e514b21313d37946c9248453e34ec43923e/regex-2026.7.19-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8ac59a0900474a52b7c04af8196affc22bd9842acb0950df12f7b813e983609a", size = 816089, upload-time = "2026-07-19T00:18:18.617Z" }, + { url = "https://files.pythonhosted.org/packages/ae/27/957e8e22690ad6634572b39b71f130a6105f4d0718bb16849eac00fff147/regex-2026.7.19-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4896db1f4ce0576765b8272aa922df324e0f5b9bb2c3d03044ff32a7234a9aba", size = 785206, upload-time = "2026-07-19T00:18:20.464Z" }, + { url = "https://files.pythonhosted.org/packages/76/a4/186e410941e731037c01166069ab86da9f65e8f8110c18009ccf4bd623ee/regex-2026.7.19-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:4e6883a021db30511d9fb8cfb0f222ce1f2c369f7d4d8b0448f449a93ba0bdfc", size = 800431, upload-time = "2026-07-19T00:18:22.716Z" }, + { url = "https://files.pythonhosted.org/packages/73/9f/e4e10e023d291d64a33e246610b724493bf1ce98e0e59c9b7c837e5acfb7/regex-2026.7.19-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:09523a592938aa9f587fb74467c63ff0cf88fc3df14c82ab0f0517dcf76aaa62", size = 864906, upload-time = "2026-07-19T00:18:24.772Z" }, + { url = "https://files.pythonhosted.org/packages/24/57/ccb20b6be5f1f52a053d1ba2a8f7a077edb9d918248b8490d7506c6832b3/regex-2026.7.19-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:1ebac3474b8589fce2f9b225b650afd61448f7c73a5d0255a10cc6366471aed1", size = 773559, upload-time = "2026-07-19T00:18:27.008Z" }, + { url = "https://files.pythonhosted.org/packages/a3/82/f3b263cf8fad927dc102891da8502e718b7ff9d19af7a2a07c03865d7188/regex-2026.7.19-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:4a0530bb1b8c1c985e7e2122e2b4d3aedd8a3c21c6bfddae6767c4405668b56e", size = 857739, upload-time = "2026-07-19T00:18:29.107Z" }, + { url = "https://files.pythonhosted.org/packages/47/2e/1687bd1b6c2aed5e672ccf845fc11557821fe7366d921b50889ea5ce57bf/regex-2026.7.19-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:2ef7eeb108c47ce7bcc9513e51bcb1bf57e8f483d52fce68a8642e3527141ae0", size = 804522, upload-time = "2026-07-19T00:18:31.362Z" }, + { url = "https://files.pythonhosted.org/packages/76/7c/cc4e7655181b2d9235b704f2c5e19d8eff002bbc437bae59baee0e381aca/regex-2026.7.19-cp313-cp313t-win32.whl", hash = "sha256:64b6ca7391a1395c2638dd5c7456d67bea44fc6c5e8e92c5dc8aa6a8f23292b4", size = 269141, upload-time = "2026-07-19T00:18:33.479Z" }, + { url = "https://files.pythonhosted.org/packages/bb/14/961b4c7b05a2391c32dbc85e27773076671ef8f97f36cec70fe414734c02/regex-2026.7.19-cp313-cp313t-win_amd64.whl", hash = "sha256:f04b9f56b0e0614c0126be12c2c2d9f8850c1e57af302bd0a63bed379d4af974", size = 280036, upload-time = "2026-07-19T00:18:35.419Z" }, + { url = "https://files.pythonhosted.org/packages/ce/67/795644550d788ddbb6dc458c95895f8009978ea6d6ea76b005eb3f45e8c9/regex-2026.7.19-cp313-cp313t-win_arm64.whl", hash = "sha256:fcee38cd8e5089d6d4f048ba1233b3ad76e5954f545382180889112ff5cb712d", size = 279394, upload-time = "2026-07-19T00:18:37.454Z" }, + { url = "https://files.pythonhosted.org/packages/d2/25/0c4c452f8ef3efe456745b2f33195f5904b573fb4c2ff3f0cb9ec188461e/regex-2026.7.19-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:a81758ed242b861b72e778ba34d41366441a2e10b16b472784c88da2dea7e2dd", size = 496750, upload-time = "2026-07-19T00:18:39.633Z" }, + { url = "https://files.pythonhosted.org/packages/24/9e/b70ca6c1704f6c7cd32a9e143c86cc5968d10981eca284bad670c245ea7d/regex-2026.7.19-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:4aa5435cdb3eb6f55fe98a171b05e3fbcd95fadaa4aa32acf62afd9b0cfdbcac", size = 297093, upload-time = "2026-07-19T00:18:41.583Z" }, + { url = "https://files.pythonhosted.org/packages/87/74/0b692da2520d51fbff19c88b83d97e4c702909dd02386c585998b7e2dbed/regex-2026.7.19-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:60be8693a1dadc210bbcbc0db3e26da5f7d01d1d5a3da594e99b4fa42df404f5", size = 292043, upload-time = "2026-07-19T00:18:43.347Z" }, + { url = "https://files.pythonhosted.org/packages/e3/a7/1d478e614016045a33feae57446215f9fd65b665a5ceb2f891fb3183bc52/regex-2026.7.19-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d19662dbedbe783d323196312d38f5ba53cf56296378252171985da6899887d3", size = 797214, upload-time = "2026-07-19T00:18:45.362Z" }, + { url = "https://files.pythonhosted.org/packages/aa/ae/11b9c9411d92c30e3d2db32df5a31133e4a99a8fc397a604fd08f6c4bffb/regex-2026.7.19-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d15df07081d91b76ff20d43f94592ee110330152d617b730fdbe5ef9fb680053", size = 866433, upload-time = "2026-07-19T00:18:47.315Z" }, + { url = "https://files.pythonhosted.org/packages/b1/62/2b2efc4992f91d6d204b24c647c9f9412e85379d92b7c0ab9fdae622327e/regex-2026.7.19-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:56ad4d9f77df871a99e25c37091052a02528ec0eb059de928ee33956b854b45b", size = 911360, upload-time = "2026-07-19T00:18:49.588Z" }, + { url = "https://files.pythonhosted.org/packages/14/71/986ceea9aa3da548bf1357cad89b63915ec6d21ec957c8113b29ece567df/regex-2026.7.19-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7322ec6cc9fba9d49ab888bb82d67ac5625627aa168f0165139b17018df3fb8a", size = 801275, upload-time = "2026-07-19T00:18:51.767Z" }, + { url = "https://files.pythonhosted.org/packages/15/be/ce9d9534b2cda96eab32c548261224b9b4e220a4126f098f60f42ae7b4cd/regex-2026.7.19-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9c7472192ebfad53a6be7c4a8bfb2d64b81c0e93a1fc8c57e1dd0b638297b5d1", size = 777131, upload-time = "2026-07-19T00:18:54.053Z" }, + { url = "https://files.pythonhosted.org/packages/61/2b/58b5c710f2c3929515a25f3a1ca0dad0dcd4518d4fff3cf23bc7adb8dcd2/regex-2026.7.19-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c10b82c2634df08dfb13b1f04e38fe310d086ee092f4f69c0c8da234251e556e", size = 785020, upload-time = "2026-07-19T00:18:56.579Z" }, + { url = "https://files.pythonhosted.org/packages/84/03/5fe091935b74f15fe0f97998c215cae418d1c0413f6258c7d4d2e83aa37f/regex-2026.7.19-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:17ed5692f6acc4183e98331101a5f9e4f64d72fe58b753da4d444a2c77d05b12", size = 861263, upload-time = "2026-07-19T00:18:58.64Z" }, + { url = "https://files.pythonhosted.org/packages/d8/fa/d60bf82e10841eef62a9e32aac401468f05fddfbcb2942e342b1ba3d2433/regex-2026.7.19-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:22a992de9a0d91bda927bf02b94351d737a0302905432c88a53de7c4b9ce62e2", size = 766199, upload-time = "2026-07-19T00:19:00.705Z" }, + { url = "https://files.pythonhosted.org/packages/bf/5d/11e64d151b0662b81d6bf644c74dc118d461df85bdf2577fadbbf751788a/regex-2026.7.19-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:618a0aed532be87294c4477b0481f3aa0f1520f4014a4374dd4cf789b4cd2c97", size = 851317, upload-time = "2026-07-19T00:19:03.015Z" }, + { url = "https://files.pythonhosted.org/packages/7c/34/532efb87488d90807bae6a443d357ee5e2728a478c597619c8aaa17cc0bd/regex-2026.7.19-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2ce9e679f776649746729b6c86382da519ef649c8e34cc41df0d2e5e0f6c36d4", size = 789557, upload-time = "2026-07-19T00:19:05.338Z" }, + { url = "https://files.pythonhosted.org/packages/d6/90/3a8d5ca977171ec3ae21a71207d2228b2663bde14d7f7ef0e6363ecf9290/regex-2026.7.19-cp314-cp314-win32.whl", hash = "sha256:73f272fba87b8ccfe70a137d02a54af386f6d27aa509fbffdd978f5947aae1aa", size = 272531, upload-time = "2026-07-19T00:19:07.487Z" }, + { url = "https://files.pythonhosted.org/packages/96/e1/8862885e70409de70e8c005f57fb2e7be8d9ef0317250d60f4c9660a300d/regex-2026.7.19-cp314-cp314-win_amd64.whl", hash = "sha256:d721e53758b2cca74990185eb0671dd466d7a388a1a45d0c6f4c13cef41a68ac", size = 280831, upload-time = "2026-07-19T00:19:09.46Z" }, + { url = "https://files.pythonhosted.org/packages/08/82/2693e53e29f9104d9de95d37ce4dd826bd32d5f9c0085d3aa6ac042675c4/regex-2026.7.19-cp314-cp314-win_arm64.whl", hash = "sha256:65fa6cb38ed5e9c3637e68e544f598b39c3b86b808ed0627a67b68320384b459", size = 281099, upload-time = "2026-07-19T00:19:11.398Z" }, + { url = "https://files.pythonhosted.org/packages/92/b7/9a01aa16461a18cde9d7b9c3ab21e501db2ce33725f53014342b91df2b0a/regex-2026.7.19-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:5a2721c8720e2cb3c209925dfb9200199b4b07361c9e01d321719404b21458b3", size = 501121, upload-time = "2026-07-19T00:19:13.425Z" }, + { url = "https://files.pythonhosted.org/packages/f3/5e/bbaeca815dc9191c424c94a4fdc5c87c75748a64a6271821212ebdd4e1a3/regex-2026.7.19-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:199535629f25caf89698039af3d1ad5fcae7f933e2112c73f1cdf49165c99518", size = 299415, upload-time = "2026-07-19T00:19:15.43Z" }, + { url = "https://files.pythonhosted.org/packages/cd/d6/0dd1a321afaab95eb7ff44aa0f637301786f1dc71c6b797b9ed236ed8890/regex-2026.7.19-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:9b60d7814174f059e5de4ab98271cc5ba9259cfea55273a81544dceea32dc8d9", size = 294483, upload-time = "2026-07-19T00:19:17.879Z" }, + { url = "https://files.pythonhosted.org/packages/92/5f/40bacf91d0904f812e13bbbab3864604c463eced8afdc54aeaa50492ea95/regex-2026.7.19-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dbece16025afda5e3031af0c4059207e61dcf73ef13af844964f57f387d1c435", size = 811833, upload-time = "2026-07-19T00:19:20.102Z" }, + { url = "https://files.pythonhosted.org/packages/94/7c/4902744261f775aeede8b5627314b38482da29cf49a57b66a6fb753246c5/regex-2026.7.19-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d24ecb4f5e009ea0bd275ee37ad9953b32005e2e5e60f8bbae16da0dbbf0d3a0", size = 871270, upload-time = "2026-07-19T00:19:22.365Z" }, + { url = "https://files.pythonhosted.org/packages/16/70/6980c9be6bf21c0a60ed3e0aea39cf419ecf3b08d1d9947bc56e196ef186/regex-2026.7.19-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8cae6fd77a5b72dae505084b1a2ee0360139faf72fedbab667cd7cc65aae7a6a", size = 917534, upload-time = "2026-07-19T00:19:24.529Z" }, + { url = "https://files.pythonhosted.org/packages/52/92/8b2bd872782ce8c42691e39acb38eb8efe014e5ddb78ad7d943d6f197ce9/regex-2026.7.19-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9724e6cb5e478cd7d8cabf027826178739cb18cf0e117d0e32814d479fa02276", size = 816135, upload-time = "2026-07-19T00:19:26.919Z" }, + { url = "https://files.pythonhosted.org/packages/de/2d/33a602f657bdc4041f17d79f92ab18261d255d91a06117a6e29df023e5e2/regex-2026.7.19-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:572fc57b0009c735ee56c175ea021b637a15551a312f56734277f923d6fd0f6c", size = 785492, upload-time = "2026-07-19T00:19:29.192Z" }, + { url = "https://files.pythonhosted.org/packages/9e/36/0987cf4cb271680064a70d24a475873775a151d0b7058698a006cb0cae4a/regex-2026.7.19-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:20568e182eb82d39a6bf7cff3fd58566f14c75c6f74b2c8c96537eecf9010e3a", size = 800658, upload-time = "2026-07-19T00:19:31.392Z" }, + { url = "https://files.pythonhosted.org/packages/a8/24/c14f31c135e1ba55fa4f9a58ca98d0842512bf6188230763c31c8f449e3b/regex-2026.7.19-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:1d58561843f0ff7dc78b4c28b5e2dc388f3eff94ebc8a232a3adba961fc00009", size = 865073, upload-time = "2026-07-19T00:19:33.485Z" }, + { url = "https://files.pythonhosted.org/packages/14/85/181a12211f22469f24d2de1ebddfe397d2396e2c29013b9a58134a91069a/regex-2026.7.19-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:61bb1bd45520aacd56dd80943bd34991fb5350afdd1f36f2282230fd5154a218", size = 773684, upload-time = "2026-07-19T00:19:35.599Z" }, + { url = "https://files.pythonhosted.org/packages/23/58/bd1a0c1a62251366f8d21f41b1ea3c76994962071b8b6ea42f72d505c0f0/regex-2026.7.19-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:cd3584591ea4429026cdb931b054342c2bcf189b44ff367f8d5c15bc092a2966", size = 857769, upload-time = "2026-07-19T00:19:37.738Z" }, + { url = "https://files.pythonhosted.org/packages/e4/4f/f7e2dad6756b2fe1fe75dd90a628c3b45f249d39f948dd90cd2476325417/regex-2026.7.19-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5cc26a66e212fa5d6c6170c3a40d99d888db3020c6fdab1523250d4341382e44", size = 804546, upload-time = "2026-07-19T00:19:40.229Z" }, + { url = "https://files.pythonhosted.org/packages/2b/d7/01d31d5bdb09bc026fab77f59a371fdf8f9b292e4810546c56182ca70498/regex-2026.7.19-cp314-cp314t-win32.whl", hash = "sha256:2c4e61e2e1be56f63ec3cc618aa9e0de81ef6f43d177205451840022e24f5b78", size = 274526, upload-time = "2026-07-19T00:19:42.398Z" }, + { url = "https://files.pythonhosted.org/packages/52/0e/cea4ce73bc0a8247a0748228ae6669984c7e1f8134b6fa66e59c0572e0ea/regex-2026.7.19-cp314-cp314t-win_amd64.whl", hash = "sha256:c639ea314df70a7b2811e8020448c75af8c9445f5a60f8a4ced81c306a9380c2", size = 283763, upload-time = "2026-07-19T00:19:44.644Z" }, + { url = "https://files.pythonhosted.org/packages/6f/b6/26e41975febae63b7a6e3e02f32cff6cff2e4f10d19c929082f56aebf7c6/regex-2026.7.19-cp314-cp314t-win_arm64.whl", hash = "sha256:9a15e785f244f3e07847b984ce8773fc3da10a9f3c131cc49a4c5b4d672b4547", size = 283451, upload-time = "2026-07-19T00:19:46.639Z" }, +] + +[[package]] +name = "rpds-py" +version = "2026.6.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/aa/2a/9618a122aeb2a169a28b03889a2995fe297588964333d4a7d67bdf46e147/rpds_py-2026.6.3.tar.gz", hash = "sha256:1cebd1337c242e4ec2293e541f712b2da849b29f48f0c293684b71c0632625d4", size = 64051, upload-time = "2026-06-30T07:17:53.009Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a4/9e/b818ee580026ec578138e961027a68820c40afeb1ec8f6819b54fb99e196/rpds_py-2026.6.3-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:3cfe765c1da0072636ca06628261e0ea05688e160d5c8a03e0217c3854037223", size = 343012, upload-time = "2026-06-30T07:15:36.005Z" }, + { url = "https://files.pythonhosted.org/packages/f3/6b/686d9dc4359a8f163cfbbf89ee0b4e586431de22fe8248edb63a8cf50d49/rpds_py-2026.6.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f4d78253f6996be4901669ad25319f842f740eccf4d58e3c7f3dd39e6dde1d8f", size = 338203, upload-time = "2026-06-30T07:15:37.462Z" }, + { url = "https://files.pythonhosted.org/packages/9e/9b/069aa329940f8207615e091f5eedbbd40e1e15eac68a0790fd05ccdf796c/rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:54f45a148e28767bf343d33a684693c70e451c6f4c0e9904709a723fafbdfc1f", size = 367984, upload-time = "2026-06-30T07:15:39.008Z" }, + { url = "https://files.pythonhosted.org/packages/14/db/34c203e4becff3703e4d3bc121842c00b8689197f398161203a880052f4e/rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:842e7b070435622248c7a2c44ae53fa1440e073cc3023bc919fed570884097a7", size = 374815, upload-time = "2026-06-30T07:15:40.253Z" }, + { url = "https://files.pythonhosted.org/packages/ee/7d/8071067d2cc453d916ad836e828c943f575e8a44612537759002a1e07381/rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8020133a74bd81b4572dd8e4be028a6b1ebcd70e6726edc3918008c08bee6ee6", size = 490545, upload-time = "2026-06-30T07:15:41.729Z" }, + { url = "https://files.pythonhosted.org/packages/a3/42/da06c5aa8f0484ff07f270787434204d9f4535e2f8c3b51ed402267e63c3/rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cdc7e35386f3847df728fbcb5e887e2d79c19e2fa1eba9e51b6621d23e3243af", size = 382828, upload-time = "2026-06-30T07:15:43.327Z" }, + { url = "https://files.pythonhosted.org/packages/57/d7/fe978efc2ae50abe48eb7464668ea99f53c010c60aeebb7b35ad27f23661/rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:acac386b453c2516111b50985d60ce46e7fadb5ea71ae7b25f4c946935bf27cf", size = 365678, upload-time = "2026-06-30T07:15:44.992Z" }, + { url = "https://files.pythonhosted.org/packages/69/9d/1d8922e1990b2a6eb532b6ff53d3e73d2b3bbffc84116c75826bee73dfc6/rpds_py-2026.6.3-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:425560c6fa0415f27261727bb20bd097568485e5eb0c121f1949417d1c516885", size = 377811, upload-time = "2026-06-30T07:15:46.523Z" }, + { url = "https://files.pythonhosted.org/packages/b1/3d/198dceafb4fb034a6a47347e1b0735d34e0bd4a50be4e898d408ee66cb14/rpds_py-2026.6.3-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a550fb4950a06dde3beb4721f5ad4b25bf4513784665b0a8522c792e2bd822a4", size = 395382, upload-time = "2026-06-30T07:15:47.955Z" }, + { url = "https://files.pythonhosted.org/packages/1f/f1/13968e49655d40b6b19d8b9140296bbc6f1d86b3f0f6c346cf9f1adddf4b/rpds_py-2026.6.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4f4bca01b63096f606e095734dd56e74e175f94cfbf24ff3d63281cec61f7bb7", size = 543832, upload-time = "2026-06-30T07:15:49.33Z" }, + { url = "https://files.pythonhosted.org/packages/ac/ab/289bcb1b90bd3e40a2900c561fa0e2087345ecbb094f0b870f2345142b7c/rpds_py-2026.6.3-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ccffae9a092a00deb7efd545fe5e2c33c33b88e7c054337e9a74c179347d0b7d", size = 611011, upload-time = "2026-06-30T07:15:50.847Z" }, + { url = "https://files.pythonhosted.org/packages/1e/16/5043105e679436ccfbc8e5e0dd2d663ed18a8b8113515fd06a5e5d77c83e/rpds_py-2026.6.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1cf01971c4f2c5553b772a542e4aaf191789cd331bc2cd4ff0e6e65ba49e1e97", size = 572431, upload-time = "2026-06-30T07:15:52.394Z" }, + { url = "https://files.pythonhosted.org/packages/85/ed/adab103321c0a6565d5ae1c2998349bc3ee175b82ccc5ae8fc04cc413075/rpds_py-2026.6.3-cp313-cp313-win32.whl", hash = "sha256:8c3d1e9c15b9d51ca0391e13da1a25a0a4df3c58a37c9dc368e0736cf7f69df0", size = 201710, upload-time = "2026-06-30T07:15:53.894Z" }, + { url = "https://files.pythonhosted.org/packages/7b/ed/a03b09668e74e5dabbf2e211f6468e1820c0552f7b0500082da31841bf7b/rpds_py-2026.6.3-cp313-cp313-win_amd64.whl", hash = "sha256:9250a9a0a6fd4648b3f868da8d91a4c52b5811a62df58e753d50ae4454a36f80", size = 219454, upload-time = "2026-06-30T07:15:55.25Z" }, + { url = "https://files.pythonhosted.org/packages/27/17/b8642c12930b71bc2b25831f6708ccf0f75abcd11883932ec9ce54ba3a78/rpds_py-2026.6.3-cp313-cp313-win_arm64.whl", hash = "sha256:900a67df3fd1660b035a4761c4ce73c382ea6b35f90f9863c36c6fd8bf8b09bb", size = 215063, upload-time = "2026-06-30T07:15:56.573Z" }, + { url = "https://files.pythonhosted.org/packages/b6/36/7fbe9dcdaf857fb3f63c2a2284b62492d95f5e8334e947e5fb6e7f68c9be/rpds_py-2026.6.3-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:931908d9fc855d8f74783377822be318edb6dcb19e47169dc038f9a1bf60b06e", size = 344510, upload-time = "2026-06-30T07:15:57.921Z" }, + { url = "https://files.pythonhosted.org/packages/ba/54/f785cc3d3f60839ca57a5af4927a9f347b07b2799c373fc20f7949f87c7e/rpds_py-2026.6.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:d7469697dce35be237db177d42e2a2ee26e6dcc5fc052078a6fefabd288c6edd", size = 339495, upload-time = "2026-06-30T07:15:59.238Z" }, + { url = "https://files.pythonhosted.org/packages/63/ef/d4cdaf309e6b095b43597103cf8c0b951d6cca2acce68c474f75ec12e0c7/rpds_py-2026.6.3-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bcfbcf66006befb9fd2aeaa9e01feaf881b4dc330a02ba07d2322b1c11be7b5d", size = 369454, upload-time = "2026-06-30T07:16:01.021Z" }, + { url = "https://files.pythonhosted.org/packages/96/4a/9559a68b7ee15db09d7981212e8c2e219d2a1d6d4faa0391d813c3496a36/rpds_py-2026.6.3-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:847927daf4cffbd4e90e42bc890069897101edd015f956cb8721b3473372edda", size = 374583, upload-time = "2026-06-30T07:16:02.287Z" }, + { url = "https://files.pythonhosted.org/packages/ef/75/8964aa7d2c6e8ac43eba8eb6e6b0fdda1f46d39f2fc3e6aa9f2cb17f485d/rpds_py-2026.6.3-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:aca6c1ef08a82bfe327cc156da694660f599923e2e6665b6d81c9c2d0ac9ffc8", size = 492919, upload-time = "2026-06-30T07:16:03.723Z" }, + { url = "https://files.pythonhosted.org/packages/8f/97/6908094ac804115e65aedfd90f1b5fee4eebebd3f6c4cfc5419939267565/rpds_py-2026.6.3-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ae50181a047c871561212bb97f7932a2d45fb53e947bd9b57ebad85b529cbc53", size = 383725, upload-time = "2026-06-30T07:16:05.305Z" }, + { url = "https://files.pythonhosted.org/packages/d1/9c/0d1fdc2e7aba23e290d603bc494e97bd205bae262ce33c6b32a69768ed5e/rpds_py-2026.6.3-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dc319e5a1de4b6913aac94bf6a2f9e847371e0a140a43dd4991db1a09bc2d504", size = 367255, upload-time = "2026-06-30T07:16:07.086Z" }, + { url = "https://files.pythonhosted.org/packages/c4/fe/f0209ca4a9ed074bc8acb44dfd0e81c3122e94c9689f5645b7973a866719/rpds_py-2026.6.3-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:e4316bf32babbed84e691e352faf967ce2f0f024174a8643c37c94a1080374fc", size = 379060, upload-time = "2026-06-30T07:16:08.525Z" }, + { url = "https://files.pythonhosted.org/packages/c6/8d/f1cc54c616b9d8897de8738aac148d20afca93f68187475fe194d09a71b9/rpds_py-2026.6.3-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:8c6e5a2f750cc71c3e3b11d71661f21d6f9bc6cebc6564b1466417a1ec03ec77", size = 395960, upload-time = "2026-06-30T07:16:09.989Z" }, + { url = "https://files.pythonhosted.org/packages/fb/04/aafff00f73aeca2945f734f1d483c64ab8f472d0864ab02377fd8e89c3b2/rpds_py-2026.6.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:4470ce197d4090875cf6affbf1f853338387428df97c4fb7b7106317b8214698", size = 545356, upload-time = "2026-06-30T07:16:11.816Z" }, + { url = "https://files.pythonhosted.org/packages/fd/cc/e229663b9e4ddac5a4acbe9085dd80a71af2a5d356b8b39d6bff233f24b0/rpds_py-2026.6.3-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:ea964164cc9afa72d4d9b23cc28dafae93693c0a53e0b42acbff15b22c3f9ddd", size = 612319, upload-time = "2026-06-30T07:16:13.586Z" }, + { url = "https://files.pythonhosted.org/packages/e3/7a/8a0e6d3e6cd066af108b71b43122c3fe158dd9eb86acac626593a2582eb1/rpds_py-2026.6.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:639c8929aa0afe81be836b04de888460d6bed38b9c54cfc18da8f6bfabf5af5d", size = 573508, upload-time = "2026-06-30T07:16:15.23Z" }, + { url = "https://files.pythonhosted.org/packages/87/03/2a69ab618a789cf6cf85c86bb844c62d090e700ab1a2aa676b3741b6c516/rpds_py-2026.6.3-cp314-cp314-win32.whl", hash = "sha256:882076c00c0a608b131187055ddc5ae29f2e7eaf870d6168980420d58528a5c8", size = 202504, upload-time = "2026-06-30T07:16:16.893Z" }, + { url = "https://files.pythonhosted.org/packages/85/62/a3892ba945f4e24c78f352e5de3c7620d8479f73f211406a97263d13c7d2/rpds_py-2026.6.3-cp314-cp314-win_amd64.whl", hash = "sha256:0be972be84cfcaf46c8c6edf690ca0f154ac17babf1f6a955a51579b34ad2dc5", size = 220380, upload-time = "2026-06-30T07:16:18.108Z" }, + { url = "https://files.pythonhosted.org/packages/3d/e7/c2bd44dc831931815ad11ebb5f430b5a0a4d3caa9de837107876c30c3432/rpds_py-2026.6.3-cp314-cp314-win_arm64.whl", hash = "sha256:2a9c6f195058cb45335e8cc3802745c603d716eb96bc9625950c1aac71c0c703", size = 215976, upload-time = "2026-06-30T07:16:19.654Z" }, + { url = "https://files.pythonhosted.org/packages/79/9c/fff7b74bce9a091ec9a012a03f9ff5f69364eaf9451060dfc4486da2ffdd/rpds_py-2026.6.3-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:f90938e92afda60266da758ee7d363447f7f0138c9559f9e1811629580582d90", size = 346840, upload-time = "2026-06-30T07:16:21.268Z" }, + { url = "https://files.pythonhosted.org/packages/e9/44/77bcb1168b33704908295533d27f10eb811e9e3e193e8993dc99572211d3/rpds_py-2026.6.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ec829541c45bca16e61c7ae50c20501f213605beb75d1aba91a6ee37fbbb56a4", size = 340282, upload-time = "2026-06-30T07:16:22.875Z" }, + { url = "https://files.pythonhosted.org/packages/87/3c/7a9081c7c9e645b39efe19e4ffbeccd80add246327cd9b888aecffd72317/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:afd70d95892096cdb26f15a00c45907b17817577aa8d1c76b2dcc2788391f9e9", size = 370403, upload-time = "2026-06-30T07:16:24.415Z" }, + { url = "https://files.pythonhosted.org/packages/f7/69/af47021eb7dad6ff3396cb001c08f0f3c4d06c20253f75be6421a59fe6b7/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:29dfa0533a5d4c94d4dfa1b694fcb56c9c63aad8330ffdd816fd225d0a7a162f", size = 376055, upload-time = "2026-06-30T07:16:26.111Z" }, + { url = "https://files.pythonhosted.org/packages/81/fc/a3bcf517084396a6dd258c592567a3c011ba4557f2fde23dceaf26e74f2e/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:af05d726809bff6b141be124d4c7ce998f9c9c7f30edb1f46c07aa103d540b41", size = 494419, upload-time = "2026-06-30T07:16:27.596Z" }, + { url = "https://files.pythonhosted.org/packages/c9/eb/13d529d1788135425c7bf207f8463458ca5d92e43f3f701365b83e9dffc1/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9826217f048f620d9a712672818bf231442c1b35d96b227a07eabd11b4bb6945", size = 384848, upload-time = "2026-06-30T07:16:29.183Z" }, + { url = "https://files.pythonhosted.org/packages/8e/f4/b7ac49f30013aba8f7b9566b1dd07e81de95e708c1374b7bacc5b9bc5c9c/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:536bceea4fa4acf7e1c61da2b5786304367c816c8895be71b8f537c480b0ea1f", size = 371369, upload-time = "2026-06-30T07:16:30.912Z" }, + { url = "https://files.pythonhosted.org/packages/31/86/6260bafa622f788b07ddec0e52d810305c8b9b0b8c27f58a2ab04bf62b4f/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:bc0011654b91cc4fb2ae701bec0a0ba1e552c0714247fa7af6c59e0ccfa3a4e1", size = 379673, upload-time = "2026-06-30T07:16:32.486Z" }, + { url = "https://files.pythonhosted.org/packages/19/c3/03f1ee79a047b48daeca157c89a18509cde22b6b951d642b9b0af1be660a/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:539d75de9e0d536c84ff18dfeb805398e58227001ce09231a26a08b9aed1ee0e", size = 397500, upload-time = "2026-06-30T07:16:34.471Z" }, + { url = "https://files.pythonhosted.org/packages/f0/95/8ed0cd8c377dca12aea498f119fe639fc474d1461545c39d2b5872eb1c0f/rpds_py-2026.6.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:166cf54d9f44fc6ceb53c7860258dde44a81406646de79f8ed3234fca3b6e538", size = 545978, upload-time = "2026-06-30T07:16:36.45Z" }, + { url = "https://files.pythonhosted.org/packages/d3/f2/0eb57f0eaa83f8fc152a7e03de968ab77e1f00732bebc892b190c6eebde7/rpds_py-2026.6.3-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:d34c20167764fbcf927194d532dd7e0c56772f0a5f943fa5ef9e9afbba8fb9db", size = 613350, upload-time = "2026-06-30T07:16:38.213Z" }, + { url = "https://files.pythonhosted.org/packages/5b/de/e0674bdbc3ef7634989b3f854c3f34bc1f587d36e5bfdc5c378d57034619/rpds_py-2026.6.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ea7bb13b7c9a29791f87a0387ba7d3ad3a6d783d827e4d3f27b40a0ff44495e2", size = 576486, upload-time = "2026-06-30T07:16:39.797Z" }, + { url = "https://files.pythonhosted.org/packages/f2/f6/21101359743cd136ada781e8210a85769578422ba460672eea0e29739200/rpds_py-2026.6.3-cp314-cp314t-win32.whl", hash = "sha256:6de4744d05bd1aa1be4ed7ea1189e3979196808008113bbbf899a460966b925e", size = 201068, upload-time = "2026-06-30T07:16:41.316Z" }, + { url = "https://files.pythonhosted.org/packages/a6/b2/9574d4d44f7760c2aa32d92a0a4f41698e33f5b204a0bf5c9758f52c79d5/rpds_py-2026.6.3-cp314-cp314t-win_amd64.whl", hash = "sha256:c7b9a2f8f4d8e90af72571d3d495deebdd7e3c75451f5b41719aee166e940fc2", size = 220600, upload-time = "2026-06-30T07:16:43.091Z" }, + { url = "https://files.pythonhosted.org/packages/08/ae/f23a2697e6ee6340a578b0f136be6483657bef0c6f9497b752bb5c0964bb/rpds_py-2026.6.3-cp315-cp315-macosx_10_12_x86_64.whl", hash = "sha256:e059c5dde6452b44424bd1834557556c226b57781dee1227af23518459722b13", size = 344726, upload-time = "2026-06-30T07:16:44.5Z" }, + { url = "https://files.pythonhosted.org/packages/c3/63/e7b3a1a5358dd32c930a1062d8e15b67fd6e8922e81df9e91706d66ee5c8/rpds_py-2026.6.3-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:2f7c26fbc5acd2522b95d4177fe4710ffd8e9b20529e703ffbf8db4d93903f05", size = 339587, upload-time = "2026-06-30T07:16:46.255Z" }, + { url = "https://files.pythonhosted.org/packages/ec/64/10a85681916ca55fffb91b0a211f84e34297c109243484dd6394660a8a7c/rpds_py-2026.6.3-cp315-cp315-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a3086b538543802f84c843911242db20447de00d8752dd0efc936dbcf02218ba", size = 369585, upload-time = "2026-06-30T07:16:48.101Z" }, + { url = "https://files.pythonhosted.org/packages/76/c2/baf95c7c38823e12ba34407c5f5767a89e5cf2233895e56f608167ae9493/rpds_py-2026.6.3-cp315-cp315-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8f2e5c5ee828d42cb11760761c0af6507927bec42d0ad5458f97c9203b054617", size = 375479, upload-time = "2026-06-30T07:16:49.93Z" }, + { url = "https://files.pythonhosted.org/packages/6a/94/0aad06c72d65101e11d33528d438cda99a39ce0da99466e156158f2541d3/rpds_py-2026.6.3-cp315-cp315-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ed0c1e5d10cdc7135537988c74a0188da68e2f3c30813ba3744ab1e42e0480f9", size = 492418, upload-time = "2026-06-30T07:16:51.641Z" }, + { url = "https://files.pythonhosted.org/packages/b5/17/de3f5a479a1f056535d7489819639d8cd591ea6281d700390b43b1abd745/rpds_py-2026.6.3-cp315-cp315-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8c2642a7603ec0b16ed77da4555db3b4b472341904873788327c0b0d7b95f1bb", size = 384123, upload-time = "2026-06-30T07:16:53.622Z" }, + { url = "https://files.pythonhosted.org/packages/46/7d/bf09bd1b145bb2671c03e1e6d1ab8651858d90d8c7dfeadd85a37a934fd8/rpds_py-2026.6.3-cp315-cp315-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8e4320744c1ffdd95a603def63344bfab2d33edeab301c5007e7de9f9f5b3885", size = 367351, upload-time = "2026-06-30T07:16:55.241Z" }, + { url = "https://files.pythonhosted.org/packages/a3/ea/1bb734f314b8be319149ddee80b18bd41372bdcfbdf88d28131c0cd37719/rpds_py-2026.6.3-cp315-cp315-manylinux_2_31_riscv64.whl", hash = "sha256:a9f4645593036b81bbdb36b9c8e0ea0d1c3fee968c4d59db0344c14087ef143a", size = 378827, upload-time = "2026-06-30T07:16:56.841Z" }, + { url = "https://files.pythonhosted.org/packages/4b/93/d9611e5b25e26df9a3649813ed66193ace9347a7c7fc4ab7cf70e94851c0/rpds_py-2026.6.3-cp315-cp315-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:e55d236be29255554da47abe5c577637db7c24a02b8b46f0ca9524c855801868", size = 395966, upload-time = "2026-06-30T07:16:58.557Z" }, + { url = "https://files.pythonhosted.org/packages/c3/cb/99d77e16e5534ae1d90629bbe419ba6ee170833a6a85e3aa1cc41726fbbc/rpds_py-2026.6.3-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:24e9c5386e16669b674a69c156c8eeefcb578f3b3397b713b08e6d60f3c7b187", size = 545680, upload-time = "2026-06-30T07:17:00.164Z" }, + { url = "https://files.pythonhosted.org/packages/59/15/11a29755f790cef7a2f755e8e14f4f0c33f39489e1893a632a2eee59672b/rpds_py-2026.6.3-cp315-cp315-musllinux_1_2_i686.whl", hash = "sha256:c60924535c75f1566b6eb75b5c31a48a43fef04fa2d0d201acbad8a9969c6107", size = 611853, upload-time = "2026-06-30T07:17:01.962Z" }, + { url = "https://files.pythonhosted.org/packages/68/86/0c27547e21644da938fb530f7e1a8148dd24d02db07e7a5f2567a17ce710/rpds_py-2026.6.3-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:38a2fea2787428f811719ceb9114cb78964a3138838320c29ac39526c79c16ba", size = 573715, upload-time = "2026-06-30T07:17:03.693Z" }, + { url = "https://files.pythonhosted.org/packages/29/71/4d8fcf700931815594bce892255bbd973b94efaf0fc1932b0590df18d886/rpds_py-2026.6.3-cp315-cp315-win32.whl", hash = "sha256:d483fe17f01ad64b7bf7cc38fcefff1ca9fb83f8c2b2542b68f97ffe0611b369", size = 202864, upload-time = "2026-06-30T07:17:05.746Z" }, + { url = "https://files.pythonhosted.org/packages/eb/62/b577562de0edbb55b2be85ce5fd09c33e386b9b13eee09833af4240fd5c4/rpds_py-2026.6.3-cp315-cp315-win_amd64.whl", hash = "sha256:67e3a721ffc5d8d2210d3671872298c4a84e4b8035cfe42ffd7cde35d772b146", size = 220430, upload-time = "2026-06-30T07:17:07.471Z" }, + { url = "https://files.pythonhosted.org/packages/c8/95/d6d0b2509825141eef60669a5739eec88dbc6a48053d6c92993a5704defe/rpds_py-2026.6.3-cp315-cp315-win_arm64.whl", hash = "sha256:6e84adbcf4bf841aed8116a8264b9f50b4cb3e7bd89b516122e616ac56ca269e", size = 215877, upload-time = "2026-06-30T07:17:09.008Z" }, + { url = "https://files.pythonhosted.org/packages/b7/bf/f3ea278f0afd615c1d0f19cb69043a41526e2bb600c2b536eb192218eb27/rpds_py-2026.6.3-cp315-cp315t-macosx_10_12_x86_64.whl", hash = "sha256:ae6dd8f10bd17aad820876d24caec9efdafd80a318d16c0a48edb5e136902c6b", size = 346933, upload-time = "2026-06-30T07:17:10.762Z" }, + { url = "https://files.pythonhosted.org/packages/9d/29/9907bdf1c5346763cf10b7f6852aad86652168c259def904cbe0082c5864/rpds_py-2026.6.3-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:bdbd97738551fca3917c1bd7188bec1920bb520104f28e7e1007f9ceb17b7690", size = 340274, upload-time = "2026-06-30T07:17:12.266Z" }, + { url = "https://files.pythonhosted.org/packages/6f/2c/8e03767b5778ef25cebf74a7a91a2c3806f8eced4c92cb7406bbe060756d/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8b95977e7211527ab0ba576e286d023389fbeeb32a6b7b771665d333c60e5342", size = 370763, upload-time = "2026-06-30T07:17:14.107Z" }, + { url = "https://files.pythonhosted.org/packages/2e/e1/df2a7e1ba2efd796af26194250b8d42c821b46592311595162af9ef0528d/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d15fde0e6fb0d88a60d221204873743e5d9f0b7d29165e62cd86d0413ad74ba6", size = 376467, upload-time = "2026-06-30T07:17:15.76Z" }, + { url = "https://files.pythonhosted.org/packages/6b/de/8a0814d1946af29cb068fb259aa8622f856df1d0bab58429448726b537f5/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a136d453475ac0fcbda502ef1e6504bd28d6d904700915d278deeab0d00fe140", size = 496689, upload-time = "2026-06-30T07:17:17.308Z" }, + { url = "https://files.pythonhosted.org/packages/df/f3/f19e0c852ba13694f5a79f3b719331051573cb5693feacf8a88ffffc3a71/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f826877d462181e5eb1c26a0026b8d0cab05d99844ecb6d8bf3627a2ca0c0442", size = 385340, upload-time = "2026-06-30T07:17:18.928Z" }, + { url = "https://files.pythonhosted.org/packages/e2/ae/7ec3a9d2d4351f99e37bcb06b6b6f954512646bfdbf9742e1de727865daf/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:79486287de1730dbaff3dbd124d0ca4d2ef7f9d29bf2544f1f93c09b5bcbbd12", size = 372179, upload-time = "2026-06-30T07:17:20.539Z" }, + { url = "https://files.pythonhosted.org/packages/d3/ac/9cee911dff2aaa9a5a8354f6610bf2e6a616de9197c5fff4f54f82585f1e/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_31_riscv64.whl", hash = "sha256:808345f53cb952433ca2816f1604ff3515608a81784954f38d4452acfe8e61d5", size = 379993, upload-time = "2026-06-30T07:17:22.212Z" }, + { url = "https://files.pythonhosted.org/packages/83/6b/7c2a07ba88d1e9a936612f7a5d067467ed03d971d5a06f7d309dff044a7e/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1967debc37f64f2c4dc90a7f563aec558b471966e12adcac4e1c4240496b6ebf", size = 398909, upload-time = "2026-06-30T07:17:23.66Z" }, + { url = "https://files.pythonhosted.org/packages/97/0b/776ffcb66783637b0031f6d58d6fb55913c8b5abf00aeecd46bf933fb477/rpds_py-2026.6.3-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:f0840b5b17057f7fd918b76183a4b5a0635f43e14eb2ce60dce1d4ee4707ea00", size = 546584, upload-time = "2026-06-30T07:17:25.264Z" }, + { url = "https://files.pythonhosted.org/packages/55/33/ba3bc04d7092bd553c9b2b195624992d2cc4f3de1f380b7b93cbee67bd79/rpds_py-2026.6.3-cp315-cp315t-musllinux_1_2_i686.whl", hash = "sha256:faa679d19a6696fd54259ad321251ad77a13e70e03dd834daa762a44fb6196ef", size = 614357, upload-time = "2026-06-30T07:17:26.888Z" }, + { url = "https://files.pythonhosted.org/packages/8b/71/14edf065f04630b1a8472f7653cad03f6c478bcf95ea0e6aed55451e33ea/rpds_py-2026.6.3-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:23a439f31ccbeff1574e24889128821d1f7917470e830cf6544dced1c662262a", size = 576533, upload-time = "2026-06-30T07:17:28.546Z" }, + { url = "https://files.pythonhosted.org/packages/ba/76/65002b08596c389105720a8c0d22298b8dc25a4baf89b2ce431343c8b1de/rpds_py-2026.6.3-cp315-cp315t-win32.whl", hash = "sha256:913ca42ccad3f8cc6e292b587ae8ae49c8c823e5dce51a736252fc7c7cdfa577", size = 201204, upload-time = "2026-06-30T07:17:30.193Z" }, + { url = "https://files.pythonhosted.org/packages/8c/97/d855d6b3c322d1f27e26f5241c42016b56cf01377ea8ed348285f54652f0/rpds_py-2026.6.3-cp315-cp315t-win_amd64.whl", hash = "sha256:ae3d4fe8c0b9213624fdce7279d70e3b148b682ca20719ebd193a23ebfa47324", size = 220719, upload-time = "2026-06-30T07:17:31.788Z" }, ] [[package]] name = "ruff" -version = "0.15.10" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/e7/d9/aa3f7d59a10ef6b14fe3431706f854dbf03c5976be614a9796d36326810c/ruff-0.15.10.tar.gz", hash = "sha256:d1f86e67ebfdef88e00faefa1552b5e510e1d35f3be7d423dc7e84e63788c94e", size = 4631728, upload-time = "2026-04-09T14:06:09.884Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/eb/00/a1c2fdc9939b2c03691edbda290afcd297f1f389196172826b03d6b6a595/ruff-0.15.10-py3-none-linux_armv6l.whl", hash = "sha256:0744e31482f8f7d0d10a11fcbf897af272fefdfcb10f5af907b18c2813ff4d5f", size = 10563362, upload-time = "2026-04-09T14:06:21.189Z" }, - { url = "https://files.pythonhosted.org/packages/5c/15/006990029aea0bebe9d33c73c3e28c80c391ebdba408d1b08496f00d422d/ruff-0.15.10-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:b1e7c16ea0ff5a53b7c2df52d947e685973049be1cdfe2b59a9c43601897b22e", size = 10951122, upload-time = "2026-04-09T14:06:02.236Z" }, - { url = "https://files.pythonhosted.org/packages/f2/c0/4ac978fe874d0618c7da647862afe697b281c2806f13ce904ad652fa87e4/ruff-0.15.10-py3-none-macosx_11_0_arm64.whl", hash = "sha256:93cc06a19e5155b4441dd72808fdf84290d84ad8a39ca3b0f994363ade4cebb1", size = 10314005, upload-time = "2026-04-09T14:06:00.026Z" }, - { url = "https://files.pythonhosted.org/packages/da/73/c209138a5c98c0d321266372fc4e33ad43d506d7e5dd817dd89b60a8548f/ruff-0.15.10-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:83e1dd04312997c99ea6965df66a14fb4f03ba978564574ffc68b0d61fd3989e", size = 10643450, upload-time = "2026-04-09T14:05:42.137Z" }, - { url = "https://files.pythonhosted.org/packages/ec/76/0deec355d8ec10709653635b1f90856735302cb8e149acfdf6f82a5feb70/ruff-0.15.10-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8154d43684e4333360fedd11aaa40b1b08a4e37d8ffa9d95fee6fa5b37b6fab1", size = 10379597, upload-time = "2026-04-09T14:05:49.984Z" }, - { url = "https://files.pythonhosted.org/packages/dc/be/86bba8fc8798c081e28a4b3bb6d143ccad3fd5f6f024f02002b8f08a9fa3/ruff-0.15.10-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8ab88715f3a6deb6bde6c227f3a123410bec7b855c3ae331b4c006189e895cef", size = 11146645, upload-time = "2026-04-09T14:06:12.246Z" }, - { url = "https://files.pythonhosted.org/packages/a8/89/140025e65911b281c57be1d385ba1d932c2366ca88ae6663685aed8d4881/ruff-0.15.10-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a768ff5969b4f44c349d48edf4ab4f91eddb27fd9d77799598e130fb628aa158", size = 12030289, upload-time = "2026-04-09T14:06:04.776Z" }, - { url = "https://files.pythonhosted.org/packages/88/de/ddacca9545a5e01332567db01d44bd8cf725f2db3b3d61a80550b48308ea/ruff-0.15.10-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0ee3ef42dab7078bda5ff6a1bcba8539e9857deb447132ad5566a038674540d0", size = 11496266, upload-time = "2026-04-09T14:05:55.485Z" }, - { url = "https://files.pythonhosted.org/packages/bc/bb/7ddb00a83760ff4a83c4e2fc231fd63937cc7317c10c82f583302e0f6586/ruff-0.15.10-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:51cb8cc943e891ba99989dd92d61e29b1d231e14811db9be6440ecf25d5c1609", size = 11256418, upload-time = "2026-04-09T14:05:57.69Z" }, - { url = "https://files.pythonhosted.org/packages/dc/8d/55de0d35aacf6cd50b6ee91ee0f291672080021896543776f4170fc5c454/ruff-0.15.10-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:e59c9bdc056a320fb9ea1700a8d591718b8faf78af065484e801258d3a76bc3f", size = 11288416, upload-time = "2026-04-09T14:05:44.695Z" }, - { url = "https://files.pythonhosted.org/packages/68/cf/9438b1a27426ec46a80e0a718093c7f958ef72f43eb3111862949ead3cc1/ruff-0.15.10-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:136c00ca2f47b0018b073f28cb5c1506642a830ea941a60354b0e8bc8076b151", size = 10621053, upload-time = "2026-04-09T14:05:52.782Z" }, - { url = "https://files.pythonhosted.org/packages/4c/50/e29be6e2c135e9cd4cb15fbade49d6a2717e009dff3766dd080fcb82e251/ruff-0.15.10-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:8b80a2f3c9c8a950d6237f2ca12b206bccff626139be9fa005f14feb881a1ae8", size = 10378302, upload-time = "2026-04-09T14:06:14.361Z" }, - { url = "https://files.pythonhosted.org/packages/18/2f/e0b36a6f99c51bb89f3a30239bc7bf97e87a37ae80aa2d6542d6e5150364/ruff-0.15.10-py3-none-musllinux_1_2_i686.whl", hash = "sha256:e3e53c588164dc025b671c9df2462429d60357ea91af7e92e9d56c565a9f1b07", size = 10850074, upload-time = "2026-04-09T14:06:16.581Z" }, - { url = "https://files.pythonhosted.org/packages/11/08/874da392558ce087a0f9b709dc6ec0d60cbc694c1c772dab8d5f31efe8cb/ruff-0.15.10-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:b0c52744cf9f143a393e284125d2576140b68264a93c6716464e129a3e9adb48", size = 11358051, upload-time = "2026-04-09T14:06:18.948Z" }, - { url = "https://files.pythonhosted.org/packages/e4/46/602938f030adfa043e67112b73821024dc79f3ab4df5474c25fa4c1d2d14/ruff-0.15.10-py3-none-win32.whl", hash = "sha256:d4272e87e801e9a27a2e8df7b21011c909d9ddd82f4f3281d269b6ba19789ca5", size = 10588964, upload-time = "2026-04-09T14:06:07.14Z" }, - { url = "https://files.pythonhosted.org/packages/25/b6/261225b875d7a13b33a6d02508c39c28450b2041bb01d0f7f1a83d569512/ruff-0.15.10-py3-none-win_amd64.whl", hash = "sha256:28cb32d53203242d403d819fd6983152489b12e4a3ae44993543d6fe62ab42ed", size = 11745044, upload-time = "2026-04-09T14:05:39.473Z" }, - { url = "https://files.pythonhosted.org/packages/58/ed/dea90a65b7d9e69888890fb14c90d7f51bf0c1e82ad800aeb0160e4bacfd/ruff-0.15.10-py3-none-win_arm64.whl", hash = "sha256:601d1610a9e1f1c2165a4f561eeaa2e2ea1e97f3287c5aa258d3dab8b57c6188", size = 11035607, upload-time = "2026-04-09T14:05:47.593Z" }, +version = "0.16.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/61/b3/3213589383f8f1b3938781bd1278713f6d18621a14992b3e81fefb8a5ef9/ruff-0.16.3.tar.gz", hash = "sha256:e76d33a347661a84b5be6d043d0347fdc745dfdcf825a8f4fed64b5e26eebdf2", size = 4891904, upload-time = "2026-08-13T15:17:13.381Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bf/96/493770daebd68c0a67f1549fdf519f53be51fc435186c0585bcc272fd76c/ruff-0.16.3-py3-none-linux_armv6l.whl", hash = "sha256:0c5710e247a58a4521e66e124ba9a74655b414f61ba3a2e9e3811e11098f48f7", size = 10902799, upload-time = "2026-08-13T15:16:27.382Z" }, + { url = "https://files.pythonhosted.org/packages/5e/e6/2becf3942fddc29a29b8df47691d456fb1085391a694f74d84513251418c/ruff-0.16.3-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:fe155130631a2471fd2e14a7a664a4dfbd7194b8229c3d7b2a40b21178639081", size = 11135539, upload-time = "2026-08-13T15:16:30.87Z" }, + { url = "https://files.pythonhosted.org/packages/3e/1e/4b8b72f0d006dbf19326aa99f9ca0ee2ff374187c4d301cf529a51aa06fe/ruff-0.16.3-py3-none-macosx_11_0_arm64.whl", hash = "sha256:e2ed719e14aa64d895c2ee922594a90a43c861a93f0575a95ff8c47cdbd13eb9", size = 10475095, upload-time = "2026-08-13T15:16:33.259Z" }, + { url = "https://files.pythonhosted.org/packages/92/32/2201fa49ba1f6c101ee321e83f051ac7a4b8d07b0ef6b4d3f2772b302275/ruff-0.16.3-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9e0b1da805eb043654645d74d5de1e5ce2edc686e40790d2b86f56d71cc06a84", size = 10668771, upload-time = "2026-08-13T15:16:35.65Z" }, + { url = "https://files.pythonhosted.org/packages/c3/66/4afc5c8363bd04d45effce1b7c8713ca037d7a6740b7451a2403a6e3a972/ruff-0.16.3-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a37bdea0bbe21780f590bf437d6412c8c4e1b6cd010f91a65c2c40c5e5f5f870", size = 10699568, upload-time = "2026-08-13T15:16:38.195Z" }, + { url = "https://files.pythonhosted.org/packages/53/fd/c67d246bf36bf1698551c56de39e95cd07f70e64433e0098e6267d77061b/ruff-0.16.3-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:09571e6d1288ed9be475207a3ac04ada404f1cd898104be0f6ab8d7df438575b", size = 11499365, upload-time = "2026-08-13T15:16:40.623Z" }, + { url = "https://files.pythonhosted.org/packages/67/0b/00ecbceb99a263af7b12f6f05ac3c92bc47b905e91adc3f207a836e3bc01/ruff-0.16.3-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2c18c5a101eb540010638cc1ff3c84944d3adb3df62b8d98ca8f22ba484d3413", size = 12311728, upload-time = "2026-08-13T15:16:43.564Z" }, + { url = "https://files.pythonhosted.org/packages/54/b2/b7b3bb54f4d3f7db504e476ad4ab8de530dceebe2c061384b2757ee419e8/ruff-0.16.3-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8457c44f15033c85ddbb77b15d451df9e24e4bd03b628396dd3610cedc3b8f82", size = 11699896, upload-time = "2026-08-13T15:16:46.209Z" }, + { url = "https://files.pythonhosted.org/packages/c7/30/4c468429ac195addc5ee1b717b6ab1b66632786737ca3b2ed3443fb0c26a/ruff-0.16.3-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:294b95c4ae0cda9388525c2047778aa758d6b8d4bb876fd4e9eaa3ebc92343eb", size = 11058736, upload-time = "2026-08-13T15:16:48.823Z" }, + { url = "https://files.pythonhosted.org/packages/43/67/7a113cdaddf24b64d7f75b1242a99d04c82fcef4f6921fdbb832beaffb5f/ruff-0.16.3-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:3d0c7c40c87c2a820509c31ba007968da6e1306468c067b2d82fbfdbcd0e8474", size = 11586911, upload-time = "2026-08-13T15:16:51.913Z" }, + { url = "https://files.pythonhosted.org/packages/f1/c1/2e66f24c0f3ead25a5e660111778685e505e5da353c82802bf49f0cbe7b9/ruff-0.16.3-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:9f738c0fdfa8eed0b2ce7fb27ee7258208a92a68d7949e62aa15164bc7b389da", size = 10954265, upload-time = "2026-08-13T15:16:54.763Z" }, + { url = "https://files.pythonhosted.org/packages/c2/ba/4cee23bf52cba9a058d3726de623624daf50ef9638868edd86f4126157f6/ruff-0.16.3-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:fb785f0be25abe69d320415cd4f833b59e17ba7613d9ba6a958023b6bceb0a50", size = 10709886, upload-time = "2026-08-13T15:16:57.339Z" }, + { url = "https://files.pythonhosted.org/packages/82/df/7da7194fa5d9dc0a285f7e6fa5a4722e7c63faac0b45b614ded9314363a1/ruff-0.16.3-py3-none-musllinux_1_2_i686.whl", hash = "sha256:c5536e3acfbf9563085aa2be7b13c629c3077e902afc5b941ac44024dbb9f506", size = 11210392, upload-time = "2026-08-13T15:17:00.171Z" }, + { url = "https://files.pythonhosted.org/packages/35/85/7795f6e817af050e7517bf3e7aa9b061cce70ef33d280aad902c956c1ecf/ruff-0.16.3-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:a2d85c02f9b8e165d85e6779184d38c4132de12603dab59c51c28e22584f9e4d", size = 11626910, upload-time = "2026-08-13T15:17:03.299Z" }, + { url = "https://files.pythonhosted.org/packages/78/9b/475b927cf27a5cbbda3c7bafb69ed6ff77e1d7923d5d85f17c2749d7ae32/ruff-0.16.3-py3-none-win32.whl", hash = "sha256:388cdf2166642bd9b13d52b5932d3170f34f8abed7e8d9a855f1d84b83645a0a", size = 10931415, upload-time = "2026-08-13T15:17:05.726Z" }, + { url = "https://files.pythonhosted.org/packages/b2/99/e2a2bfc4fbf0a1e8a916bc9ebe6fe6c58cc34c28e0ffc6ce281d572d1c2e/ruff-0.16.3-py3-none-win_amd64.whl", hash = "sha256:e80a7d69ca2a6d1c4d352ec91458cdca6e56c83cdbcabd93e4abe1e53591d948", size = 11445993, upload-time = "2026-08-13T15:17:08.353Z" }, + { url = "https://files.pythonhosted.org/packages/69/3e/4132e539aed78c148854d4997a2685b0ed4dc4e87110b59ce528564e184e/ruff-0.16.3-py3-none-win_arm64.whl", hash = "sha256:b8ca152da82c1acc1fa8d5874b15951935f0eef46f10e6954c83859011b6178a", size = 11399302, upload-time = "2026-08-13T15:17:10.908Z" }, ] [[package]] name = "starlette" -version = "1.0.0" +version = "1.6.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/81/69/17425771797c36cded50b7fe44e850315d039f28b15901ab44839e70b593/starlette-1.0.0.tar.gz", hash = "sha256:6a4beaf1f81bb472fd19ea9b918b50dc3a77a6f2e190a12954b25e6ed5eea149", size = 2655289, upload-time = "2026-03-22T18:29:46.779Z" } +sdist = { url = "https://files.pythonhosted.org/packages/b5/b4/205b0d5241d934e8add0c38aa924c4f9fb7330834ff11e5444db964ec3f9/starlette-1.6.0.tar.gz", hash = "sha256:d4e3ac5e546444960c710297a3c9fc3f7ebae1b7e963f3d36173b49da535be9b", size = 2716969, upload-time = "2026-08-08T18:27:57.512Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c8/cb/6a6a47d5b464bd08695d254f3da6e7986cc70c9fa5d778eda57538edfe56/starlette-1.6.0-py3-none-any.whl", hash = "sha256:a86dd39d14bb45f85a3d18525215a9ef0cfd1f192ac793220e72598c90335f0c", size = 75969, upload-time = "2026-08-08T18:27:56.196Z" }, +] + +[[package]] +name = "truststore" +version = "0.10.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/53/a3/1585216310e344e8102c22482f6060c7a6ea0322b63e026372e6dcefcfd6/truststore-0.10.4.tar.gz", hash = "sha256:9d91bd436463ad5e4ee4aba766628dd6cd7010cf3e2461756b3303710eebc301", size = 26169, upload-time = "2025-08-12T18:49:02.73Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/19/97/56608b2249fe206a67cd573bc93cd9896e1efb9e98bce9c163bcdc704b88/truststore-0.10.4-py3-none-any.whl", hash = "sha256:adaeaecf1cbb5f4de3b1959b42d41f6fab57b2b1666adb59e89cb0b53361d981", size = 18660, upload-time = "2025-08-12T18:49:01.46Z" }, +] + +[[package]] +name = "ty" +version = "0.0.72" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d5/df/656e684bafb13c1d146e7d5b5f3e7978ca177232acc84998ff36427e9462/ty-0.0.72.tar.gz", hash = "sha256:ec2b8066b618df18cab4cb8e992f8da45d360332acb23fa34df7fa29cd1b9d3a", size = 6654939, upload-time = "2026-08-14T21:35:42.612Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/0b/c9/584bc9651441b4ba60cc4d557d8a547b5aff901af35bda3a4ee30c819b82/starlette-1.0.0-py3-none-any.whl", hash = "sha256:d3ec55e0bb321692d275455ddfd3df75fff145d009685eb40dc91fc66b03d38b", size = 72651, upload-time = "2026-03-22T18:29:45.111Z" }, + { url = "https://files.pythonhosted.org/packages/e2/3b/f51461239a4e66565d4b362f97a3b55fe7fdba2e944068341f87c62f6743/ty-0.0.72-py3-none-linux_armv6l.whl", hash = "sha256:fda86db153ffd85ee52000cf175d6a3f1c0223772cf7c5b6f726200bf92c7b44", size = 12621989, upload-time = "2026-08-14T21:35:01.676Z" }, + { url = "https://files.pythonhosted.org/packages/ca/fb/79ddf683affc679ca856f3510b5640ec3a88a842ba5f654f5d4bc78f1786/ty-0.0.72-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:ceb944c612529b9023acfdc9cf4c0dcbb722549f9d17d46baecd1141baf01d7f", size = 12233910, upload-time = "2026-08-14T21:35:04.334Z" }, + { url = "https://files.pythonhosted.org/packages/5d/45/10562a0d84802158db8fa4ec46de54aa9fdcecdeeaabbfe3639ae7042b66/ty-0.0.72-py3-none-macosx_11_0_arm64.whl", hash = "sha256:108d76218333d6c092e5f1cebf8e9b06f25738613a0236a28e2dd47c936ee52c", size = 12084108, upload-time = "2026-08-14T21:35:06.686Z" }, + { url = "https://files.pythonhosted.org/packages/a1/dc/1fe1aef8d697e3509face271a5331700c7aa1d1e44a4b622707bdfa41d4b/ty-0.0.72-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7f3943f186f741a2499a31053872169250c9264a9a49684920e48d8fcf4ef4f5", size = 12132640, upload-time = "2026-08-14T21:35:09.305Z" }, + { url = "https://files.pythonhosted.org/packages/14/46/41ceb265e96969487311a2014bd0e53abb4fbc1395efb2ebe411fcb4db62/ty-0.0.72-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:cf283c07dc3cc52ca48a3ad8ab100fb5aec3aebbd03ef6a12d5f910b8e596fc5", size = 12402489, upload-time = "2026-08-14T21:35:11.555Z" }, + { url = "https://files.pythonhosted.org/packages/2b/45/30bf43cb4fd505c5c2dd30fda27dde5f05208686cd21217adec77c954204/ty-0.0.72-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:95f3b6462c38f9f115d10cee21f47fedf715fcf2040daf36eef210359300bc7c", size = 13130835, upload-time = "2026-08-14T21:35:13.746Z" }, + { url = "https://files.pythonhosted.org/packages/31/2f/03bba754d2613f640df168335c41f83f41db150bb515839c60d80e3a7880/ty-0.0.72-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:30caf658feb8ffb250d9e9e47107657a78f5f3425c227df1664d8df2ebe38880", size = 13590392, upload-time = "2026-08-14T21:35:16.839Z" }, + { url = "https://files.pythonhosted.org/packages/04/c7/03c67f00e63005ec41585653dc3096064570b1e6273742baae2798cd242f/ty-0.0.72-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:27bdc012ddfbeec8948e4a6036c0dc39ac7cf2c8ec7c7d48dc7d2fd56d57b399", size = 13309629, upload-time = "2026-08-14T21:35:19.169Z" }, + { url = "https://files.pythonhosted.org/packages/c1/df/102d3b264eb7f2a58dd11952f229bb5150bb5668d176a6154976a6675981/ty-0.0.72-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:802c5970a77d7739e6f499921fbb6984fb7ad8a31d95e1ff42fd46f3642e4f3b", size = 12734028, upload-time = "2026-08-14T21:35:22.099Z" }, + { url = "https://files.pythonhosted.org/packages/61/85/d0737c8c54d0ba67366ddfb9f31d88edf0b02299e65923e6945ae60ebcb5/ty-0.0.72-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:47dce65114fdc615c68ca0edb393b433df0956447e4267df0e264137a789598d", size = 13174832, upload-time = "2026-08-14T21:35:24.71Z" }, + { url = "https://files.pythonhosted.org/packages/1e/31/497f5a96c36d9b586ab6afe0574986835c6fd5b835a89773d2bec4711b49/ty-0.0.72-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:325144fa07e2675d0faa337fcc864213c272a499eb0cfe5bde2fdc62282d27bc", size = 12215005, upload-time = "2026-08-14T21:35:26.892Z" }, + { url = "https://files.pythonhosted.org/packages/df/7d/46e65b17b4966c7cd0140f134380d33d8e84fe6efccd761533ce793dc502/ty-0.0.72-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:a5c9f15d0f58e43707d8848274be1821a0ef408eccb8aa7dda28a4a9eddf7640", size = 12421298, upload-time = "2026-08-14T21:35:29.301Z" }, + { url = "https://files.pythonhosted.org/packages/08/2a/12ada4ec17700b3cb1d4fd3bc3e5b1852df9e6885288429318cade87b3c1/ty-0.0.72-py3-none-musllinux_1_2_i686.whl", hash = "sha256:8ee508d64b381871529cc22c412b41071bf5e908b7aa5d66a38f3f6b2573a806", size = 12669242, upload-time = "2026-08-14T21:35:31.444Z" }, + { url = "https://files.pythonhosted.org/packages/1c/1a/4692536880790fb550ed6d44a6096778dc71bb112f2c6d615cebb01a57e5/ty-0.0.72-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:3699e2ec7921d44da79d6b089f7bf239b2cc53c4e45a5a38430adc34ee9e9a55", size = 12988199, upload-time = "2026-08-14T21:35:33.749Z" }, + { url = "https://files.pythonhosted.org/packages/9a/0d/f5e5a50322e9c45865e7b7a428ba6cd6527387cf0f2472492ac3cf746243/ty-0.0.72-py3-none-win32.whl", hash = "sha256:f25f72a67bd36cd247707c4784e52fad0b6b4f42a1b7dd14804110fa95c486ed", size = 11939708, upload-time = "2026-08-14T21:35:36.006Z" }, + { url = "https://files.pythonhosted.org/packages/3f/4e/8af3534b2e4214e6184a5a59c34101e94a68d578f081f97b995866bab1bf/ty-0.0.72-py3-none-win_amd64.whl", hash = "sha256:cdeee869341717e1736cea2e2d7856738c6957c320f584ed2f68c8f90100d2f5", size = 12643876, upload-time = "2026-08-14T21:35:38.141Z" }, + { url = "https://files.pythonhosted.org/packages/ff/ea/a2606e654c7276bd08586391a2525b0af3f3bf60228a8c57b2d248f273f9/ty-0.0.72-py3-none-win_arm64.whl", hash = "sha256:1bd3ac3ed4424a6d6990a85dc388556aea012bd752de21349a84b685951de0d8", size = 12394857, upload-time = "2026-08-14T21:35:40.277Z" }, ] [[package]] name = "typing-extensions" -version = "4.15.0" +version = "4.16.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } +sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload-time = "2026-07-02T08:40:05.92Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, + { url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" }, ] [[package]] name = "typing-inspection" -version = "0.4.2" +version = "0.4.4" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" } +sdist = { url = "https://files.pythonhosted.org/packages/a3/26/b09b8010994eccc3c09092e6b34058f36a460eea2d4c3e8b910c695975a0/typing_inspection-0.4.4.tar.gz", hash = "sha256:547274fa6b0a561ccf549cc9524b999a578e737d015d8709d021f9d0d13bea47", size = 76928, upload-time = "2026-08-12T12:37:25.997Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" }, + { url = "https://files.pythonhosted.org/packages/67/81/4add07e5172b7ac40d8ed5ff580409a7801a4fe26d529bdd915401dabfbe/typing_inspection-0.4.4-py3-none-any.whl", hash = "sha256:65b8397ba37ccbce054456aaccddfc91e6e3083c92824df348d96ca832f3f147", size = 14750, upload-time = "2026-08-12T12:37:24.648Z" }, ] [[package]] name = "uvicorn" -version = "0.44.0" +version = "0.52.3" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "click" }, { name = "h11" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/5e/da/6eee1ff8b6cbeed47eeb5229749168e81eb4b7b999a1a15a7176e51410c9/uvicorn-0.44.0.tar.gz", hash = "sha256:6c942071b68f07e178264b9152f1f16dfac5da85880c4ce06366a96d70d4f31e", size = 86947, upload-time = "2026-04-06T09:23:22.826Z" } +sdist = { url = "https://files.pythonhosted.org/packages/2e/28/64ca011edf31c715b4fad359c587ea52391aaffa125065695590241ff617/uvicorn-0.52.3.tar.gz", hash = "sha256:18857b9e6579300be55c91c0a1cfd37d9a2cf0cabea33b88275f199eb73b8b58", size = 100621, upload-time = "2026-08-13T16:50:02.899Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/b7/23/a5bbd9600dd607411fa644c06ff4951bec3a4d82c4b852374024359c19c0/uvicorn-0.44.0-py3-none-any.whl", hash = "sha256:ce937c99a2cc70279556967274414c087888e8cec9f9c94644dfca11bd3ced89", size = 69425, upload-time = "2026-04-06T09:23:21.524Z" }, + { url = "https://files.pythonhosted.org/packages/dc/2b/ebd108734a8204c6b4b93c681c9a38c5273b3ccd5d129fee4ffc1d97772c/uvicorn-0.52.3-py3-none-any.whl", hash = "sha256:116af2710dbf47c80f463cd20ee4884b6662f4c9f227d797ddc7279d2fcc2c7c", size = 79859, upload-time = "2026-08-13T16:50:01.323Z" }, ] [[package]] @@ -536,6 +946,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/15/c0/0be24758891ef825f2065cd5db8741aaddabe3e248ee6acc5e8a80f04005/uvloop-0.22.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0530a5fbad9c9e4ee3f2b33b148c6a64d47bbad8000ea63704fa8260f4cf728e", size = 4366890, upload-time = "2025-10-16T22:16:40.547Z" }, { url = "https://files.pythonhosted.org/packages/d2/53/8369e5219a5855869bcee5f4d317f6da0e2c669aecf0ef7d371e3d084449/uvloop-0.22.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bc5ef13bbc10b5335792360623cc378d52d7e62c2de64660616478c32cd0598e", size = 4119472, upload-time = "2025-10-16T22:16:41.694Z" }, { url = "https://files.pythonhosted.org/packages/f8/ba/d69adbe699b768f6b29a5eec7b47dd610bd17a69de51b251126a801369ea/uvloop-0.22.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1f38ec5e3f18c8a10ded09742f7fb8de0108796eb673f30ce7762ce1b8550cad", size = 4239051, upload-time = "2025-10-16T22:16:43.224Z" }, + { url = "https://files.pythonhosted.org/packages/90/cd/b62bdeaa429758aee8de8b00ac0dd26593a9de93d302bff3d21439e9791d/uvloop-0.22.1-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:3879b88423ec7e97cd4eba2a443aa26ed4e59b45e6b76aabf13fe2f27023a142", size = 1362067, upload-time = "2025-10-16T22:16:44.503Z" }, + { url = "https://files.pythonhosted.org/packages/0d/f8/a132124dfda0777e489ca86732e85e69afcd1ff7686647000050ba670689/uvloop-0.22.1-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:4baa86acedf1d62115c1dc6ad1e17134476688f08c6efd8a2ab076e815665c74", size = 752423, upload-time = "2025-10-16T22:16:45.968Z" }, + { url = "https://files.pythonhosted.org/packages/a3/94/94af78c156f88da4b3a733773ad5ba0b164393e357cc4bd0ab2e2677a7d6/uvloop-0.22.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:297c27d8003520596236bdb2335e6b3f649480bd09e00d1e3a99144b691d2a35", size = 4272437, upload-time = "2025-10-16T22:16:47.451Z" }, + { url = "https://files.pythonhosted.org/packages/b5/35/60249e9fd07b32c665192cec7af29e06c7cd96fa1d08b84f012a56a0b38e/uvloop-0.22.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c1955d5a1dd43198244d47664a5858082a3239766a839b2102a269aaff7a4e25", size = 4292101, upload-time = "2025-10-16T22:16:49.318Z" }, + { url = "https://files.pythonhosted.org/packages/02/62/67d382dfcb25d0a98ce73c11ed1a6fba5037a1a1d533dcbb7cab033a2636/uvloop-0.22.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:b31dc2fccbd42adc73bc4e7cdbae4fc5086cf378979e53ca5d0301838c5682c6", size = 4114158, upload-time = "2025-10-16T22:16:50.517Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7a/f1171b4a882a5d13c8b7576f348acfe6074d72eaf52cccef752f748d4a9f/uvloop-0.22.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:93f617675b2d03af4e72a5333ef89450dfaa5321303ede6e67ba9c9d26878079", size = 4177360, upload-time = "2025-10-16T22:16:52.646Z" }, + { url = "https://files.pythonhosted.org/packages/79/7b/b01414f31546caf0919da80ad57cbfe24c56b151d12af68cee1b04922ca8/uvloop-0.22.1-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:37554f70528f60cad66945b885eb01f1bb514f132d92b6eeed1c90fd54ed6289", size = 1454790, upload-time = "2025-10-16T22:16:54.355Z" }, + { url = "https://files.pythonhosted.org/packages/d4/31/0bb232318dd838cad3fa8fb0c68c8b40e1145b32025581975e18b11fab40/uvloop-0.22.1-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:b76324e2dc033a0b2f435f33eb88ff9913c156ef78e153fb210e03c13da746b3", size = 796783, upload-time = "2025-10-16T22:16:55.906Z" }, + { url = "https://files.pythonhosted.org/packages/42/38/c9b09f3271a7a723a5de69f8e237ab8e7803183131bc57c890db0b6bb872/uvloop-0.22.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:badb4d8e58ee08dad957002027830d5c3b06aea446a6a3744483c2b3b745345c", size = 4647548, upload-time = "2025-10-16T22:16:57.008Z" }, + { url = "https://files.pythonhosted.org/packages/c1/37/945b4ca0ac27e3dc4952642d4c900edd030b3da6c9634875af6e13ae80e5/uvloop-0.22.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b91328c72635f6f9e0282e4a57da7470c7350ab1c9f48546c0f2866205349d21", size = 4467065, upload-time = "2025-10-16T22:16:58.206Z" }, + { url = "https://files.pythonhosted.org/packages/97/cc/48d232f33d60e2e2e0b42f4e73455b146b76ebe216487e862700457fbf3c/uvloop-0.22.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:daf620c2995d193449393d6c62131b3fbd40a63bf7b307a1527856ace637fe88", size = 4328384, upload-time = "2025-10-16T22:16:59.36Z" }, + { url = "https://files.pythonhosted.org/packages/e4/16/c1fd27e9549f3c4baf1dc9c20c456cd2f822dbf8de9f463824b0c0357e06/uvloop-0.22.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6cde23eeda1a25c75b2e07d39970f3374105d5eafbaab2a4482be82f272d5a5e", size = 4296730, upload-time = "2025-10-16T22:17:00.744Z" }, ] [[package]]