diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000000000000000000000000000000000000..c66c3f8b6280b59f75bdc258366070ef6896fdf4 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,14 @@ +.git +.gitignore +.idea +.venv +__pycache__ +*.pyc +*.pyo +*.pyd + +web/.next +web/node_modules + +assets +README.md diff --git a/.env.example b/.env.example new file mode 100644 index 0000000000000000000000000000000000000000..a8eb2763e242c01b10d59680c7f03b53f8cfebf9 --- /dev/null +++ b/.env.example @@ -0,0 +1,48 @@ +# ============================================ +# ChatGPT2API 环境变量配置示例 +# ============================================ + +# 认证密钥(必填) +CHATGPT2API_AUTH_KEY=your_secret_key_here + +# 基础 URL(可选,用于生成图片 URL) +# CHATGPT2API_BASE_URL=https://your-domain.com + +# ============================================ +# 存储后端配置 +# ============================================ + +# 存储后端类型(可选值: json, sqlite, postgres, git) +# 默认: json +STORAGE_BACKEND=json + +# ============================================ +# 数据库配置(当 STORAGE_BACKEND=sqlite/postgres 时使用) +# ============================================ + +# PostgreSQL 示例 +# DATABASE_URL=postgresql://user:password@localhost:5432/chatgpt2api + +# Supabase 示例 +# DATABASE_URL=postgresql://postgres.xxx:password@aws-1-us-west-1.pooler.supabase.com:5432/postgres + +# SQLite 示例(不指定时自动使用 data/accounts.db) +# DATABASE_URL=sqlite:///app/data/accounts.db + +# ============================================ +# Git 仓库配置(当 STORAGE_BACKEND=git 时使用) +# ============================================ + +# Git 仓库地址(必填) +# GIT_REPO_URL=https://github.com/your-username/your-private-repo.git + +# Git 访问令牌(必填) +# GitHub: ghp_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx +# GitLab: glpat-xxxxxxxxxxxxxxxxxxxx +# GIT_TOKEN=your_git_token_here + +# Git 分支(可选,默认: main) +# GIT_BRANCH=main + +# Git 仓库中的文件路径(可选,默认: accounts.json) +# GIT_FILE_PATH=accounts.json diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml new file mode 100644 index 0000000000000000000000000000000000000000..f7bdd844802ca267ba5343d1865ada80e28a3d63 --- /dev/null +++ b/.github/workflows/docker-publish.yml @@ -0,0 +1,61 @@ +name: Publish Docker Image + +on: + push: + tags: + - "v*" + workflow_dispatch: + +env: + IMAGE_NAME: chatgpt2api + +jobs: + docker: + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up QEMU + uses: docker/setup-qemu-action@v3 + with: + platforms: arm64 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Log in to GHCR + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Extract Docker metadata + id: meta + uses: docker/metadata-action@v5 + with: + images: ghcr.io/${{ github.repository_owner }}/${{ env.IMAGE_NAME }} + tags: | + type=raw,value=latest,enable=${{ startsWith(github.ref, 'refs/tags/v') }} + type=ref,event=tag + type=sha + type=semver,pattern={{version}} + type=semver,pattern={{major}}.{{minor}} + + - name: Build and push image + uses: docker/build-push-action@v6 + with: + context: . + file: ./Dockerfile + target: app + platforms: linux/amd64,linux/arm64 + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + cache-from: type=gha + cache-to: type=gha,mode=max diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000000000000000000000000000000000000..936bbbc9817d511ec635c6bd46c6324ac4fbd50f --- /dev/null +++ b/.gitignore @@ -0,0 +1,30 @@ +# Python-generated files +__pycache__/ +*.py[oc] +build/ +dist/ +wheels/ +*.egg-info +web_dist +# Virtual environments +.venv +.idea +data +config.json +edit + +docker-compose-local.yml + +.claude/settings.local.json + +# Environment variables +.env +.env.local + +# Database files +*.db +*.sqlite +*.sqlite3 + +# Git cache +git_cache/ \ No newline at end of file diff --git a/.python-version b/.python-version new file mode 100644 index 0000000000000000000000000000000000000000..24ee5b1be9961e38a503c8e764b7385dbb6ba124 --- /dev/null +++ b/.python-version @@ -0,0 +1 @@ +3.13 diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000000000000000000000000000000000000..c7d5e6279c5ac7ee881f891c03f0aef7f71ace1e --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,20 @@ +# Changelog + +## Unreleased + +## 1.2.3 - 2026-05-29 + ++ [新增] 新增账号级代理。 ++ [修复] 修复503异常信息、前端邮箱换行问题。 + +## 1.2.2 - 2026-05-29 + ++ [新增] 新增Codex链路生图、支持2k,4k。 ++ [新增] 支持RT刷新账号信息。 + +## 1.2.0 - 2026-05-28 + ++ [新增] 当前版本基线,包含 Web 面板、画图、号池管理、注册机、图片管理、日志管理和设置能力。 ++ [新增] 前端版本号支持点击查看版本更新弹窗,展示当前版本、最新版本和更新日志。 ++ [优化] 优化注册机效率,成功率大幅提高。 ++ [优化] 优化生图页面配置选项。 diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000000000000000000000000000000000000..32a8fef218f2349e017f46d8b9fad1982d30783e --- /dev/null +++ b/Dockerfile @@ -0,0 +1,56 @@ +ARG BUILDPLATFORM +ARG TARGETPLATFORM +ARG TARGETARCH + +FROM --platform=$BUILDPLATFORM node:22-alpine AS web-build + +WORKDIR /app/web + +COPY web/package.json web/bun.lock ./ +RUN npm install + +COPY VERSION /app/VERSION +COPY CHANGELOG.md /app/CHANGELOG.md +COPY web ./ +RUN NEXT_PUBLIC_APP_VERSION="$(cat /app/VERSION)" npm run build + + +FROM --platform=$TARGETPLATFORM python:3.13-slim AS app + +ARG TARGETPLATFORM +ARG TARGETARCH + +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 \ + UV_LINK_MODE=copy + +WORKDIR /app + +# 安装系统依赖 +# - git: Git 存储后端需要 +# - libpq-dev: PostgreSQL 客户端库 +# - gcc: 编译 psycopg2-binary 需要 +RUN apt-get update && apt-get install -y --no-install-recommends \ + git \ + libpq-dev \ + gcc \ + openssl \ + && rm -rf /var/lib/apt/lists/* + +RUN pip install --no-cache-dir uv + +COPY pyproject.toml uv.lock ./ +RUN uv sync --frozen --no-dev --no-install-project + +COPY main.py ./ +COPY config.json ./ +COPY VERSION ./ +COPY api ./api +COPY services ./services +COPY utils ./utils +COPY scripts ./scripts +COPY --from=web-build /app/web/out ./web_dist + +EXPOSE 80 + +CMD ["uv", "run", "uvicorn", "main:app", "--host", "0.0.0.0", "--port", "80", "--access-log"] diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000000000000000000000000000000000000..58e8f6506556e2827432bd52cc9fbf3d13b5bd70 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 kunkun + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000000000000000000000000000000000000..436e0f4597baf3586a92f20cd5636052859bc2b1 --- /dev/null +++ b/README.md @@ -0,0 +1,336 @@ +

ChatGPT2API

+ + +

ChatGPT2API 主要是对 ChatGPT 官网相关能力进行逆向整理与封装,提供面向 ChatGPT 图片生成、图片编辑、多图组图编辑场景的 OpenAI 兼容图片 API / 代理,并集成在线画图、号池管理、多种账号导入方式与 Docker 自托管部署能力。

+ +> [!WARNING] +> 免责声明: +> +> 本项目涉及对 ChatGPT 官网文本生成、图片生成与图片编辑等相关接口的逆向研究,仅供个人学习、技术研究与非商业性技术交流使用。 +> +> - 严禁将本项目用于任何商业用途、盈利性使用、批量操作、自动化滥用或规模化调用。 +> - 严禁将本项目用于破坏市场秩序、恶意竞争、套利倒卖、二次售卖相关服务,以及任何违反 OpenAI 服务条款或当地法律法规的行为。 +> - 严禁将本项目用于生成、传播或协助生成违法、暴力、色情、未成年人相关内容,或用于诈骗、欺诈、骚扰等非法或不当用途。 +> - 使用者应自行承担全部风险,包括但不限于账号被限制、临时封禁或永久封禁以及因违规使用等所导致的法律责任。 +> - 使用本项目即视为你已充分理解并同意本免责声明全部内容;如因滥用、违规或违法使用造成任何后果,均由使用者自行承担。 +> - 本项目基于对 ChatGPT 官网相关能力的逆向研究实现,存在账号受限、临时封禁或永久封禁的风险。请勿使用你自己的重要账号、常用账号或高价值账号进行测试。 + +## 快速开始 + +已发布镜像支持 `linux/amd64` 与 `linux/arm64`,在 x86 服务器和 Apple Silicon / ARM Linux 设备上都会自动拉取匹配架构的版本。 + +### Docker 运行 + +```bash +git clone git@github.com:basketikun/chatgpt2api.git +cd chatgpt2api +docker compose up -d +``` + +启动前请先在 `config.json` 中设置 `auth-key`,也可以在 `docker-compose.yml` 中通过 `CHATGPT2API_AUTH_KEY` 覆盖。 + +- Web 面板:`http://localhost:3000` +- API 地址:`http://localhost:3000/v1` +- 数据目录:`./data` + +### 本地开发 + +启动后端: + +```bash +git clone git@github.com:basketikun/chatgpt2api.git +cd chatgpt2api +uv sync +uv run main.py +``` + +启动前端: + +```bash +cd chatgpt2api/web +bun install +bun run dev +``` + +后续更新新版本: + +```bash +docker pull ghcr.io/basketikun/chatgpt2api:latest +docker-compose down +docker-compose up -d + +``` + +### 存储后端配置 + +支持通过环境变量 `STORAGE_BACKEND` 切换存储方式: + +- `json` - 本地 JSON 文件(默认) +- `sqlite` - 本地 SQLite 数据库 +- `postgres` - 外部 PostgreSQL(需配置 `DATABASE_URL`) +- `git` - Git 私有仓库(需配置 `GIT_REPO_URL` 和 `GIT_TOKEN`) + +示例:使用 PostgreSQL + +```yaml +environment: + - STORAGE_BACKEND=postgres + - DATABASE_URL=postgresql://user:password@host:5432/dbname +``` + +## 功能 + +### API 兼容能力 + +- 兼容 `POST /v1/images/generations` 图片生成接口 +- 兼容 `POST /v1/images/edits` 图片编辑接口 +- 兼容面向图片场景的 `POST /v1/chat/completions` +- 兼容面向图片场景的 `POST /v1/responses` +- `GET /v1/models` 返回 `gpt-image-2`、`codex-gpt-image-2`、`auto`、`gpt-5`、`gpt-5-1`、`gpt-5-2`、`gpt-5-3`、`gpt-5-3-mini`、 + `gpt-5-mini` +- 支持通过 `n` 返回多张生成结果 +- 支持 Codex 中的画图接口逆向,仅 `Plus` / `Team` / `Pro` 订阅可用,模型别名为 `codex-gpt-image-2`,如有需要可自行在其他场景映射回 + `gpt-image-2`,用于和官网画图区分;也就意味着同一账号会同时有官网和 Codex 两份生图额度 + +### 在线画图功能 + +- 内置在线画图工作台,支持生成、图片编辑与多图组图编辑 +- 支持 `gpt-image-2`、`codex-gpt-image-2`、`auto`、`gpt-5`、`gpt-5-1`、`gpt-5-2`、`gpt-5-3`、`gpt-5-3-mini`、`gpt-5-mini` 模型选择 +- 编辑模式支持参考图上传 +- 前端支持多图生成交互 +- 本地保存图片会话历史,支持回看、删除和清空 +- 支持服务端缓存图片URL + +### 号池管理功能 + +- 自动刷新账号邮箱、类型、额度和恢复时间 +- 轮询可用账号执行图片生成与图片编辑 +- 遇到 Token 失效类错误时自动剔除无效 Token +- 定时检查限流账号并自动刷新 +- 支持网页端配置全局 HTTP / HTTPS / SOCKS5 / SOCKS5H 代理 +- 支持搜索、筛选、批量刷新、导出、手动编辑和清理账号 +- 支持四种导入方式:本地 CPA JSON 文件导入、远程 CPA 服务器导入、`sub2api` 服务器导入、`access_token` 导入 +- 支持在设置页配置 `sub2api` 服务器,筛选并批量导入其中的 OpenAI OAuth 账号 + +### 实验性 / 规划中 + +- `/v1/complete` 文本补全与流式输出已实现,但仍在测试,目前会出现对话重复的问题,请谨慎测试使用 +- `/v1/chat/completions` 文本链路支持短 TTL 缓存、重复请求合并与相邻重复消息清理,可通过 `chat_completion_cache` 配置调整 +- 详细状态说明见:[功能清单](./docs/feature-status.en.md) + +## 效果展示 + + + + + + + + + + + + + +
imageimage edit
chery studioaccount pool
new api
+ +## API + +所有 AI 接口都需要请求头: + +```http +Authorization: Bearer +``` + +
+GET /v1/models +
+ +返回当前暴露的图片模型列表。 + +```bash +curl http://localhost:8000/v1/models \ + -H "Authorization: Bearer " +``` + +
+说明 +
+ +| 字段 | 说明 | +|:-----|:-----------------------------------------------------------------------------------------------------------| +| 返回模型 | `gpt-image-2`、`codex-gpt-image-2`、`auto`、`gpt-5`、`gpt-5-1`、`gpt-5-2`、`gpt-5-3`、`gpt-5-3-mini`、`gpt-5-mini` | +| 接入场景 | 可接入 Cherry Studio、New API 等上游或客户端 | + +
+
+
+ +
+POST /v1/images/generations +
+ +OpenAI 兼容图片生成接口,用于文生图。 + +```bash +curl http://localhost:8000/v1/images/generations \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer " \ + -d '{ + "model": "gpt-image-2", + "prompt": "一只漂浮在太空里的猫", + "n": 1, + "response_format": "b64_json" + }' +``` + +
+字段说明 +
+ +| 字段 | 说明 | +|:------------------|:---------------------------------------------------| +| `model` | 图片模型,当前可用值以 `/v1/models` 返回结果为准,推荐使用 `gpt-image-2` | +| `prompt` | 图片生成提示词 | +| `n` | 生成数量,当前后端限制为 `1-4` | +| `response_format` | 当前请求模型中包含该字段,默认值为 `b64_json` | + +
+
+
+ +
+POST /v1/images/edits +
+ +OpenAI 兼容图片编辑接口,可上传图片文件,也可按官方 JSON 格式传入图片链接并生成编辑结果。 + +```bash +curl http://localhost:8000/v1/images/edits \ + -H "Authorization: Bearer " \ + -F "model=gpt-image-2" \ + -F "prompt=把这张图改成赛博朋克夜景风格" \ + -F "n=1" \ + -F "image=@./input.png" +``` + +也可以直接传图片 URL: + +```bash +curl http://localhost:8000/v1/images/edits \ + -H "Authorization: Bearer " \ + -H "Content-Type: application/json" \ + -d '{ + "model": "gpt-image-2", + "prompt": "把这张图改成赛博朋克夜景风格", + "images": [ + {"image_url": "https://example.com/input.png"} + ] + }' +``` + +
+字段说明 +
+ +| 字段 | 说明 | +|:------------|:----------------------------------------------| +| `model` | 图片模型, `gpt-image-2` | +| `prompt` | 图片编辑提示词 | +| `n` | 生成数量,当前后端限制为 `1-4` | +| `image` | 需要编辑的图片文件,使用 multipart/form-data 上传 | +| `images` | JSON 图片引用数组,支持 `{"image_url": "https://..."}` | +| `image_url` | 表单模式下也可直接传图片链接,支持重复字段传多张图 | + +
+
+
+ +
+POST /v1/chat/completions +
+ +面向图片场景的 Chat Completions 兼容接口,不是完整通用聊天代理。 + +```bash +curl http://localhost:8000/v1/chat/completions \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer " \ + -d '{ + "model": "gpt-image-2", + "messages": [ + { + "role": "user", + "content": "生成一张雨夜东京街头的赛博朋克猫" + } + ], + "n": 1 + }' +``` + +
+字段说明 +
+ +| 字段 | 说明 | +|:-----------|:------------------| +| `model` | 图片模型,默认按图片生成场景处理 | +| `messages` | 消息数组,需要是图片相关请求内容 | +| `n` | 生成数量,按当前实现解析为图片数量 | +| `stream` | 已实现,但仍在测试 | + +
+
+
+ +
+POST /v1/responses +
+ +面向图片生成工具调用的 Responses API 兼容接口,不是完整通用 Responses API 代理。 + +```bash +curl http://localhost:8000/v1/responses \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer " \ + -d '{ + "model": "gpt-5", + "input": "生成一张未来感城市天际线图片", + "tools": [ + { + "type": "image_generation" + } + ] + }' +``` + +
+字段说明 +
+ +| 字段 | 说明 | +|:---------|:------------------------------| +| `model` | 响应中会回显该模型字段,但图片生成当前仍走图片生成兼容逻辑 | +| `input` | 输入内容,需要能解析出图片生成提示词 | +| `tools` | 必须包含 `image_generation` 工具请求 | +| `stream` | 已实现,但仍在测试 | + +
+
+
+ +## 社区支持 + +学 AI , 上 L 站:[LinuxDO](https://linux.do) + +## Contributors + +感谢所有为本项目做出贡献的开发者: + + + Contributors + + +## Star History + +[![Star History Chart](https://api.star-history.com/chart?repos=basketikun/chatgpt2api&type=date&legend=top-left)](https://www.star-history.com/?repos=basketikun%2Fchatgpt2api&type=date&legend=top-left) diff --git a/VERSION b/VERSION new file mode 100644 index 0000000000000000000000000000000000000000..e2cac26c1a8221c28f8b79c6eba0005c6f1cecb4 --- /dev/null +++ b/VERSION @@ -0,0 +1 @@ +1.2.3 \ No newline at end of file diff --git a/api/__init__.py b/api/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..61876d2267671fabdae957b537f7cdedec15c905 --- /dev/null +++ b/api/__init__.py @@ -0,0 +1,2 @@ +from api.app import create_app + diff --git a/api/accounts.py b/api/accounts.py new file mode 100644 index 0000000000000000000000000000000000000000..dc399b9740432d7e4f5428700f25e1e5d3bafcd1 --- /dev/null +++ b/api/accounts.py @@ -0,0 +1,488 @@ +from __future__ import annotations + +import io +import json +import re +import zipfile +from datetime import datetime +from typing import Any, Literal + +from fastapi import APIRouter, Header, HTTPException +from fastapi.concurrency import run_in_threadpool +from fastapi.responses import Response +from pydantic import BaseModel, Field + +from services.auth_service import auth_service + +from api.support import ( + require_admin, + sanitize_cpa_pool, + sanitize_cpa_pools, + sanitize_sub2api_server, + sanitize_sub2api_servers, +) +from services.account_service import account_service +from services.cpa_service import cpa_config, cpa_import_service, list_remote_files +from services.oauth_login_service import OAuthLoginError, oauth_login_service +from services.sub2api_service import ( + list_remote_accounts as sub2api_list_remote_accounts, + list_remote_groups as sub2api_list_remote_groups, + sub2api_config, + sub2api_import_service, +) + + + +class UserKeyCreateRequest(BaseModel): + name: str = "" + + +class UserKeyUpdateRequest(BaseModel): + name: str | None = None + enabled: bool | None = None + key: str | None = None + + +class AccountCreateRequest(BaseModel): + tokens: list[str] = Field(default_factory=list) + accounts: list[dict[str, Any]] = Field(default_factory=list) + + +class AccountDeleteRequest(BaseModel): + tokens: list[str] = Field(default_factory=list) + + +class AccountRefreshRequest(BaseModel): + access_tokens: list[str] = Field(default_factory=list) + + +class AccountExportRequest(BaseModel): + access_tokens: list[str] = Field(default_factory=list) + format: Literal["json", "zip"] = "json" + + +class AccountUpdateRequest(BaseModel): + access_token: str = "" + type: str | None = None + status: str | None = None + quota: int | None = None + proxy: str | None = None + + +class CPAPoolCreateRequest(BaseModel): + name: str = "" + base_url: str = "" + secret_key: str = "" + + +class CPAPoolUpdateRequest(BaseModel): + name: str | None = None + base_url: str | None = None + secret_key: str | None = None + + +class CPAImportRequest(BaseModel): + names: list[str] = Field(default_factory=list) + + +class Sub2APIServerCreateRequest(BaseModel): + name: str = "" + base_url: str = "" + email: str = "" + password: str = "" + api_key: str = "" + group_id: str = "" + + +class Sub2APIServerUpdateRequest(BaseModel): + name: str | None = None + base_url: str | None = None + email: str | None = None + password: str | None = None + api_key: str | None = None + group_id: str | None = None + + +class Sub2APIImportRequest(BaseModel): + account_ids: list[str] = Field(default_factory=list) + + +class OAuthLoginStartRequest(BaseModel): + """起始 OAuth 桥。email_hint 可选,仅用于让 OpenAI 登录页预填邮箱。""" + email_hint: str = "" + + +class OAuthLoginFinishRequest(BaseModel): + """提交 callback。callback 既可以是完整 URL 也可以只填 code。""" + session_id: str = "" + callback: str = "" + + +def _account_payload_token(item: dict[str, Any]) -> str: + return str(item.get("access_token") or item.get("accessToken") or "").strip() + + +def _unique_tokens(tokens: list[str]) -> list[str]: + return list(dict.fromkeys(str(token or "").strip() for token in tokens if str(token or "").strip())) + + +def _download_timestamp() -> str: + return datetime.now().strftime("%Y%m%d-%H%M%S") + + +def _safe_export_name(value: str, fallback: str) -> str: + clean = re.sub(r"[^A-Za-z0-9._-]+", "-", value.strip()).strip("-._") + return (clean or fallback)[:80] + + +def _account_zip_bytes(items: list[dict[str, str]]) -> bytes: + buf = io.BytesIO() + used_names: set[str] = set() + with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as archive: + for index, item in enumerate(items, start=1): + raw_name = item.get("email") or item.get("account_id") or f"account-{index:03d}" + base_name = _safe_export_name(raw_name, f"account-{index:03d}") + name = base_name + suffix = 2 + while name in used_names: + name = f"{base_name}-{suffix}" + suffix += 1 + used_names.add(name) + archive.writestr( + f"{name}.json", + json.dumps(item, ensure_ascii=False, indent=2) + "\n", + ) + return buf.getvalue() + + +def create_router() -> APIRouter: + router = APIRouter() + + @router.get("/api/auth/users") + async def list_user_keys(authorization: str | None = Header(default=None)): + require_admin(authorization) + return {"items": auth_service.list_keys(role="user")} + + @router.post("/api/auth/users") + async def create_user_key(body: UserKeyCreateRequest, authorization: str | None = Header(default=None)): + require_admin(authorization) + try: + item, raw_key = auth_service.create_key(role="user", name=body.name) + except ValueError as exc: + raise HTTPException(status_code=400, detail={"error": str(exc)}) from exc + return {"item": item, "key": raw_key, "items": auth_service.list_keys(role="user")} + + @router.post("/api/auth/users/{key_id}") + async def update_user_key( + key_id: str, + body: UserKeyUpdateRequest, + authorization: str | None = Header(default=None), + ): + require_admin(authorization) + updates = { + key: value + for key, value in { + "name": body.name, + "enabled": body.enabled, + "key": body.key, + }.items() + if value is not None + } + if not updates: + raise HTTPException(status_code=400, detail={"error": "还没有检测到改动,请修改后再保存"}) + try: + item = auth_service.update_key(key_id, updates, role="user") + except ValueError as exc: + raise HTTPException(status_code=400, detail={"error": str(exc)}) from exc + if item is None: + raise HTTPException(status_code=404, detail={"error": "这条用户密钥不存在,可能已经被删除"}) + return {"item": item, "items": auth_service.list_keys(role="user")} + + @router.delete("/api/auth/users/{key_id}") + async def delete_user_key(key_id: str, authorization: str | None = Header(default=None)): + require_admin(authorization) + if not auth_service.delete_key(key_id, role="user"): + raise HTTPException(status_code=404, detail={"error": "这条用户密钥不存在,可能已经被删除"}) + return {"items": auth_service.list_keys(role="user")} + + @router.get("/api/accounts") + async def get_accounts(authorization: str | None = Header(default=None)): + require_admin(authorization) + return {"items": account_service.list_accounts()} + + @router.post("/api/accounts") + async def create_accounts(body: AccountCreateRequest, authorization: str | None = Header(default=None)): + require_admin(authorization) + account_payloads = [item for item in body.accounts if isinstance(item, dict)] + payload_tokens = [_account_payload_token(item) for item in account_payloads] + tokens = _unique_tokens([*body.tokens, *payload_tokens]) + if not tokens: + raise HTTPException(status_code=400, detail={"error": "tokens is required"}) + if account_payloads: + result = account_service.add_account_items(account_payloads) + payload_token_set = set(_unique_tokens(payload_tokens)) + extra_tokens = [token for token in tokens if token not in payload_token_set] + if extra_tokens: + extra_result = account_service.add_accounts(extra_tokens) + result["added"] = int(result.get("added") or 0) + int(extra_result.get("added") or 0) + result["skipped"] = int(result.get("skipped") or 0) + int(extra_result.get("skipped") or 0) + else: + result = account_service.add_accounts(tokens) + refresh_result = account_service.refresh_accounts(tokens) + return { + **result, + "refreshed": refresh_result.get("refreshed", 0), + "errors": refresh_result.get("errors", []), + "items": refresh_result.get("items", result.get("items", [])), + } + + @router.delete("/api/accounts") + async def delete_accounts(body: AccountDeleteRequest, authorization: str | None = Header(default=None)): + require_admin(authorization) + tokens = [str(token or "").strip() for token in body.tokens if str(token or "").strip()] + if not tokens: + raise HTTPException(status_code=400, detail={"error": "tokens is required"}) + return account_service.delete_accounts(tokens) + + @router.post("/api/accounts/refresh") + async def refresh_accounts(body: AccountRefreshRequest, authorization: str | None = Header(default=None)): + require_admin(authorization) + access_tokens = [str(token or "").strip() for token in body.access_tokens if str(token or "").strip()] + if not access_tokens: + access_tokens = account_service.list_tokens() + if not access_tokens: + raise HTTPException(status_code=400, detail={"error": "access_tokens is required"}) + return account_service.refresh_accounts(access_tokens) + + @router.post("/api/accounts/export") + async def export_accounts(body: AccountExportRequest, authorization: str | None = Header(default=None)): + require_admin(authorization) + access_tokens = _unique_tokens(body.access_tokens) + items = account_service.build_export_items(access_tokens) + if not items: + raise HTTPException( + status_code=400, + detail={"error": "没有可导出的完整账号,需要同时有 access_token、refresh_token 和 id_token"}, + ) + + timestamp = _download_timestamp() + if body.format == "zip": + content = _account_zip_bytes(items) + return Response( + content, + media_type="application/zip", + headers={"Content-Disposition": f'attachment; filename="codex-accounts-{timestamp}.zip"'}, + ) + + payload: dict[str, str] | list[dict[str, str]] = items[0] if len(items) == 1 else items + return Response( + json.dumps(payload, ensure_ascii=False, indent=2) + "\n", + media_type="application/json", + headers={"Content-Disposition": f'attachment; filename="codex-accounts-{timestamp}.json"'}, + ) + + @router.post("/api/accounts/update") + async def update_account(body: AccountUpdateRequest, authorization: str | None = Header(default=None)): + require_admin(authorization) + access_token = str(body.access_token or "").strip() + if not access_token: + raise HTTPException(status_code=400, detail={"error": "access_token is required"}) + updates = {key: value for key, value in {"type": body.type, "status": body.status, "quota": body.quota, "proxy": body.proxy}.items() if value is not None} + if not updates: + raise HTTPException(status_code=400, detail={"error": "还没有检测到改动,请修改后再保存"}) + account = account_service.update_account(access_token, updates) + if account is None: + raise HTTPException(status_code=404, detail={"error": "account not found"}) + return {"item": account, "items": account_service.list_accounts()} + + @router.post("/api/accounts/oauth/start") + async def start_oauth_login( + body: OAuthLoginStartRequest, + authorization: str | None = Header(default=None), + ): + """登记一次 PKCE 会话,返回可让用户浏览器打开的 authorize URL。""" + require_admin(authorization) + try: + return await run_in_threadpool(oauth_login_service.start, body.email_hint) + except OAuthLoginError as exc: + raise HTTPException(status_code=400, detail={"error": str(exc)}) from exc + + @router.post("/api/accounts/oauth/finish") + async def finish_oauth_login( + body: OAuthLoginFinishRequest, + authorization: str | None = Header(default=None), + ): + """收用户从浏览器抓回的 callback URL / code,换出 token 三件套并落盘。""" + require_admin(authorization) + # 入参日志:截断敏感字段,仅保留前几位,方便排错而不泄密 + cb_preview = (body.callback or "")[:80] + sid_preview = (body.session_id or "")[:8] + print( + f"[oauth-login] finish called: session_id={sid_preview}..., callback_preview={cb_preview!r}", + flush=True, + ) + try: + tokens = await run_in_threadpool(oauth_login_service.finish, body.session_id, body.callback) + except OAuthLoginError as exc: + print(f"[oauth-login] finish rejected: {exc}", flush=True) + raise HTTPException(status_code=400, detail={"error": str(exc)}) from exc + + payload = { + "access_token": tokens["access_token"], + "refresh_token": tokens["refresh_token"], + "id_token": tokens["id_token"], + "source_type": "oauth_login", + } + add_result = await run_in_threadpool(account_service.add_account_items, [payload]) + refresh_result = await run_in_threadpool( + account_service.refresh_accounts, [tokens["access_token"]] + ) + return { + **add_result, + "refreshed": refresh_result.get("refreshed", 0), + "errors": refresh_result.get("errors", []), + "items": refresh_result.get("items", add_result.get("items", [])), + } + + @router.get("/api/cpa/pools") + async def list_cpa_pools(authorization: str | None = Header(default=None)): + require_admin(authorization) + return {"pools": sanitize_cpa_pools(cpa_config.list_pools())} + + @router.post("/api/cpa/pools") + async def create_cpa_pool(body: CPAPoolCreateRequest, authorization: str | None = Header(default=None)): + require_admin(authorization) + if not body.base_url.strip(): + raise HTTPException(status_code=400, detail={"error": "base_url is required"}) + if not body.secret_key.strip(): + raise HTTPException(status_code=400, detail={"error": "secret_key is required"}) + pool = cpa_config.add_pool(name=body.name, base_url=body.base_url, secret_key=body.secret_key) + return {"pool": sanitize_cpa_pool(pool), "pools": sanitize_cpa_pools(cpa_config.list_pools())} + + @router.post("/api/cpa/pools/{pool_id}") + async def update_cpa_pool(pool_id: str, body: CPAPoolUpdateRequest, authorization: str | None = Header(default=None)): + require_admin(authorization) + pool = cpa_config.update_pool(pool_id, body.model_dump(exclude_none=True)) + if pool is None: + raise HTTPException(status_code=404, detail={"error": "pool not found"}) + return {"pool": sanitize_cpa_pool(pool), "pools": sanitize_cpa_pools(cpa_config.list_pools())} + + @router.delete("/api/cpa/pools/{pool_id}") + async def delete_cpa_pool(pool_id: str, authorization: str | None = Header(default=None)): + require_admin(authorization) + if not cpa_config.delete_pool(pool_id): + raise HTTPException(status_code=404, detail={"error": "pool not found"}) + return {"pools": sanitize_cpa_pools(cpa_config.list_pools())} + + @router.get("/api/cpa/pools/{pool_id}/files") + async def cpa_pool_files(pool_id: str, authorization: str | None = Header(default=None)): + require_admin(authorization) + pool = cpa_config.get_pool(pool_id) + if pool is None: + raise HTTPException(status_code=404, detail={"error": "pool not found"}) + return {"pool_id": pool_id, "files": await run_in_threadpool(list_remote_files, pool)} + + @router.post("/api/cpa/pools/{pool_id}/import") + async def cpa_pool_import(pool_id: str, body: CPAImportRequest, authorization: str | None = Header(default=None)): + require_admin(authorization) + pool = cpa_config.get_pool(pool_id) + if pool is None: + raise HTTPException(status_code=404, detail={"error": "pool not found"}) + try: + job = cpa_import_service.start_import(pool, body.names) + except ValueError as exc: + raise HTTPException(status_code=400, detail={"error": str(exc)}) from exc + return {"import_job": job} + + @router.get("/api/cpa/pools/{pool_id}/import") + async def cpa_pool_import_progress(pool_id: str, authorization: str | None = Header(default=None)): + require_admin(authorization) + pool = cpa_config.get_pool(pool_id) + if pool is None: + raise HTTPException(status_code=404, detail={"error": "pool not found"}) + return {"import_job": pool.get("import_job")} + + @router.get("/api/sub2api/servers") + async def list_sub2api_servers(authorization: str | None = Header(default=None)): + require_admin(authorization) + return {"servers": sanitize_sub2api_servers(sub2api_config.list_servers())} + + @router.post("/api/sub2api/servers") + async def create_sub2api_server(body: Sub2APIServerCreateRequest, authorization: str | None = Header(default=None)): + require_admin(authorization) + if not body.base_url.strip(): + raise HTTPException(status_code=400, detail={"error": "base_url is required"}) + has_login = body.email.strip() and body.password.strip() + has_api_key = bool(body.api_key.strip()) + if not has_login and not has_api_key: + raise HTTPException(status_code=400, detail={"error": "email+password or api_key is required"}) + server = sub2api_config.add_server( + name=body.name, + base_url=body.base_url, + email=body.email, + password=body.password, + api_key=body.api_key, + group_id=body.group_id, + ) + return {"server": sanitize_sub2api_server(server), "servers": sanitize_sub2api_servers(sub2api_config.list_servers())} + + @router.post("/api/sub2api/servers/{server_id}") + async def update_sub2api_server(server_id: str, body: Sub2APIServerUpdateRequest, authorization: str | None = Header(default=None)): + require_admin(authorization) + server = sub2api_config.update_server(server_id, body.model_dump(exclude_none=True)) + if server is None: + raise HTTPException(status_code=404, detail={"error": "server not found"}) + return {"server": sanitize_sub2api_server(server), "servers": sanitize_sub2api_servers(sub2api_config.list_servers())} + + @router.delete("/api/sub2api/servers/{server_id}") + async def delete_sub2api_server(server_id: str, authorization: str | None = Header(default=None)): + require_admin(authorization) + if not sub2api_config.delete_server(server_id): + raise HTTPException(status_code=404, detail={"error": "server not found"}) + return {"servers": sanitize_sub2api_servers(sub2api_config.list_servers())} + + @router.get("/api/sub2api/servers/{server_id}/groups") + async def sub2api_server_groups(server_id: str, authorization: str | None = Header(default=None)): + require_admin(authorization) + server = sub2api_config.get_server(server_id) + if server is None: + raise HTTPException(status_code=404, detail={"error": "server not found"}) + try: + groups = await run_in_threadpool(sub2api_list_remote_groups, server) + except Exception as exc: + raise HTTPException(status_code=502, detail={"error": str(exc)}) from exc + return {"server_id": server_id, "groups": groups} + + @router.get("/api/sub2api/servers/{server_id}/accounts") + async def sub2api_server_accounts(server_id: str, authorization: str | None = Header(default=None)): + require_admin(authorization) + server = sub2api_config.get_server(server_id) + if server is None: + raise HTTPException(status_code=404, detail={"error": "server not found"}) + try: + accounts = await run_in_threadpool(sub2api_list_remote_accounts, server) + except Exception as exc: + raise HTTPException(status_code=502, detail={"error": str(exc)}) from exc + return {"server_id": server_id, "accounts": accounts} + + @router.post("/api/sub2api/servers/{server_id}/import") + async def sub2api_server_import(server_id: str, body: Sub2APIImportRequest, authorization: str | None = Header(default=None)): + require_admin(authorization) + server = sub2api_config.get_server(server_id) + if server is None: + raise HTTPException(status_code=404, detail={"error": "server not found"}) + try: + job = sub2api_import_service.start_import(server, body.account_ids) + except ValueError as exc: + raise HTTPException(status_code=400, detail={"error": str(exc)}) from exc + return {"import_job": job} + + @router.get("/api/sub2api/servers/{server_id}/import") + async def sub2api_server_import_progress(server_id: str, authorization: str | None = Header(default=None)): + require_admin(authorization) + server = sub2api_config.get_server(server_id) + if server is None: + raise HTTPException(status_code=404, detail={"error": "server not found"}) + return {"import_job": server.get("import_job")} + + return router diff --git a/api/ai.py b/api/ai.py new file mode 100644 index 0000000000000000000000000000000000000000..709417706a775e260c0bf963080f4edf7bb82e38 --- /dev/null +++ b/api/ai.py @@ -0,0 +1,141 @@ +from __future__ import annotations + +from fastapi import APIRouter, Header, HTTPException, Request +from fastapi.concurrency import run_in_threadpool +from pydantic import BaseModel, ConfigDict, Field + +from api.image_inputs import parse_image_edit_request, read_image_sources +from api.support import require_identity, resolve_image_base_url +from services.content_filter import check_request, request_text +from services.log_service import LoggedCall +from services.protocol import ( + anthropic_v1_messages, + openai_v1_chat_complete, + openai_v1_image_edit, + openai_v1_image_generations, + openai_v1_models, + openai_v1_response, +) + + +class ImageGenerationRequest(BaseModel): + prompt: str = Field(..., min_length=1) + model: str = "gpt-image-2" + n: int = Field(default=1, ge=1, le=4) + size: str | None = None + quality: str = "auto" + response_format: str = "b64_json" + history_disabled: bool = True + stream: bool | None = None + + +class ChatCompletionRequest(BaseModel): + model_config = ConfigDict(extra="allow") + model: str | None = None + prompt: str | None = None + n: int | None = None + stream: bool | None = None + modalities: list[str] | None = None + messages: list[dict[str, object]] | None = None + + +class ResponseCreateRequest(BaseModel): + model_config = ConfigDict(extra="allow") + model: str | None = None + input: object | None = None + tools: list[dict[str, object]] | None = None + tool_choice: object | None = None + stream: bool | None = None + + +class AnthropicMessageRequest(BaseModel): + model_config = ConfigDict(extra="allow") + model: str | None = None + messages: list[dict[str, object]] | None = None + system: object | None = None + stream: bool | None = None + + +async def filter_or_log(call: LoggedCall, text: str) -> None: + try: + await run_in_threadpool(check_request, text) + except HTTPException as exc: + call.log("调用失败", status="failed", error=str(exc.detail)) + raise + + +def create_router() -> APIRouter: + router = APIRouter() + + @router.get("/v1/models") + async def list_models(authorization: str | None = Header(default=None)): + require_identity(authorization) + try: + return await run_in_threadpool(openai_v1_models.list_models) + except Exception as exc: + raise HTTPException(status_code=502, detail={"error": str(exc)}) from exc + + @router.post("/v1/images/generations") + async def generate_images( + body: ImageGenerationRequest, + request: Request, + authorization: str | None = Header(default=None), + ): + identity = require_identity(authorization) + payload = body.model_dump(mode="python") + payload["base_url"] = resolve_image_base_url(request) + call = LoggedCall(identity, "/v1/images/generations", body.model, "文生图", request_text=body.prompt) + await filter_or_log(call, body.prompt) + return await call.run(openai_v1_image_generations.handle, payload) + + @router.post("/v1/images/edits") + async def edit_images( + request: Request, + authorization: str | None = Header(default=None), + ): + identity = require_identity(authorization) + payload, image_sources = await parse_image_edit_request(request) + prompt = str(payload["prompt"]) + model = str(payload["model"]) + call = LoggedCall(identity, "/v1/images/edits", model, "图生图", request_text=prompt) + await filter_or_log(call, prompt) + payload["images"] = await read_image_sources(image_sources) + payload["base_url"] = resolve_image_base_url(request) + return await call.run(openai_v1_image_edit.handle, payload) + + @router.post("/v1/chat/completions") + async def create_chat_completion(body: ChatCompletionRequest, authorization: str | None = Header(default=None)): + identity = require_identity(authorization) + payload = body.model_dump(mode="python") + model = str(payload.get("model") or "auto") + request_preview = request_text(payload.get("prompt"), payload.get("messages")) + call = LoggedCall(identity, "/v1/chat/completions", model, "文本生成", request_text=request_preview) + await filter_or_log(call, request_preview) + return await call.run(openai_v1_chat_complete.handle, payload) + + @router.post("/v1/responses") + async def create_response(body: ResponseCreateRequest, authorization: str | None = Header(default=None)): + identity = require_identity(authorization) + payload = body.model_dump(mode="python") + model = str(payload.get("model") or "auto") + request_preview = request_text(payload.get("input"), payload.get("instructions")) + call = LoggedCall(identity, "/v1/responses", model, "Responses", request_text=request_preview) + await filter_or_log(call, request_preview) + return await call.run(openai_v1_response.handle, payload) + + @router.post("/v1/messages") + async def create_message( + body: AnthropicMessageRequest, + authorization: str | None = Header(default=None), + x_api_key: str | None = Header(default=None, alias="x-api-key"), + anthropic_version: str | None = Header(default=None, alias="anthropic-version"), + ): + identity = require_identity(authorization or (f"Bearer {x_api_key}" if x_api_key else None)) + payload = body.model_dump(mode="python") + model = str(payload.get("model") or "auto") + request_preview = request_text(payload.get("system"), payload.get("messages"), payload.get("tools")) + call = LoggedCall(identity, "/v1/messages", model, "Messages", request_text=request_preview) + await filter_or_log(call, request_preview) + return await call.run(anthropic_v1_messages.handle, payload, sse="anthropic") + + return router diff --git a/api/app.py b/api/app.py new file mode 100644 index 0000000000000000000000000000000000000000..18f7bb1109e28e9690f9b9e6f5427090c49cfb0b --- /dev/null +++ b/api/app.py @@ -0,0 +1,63 @@ +from __future__ import annotations + +from contextlib import asynccontextmanager +from threading import Event + +from fastapi import FastAPI, HTTPException +from fastapi.middleware.cors import CORSMiddleware +from fastapi.responses import FileResponse + +from api import accounts, ai, image_tasks, register, system +from api.errors import install_exception_handlers +from api.support import resolve_web_asset, start_limited_account_watcher +from services.backup_service import backup_service +from services.config import config +from services.image_service import start_image_cleanup_scheduler + + +def create_app() -> FastAPI: + app_version = config.app_version + + @asynccontextmanager + async def lifespan(_: FastAPI): + stop_event = Event() + thread = start_limited_account_watcher(stop_event) + cleanup_thread = start_image_cleanup_scheduler(stop_event) + backup_service.start() + config.cleanup_old_images() + try: + yield + finally: + stop_event.set() + thread.join(timeout=1) + cleanup_thread.join(timeout=1) + backup_service.stop() + + app = FastAPI(title="chatgpt2api", version=app_version, lifespan=lifespan) + install_exception_handlers(app) + app.add_middleware( + CORSMiddleware, + allow_origins=["*"], + allow_credentials=False, + allow_methods=["*"], + allow_headers=["*"], + ) + app.include_router(ai.create_router()) + app.include_router(accounts.create_router()) + app.include_router(image_tasks.create_router()) + app.include_router(register.create_router()) + app.include_router(system.create_router(app_version)) + + @app.get("/{full_path:path}", include_in_schema=False) + async def serve_web(full_path: str): + asset = resolve_web_asset(full_path) + if asset is not None: + return FileResponse(asset) + if full_path.strip("/").startswith("_next/"): + raise HTTPException(status_code=404, detail="Not Found") + fallback = resolve_web_asset("") + if fallback is None: + raise HTTPException(status_code=404, detail="Not Found") + return FileResponse(fallback) + + return app diff --git a/api/errors.py b/api/errors.py new file mode 100644 index 0000000000000000000000000000000000000000..6ffd46e07ae96ce9debb68ae841f0f37983b91d0 --- /dev/null +++ b/api/errors.py @@ -0,0 +1,46 @@ +from __future__ import annotations + +from fastapi import FastAPI, Request +from fastapi.encoders import jsonable_encoder +from fastapi.exceptions import RequestValidationError +from fastapi.responses import JSONResponse +from starlette.exceptions import HTTPException as StarletteHTTPException + +from services.protocol.error_response import anthropic_error_response, openai_error_response + + +def _is_openai_compatible_path(path: str) -> bool: + return path == "/v1" or path.startswith("/v1/") + + +def _is_anthropic_messages_path(path: str) -> bool: + return path == "/v1/messages" + + +def _compatible_error_response( + request: Request, + detail: object, + status_code: int, + headers: dict[str, str] | None = None, +) -> JSONResponse: + if _is_anthropic_messages_path(request.url.path): + return anthropic_error_response(detail, status_code, headers=headers) + return openai_error_response(detail, status_code, headers=headers) + + +def install_exception_handlers(app: FastAPI) -> None: + @app.exception_handler(StarletteHTTPException) + async def http_exception_handler(request: Request, exc: StarletteHTTPException) -> JSONResponse: + if _is_openai_compatible_path(request.url.path): + return _compatible_error_response(request, exc.detail, exc.status_code, exc.headers) + return JSONResponse( + status_code=exc.status_code, + content={"detail": jsonable_encoder(exc.detail)}, + headers=exc.headers, + ) + + @app.exception_handler(RequestValidationError) + async def validation_exception_handler(request: Request, exc: RequestValidationError) -> JSONResponse: + if _is_openai_compatible_path(request.url.path): + return _compatible_error_response(request, exc.errors(), 422) + return JSONResponse(status_code=422, content={"detail": jsonable_encoder(exc.errors())}) diff --git a/api/image_inputs.py b/api/image_inputs.py new file mode 100644 index 0000000000000000000000000000000000000000..31af6e08249243f4703f73a219da23bcd1460e86 --- /dev/null +++ b/api/image_inputs.py @@ -0,0 +1,294 @@ +from __future__ import annotations + +import base64 +import binascii +import json +import mimetypes +import re +from pathlib import PurePosixPath +from typing import Any, TypeGuard +from urllib.parse import unquote, unquote_to_bytes, urlparse + +from curl_cffi import requests +from fastapi import HTTPException, Request +from fastapi.concurrency import run_in_threadpool +from starlette.datastructures import UploadFile + +from services.proxy_service import proxy_settings + +ImageInput = tuple[bytes, str, str] +ImageSource = str | UploadFile | ImageInput + +MAX_IMAGE_REFERENCE_BYTES = 50 * 1024 * 1024 +IMAGE_REFERENCE_FIELDS = {"image", "image[]", "images", "images[]", "image_url", "image_url[]"} + + +def _clean(value: object, default: str = "") -> str: + """清理字符串:转换为字符串并去掉首尾空白。""" + text = str(value if value is not None else default).strip() + return text or default + + +def _is_upload(value: object) -> TypeGuard[UploadFile]: + """识别上传文件:兼容 Starlette 表单返回的 UploadFile。""" + return isinstance(value, UploadFile) + + +def _parse_bool(value: object) -> bool | None: + """解析布尔字段:兼容 JSON 布尔值和表单字符串。""" + if value is None or value == "": + return None + if isinstance(value, bool): + return value + text = _clean(value).lower() + if text in {"true", "1", "yes", "y", "on"}: + return True + if text in {"false", "0", "no", "n", "off"}: + return False + raise HTTPException(status_code=400, detail={"error": "stream must be a boolean"}) + + +def _parse_count(value: object) -> int: + """解析生成数量:保持图片接口的 1 到 4 限制。""" + try: + count = int(value or 1) + except (TypeError, ValueError) as exc: + raise HTTPException(status_code=400, detail={"error": "n must be an integer"}) from exc + if count < 1 or count > 4: + raise HTTPException(status_code=400, detail={"error": "n must be between 1 and 4"}) + return count + + +def _payload_from_fields(fields: dict[str, Any]) -> dict[str, Any]: + """构造图片编辑载荷:从表单或 JSON 字段提取通用参数。""" + prompt = _clean(fields.get("prompt")) + if not prompt: + raise HTTPException(status_code=400, detail={"error": "prompt is required"}) + payload = { + "prompt": prompt, + "model": _clean(fields.get("model"), "gpt-image-2"), + "n": _parse_count(fields.get("n")), + "size": _clean(fields.get("size")) or None, + "quality": _clean(fields.get("quality"), "auto"), + "response_format": _clean(fields.get("response_format"), "b64_json"), + "stream": _parse_bool(fields.get("stream")), + } + if "client_task_id" in fields: + payload["client_task_id"] = _clean(fields.get("client_task_id")) + return payload + + +def _json_reference_value(value: object) -> object: + """解析表单图片引用:支持把 images 字段写成 JSON 字符串。""" + if not isinstance(value, str): + return value + text = value.strip() + if not text or text[0] not in "[{": + return value + try: + return json.loads(text) + except json.JSONDecodeError: + return value + + +def _decode_base64_image(value: object, filename: str, mime_type: str) -> ImageInput: + try: + data = base64.b64decode(str(value).strip(), validate=True) + except (binascii.Error, ValueError) as exc: + raise HTTPException(status_code=400, detail={"error": "invalid base64 image data"}) from exc + if not data: + raise HTTPException(status_code=400, detail={"error": "image file is empty"}) + if len(data) > MAX_IMAGE_REFERENCE_BYTES: + raise HTTPException(status_code=400, detail={"error": "image URL exceeds 50MB limit"}) + return data, filename, mime_type + + +def _source_from_object(value: dict[str, Any]) -> list[ImageSource]: + """提取图片引用对象:支持 image_url 或 url,明确拒绝 file_id。""" + has_url = "image_url" in value or "url" in value + if value.get("file_id"): + raise HTTPException( + status_code=400, + detail={"error": "file_id image references are not supported; use image_url instead"}, + ) + inline = value.get("b64_json") or value.get("base64") + if inline: + filename = _clean(value.get("filename") or value.get("file_name"), "image.png") + mime_type = _clean(value.get("mime_type") or value.get("mimeType"), "image/png") + return [_decode_base64_image(inline, filename, mime_type)] + if not has_url: + raise HTTPException(status_code=400, detail={"error": "image reference must include image_url"}) + image_url = value.get("image_url", value.get("url")) + if isinstance(image_url, dict): + image_url = image_url.get("url") + return _sources_from_value(image_url) + + +def _sources_from_value(value: object) -> list[ImageSource]: + """展开图片引用:把字符串、数组和对象统一成图片来源列表。""" + value = _json_reference_value(value) + if _is_upload(value): + return [value] + if isinstance(value, str): + text = value.strip() + if not text: + return [] + if text.lower().startswith(("data:", "http://", "https://")): + return [text] + return [_decode_base64_image(text, "image.png", "image/png")] + if isinstance(value, list): + sources: list[ImageSource] = [] + for item in value: + sources.extend(_sources_from_value(item)) + return sources + if isinstance(value, dict): + return _source_from_object(value) + if value is None: + return [] + raise HTTPException(status_code=400, detail={"error": "invalid image reference"}) + + +def _json_image_sources(body: dict[str, Any]) -> list[ImageSource]: + """读取 JSON 图片引用:优先支持官方 images 数组字段。""" + sources: list[ImageSource] = [] + for key in ("images", "image", "image_url"): + if key in body: + sources.extend(_sources_from_value(body.get(key))) + return sources + + +async def parse_image_edit_request(request: Request) -> tuple[dict[str, Any], list[ImageSource]]: + """解析图片编辑请求:同时支持 multipart 上传和官方 JSON 图片 URL。""" + content_type = request.headers.get("content-type", "").split(";", 1)[0].strip().lower() + if content_type == "application/json": + try: + body = await request.json() + except json.JSONDecodeError as exc: + raise HTTPException(status_code=400, detail={"error": "invalid JSON body"}) from exc + if not isinstance(body, dict): + raise HTTPException(status_code=400, detail={"error": "JSON body must be an object"}) + return _payload_from_fields(body), _json_image_sources(body) + + form = await request.form() + fields: dict[str, Any] = {} + for key in ("client_task_id", "prompt", "model", "n", "size", "quality", "response_format", "stream"): + value = form.get(key) + if isinstance(value, str): + fields[key] = value + sources: list[ImageSource] = [] + for key, value in form.multi_items(): + if key in IMAGE_REFERENCE_FIELDS: + sources.extend(_sources_from_value(value)) + return _payload_from_fields(fields), sources + + +def _extension_from_mime(mime_type: str) -> str: + """推导图片扩展名:把 MIME 类型转换为常见文件后缀。""" + subtype = mime_type.split("/", 1)[1].split("+", 1)[0] if "/" in mime_type else "png" + if subtype == "jpeg": + return "jpg" + return re.sub(r"[^a-z0-9]+", "", subtype.lower()) or "png" + + +def _safe_filename(name: str, mime_type: str, fallback: str) -> str: + """生成安全文件名:清理 URL 文件名并补齐扩展名。""" + cleaned = re.sub(r"[^A-Za-z0-9._-]+", "_", name).strip("._") + if not cleaned: + cleaned = fallback + if "." not in cleaned: + cleaned = f"{cleaned}.{_extension_from_mime(mime_type)}" + return cleaned + + +def _decode_data_url(url: str) -> ImageInput: + """解码 data URL:把内联图片转成标准图片输入元组。""" + header, separator, payload = url.partition(",") + if not separator: + raise HTTPException(status_code=400, detail={"error": "invalid data image URL"}) + mime_type = header.split(";", 1)[0].removeprefix("data:") or "image/png" + if not mime_type.startswith("image/"): + raise HTTPException(status_code=400, detail={"error": "image_url must point to an image"}) + try: + data = base64.b64decode(payload, validate=True) if ";base64" in header else unquote_to_bytes(payload) + except (binascii.Error, ValueError) as exc: + raise HTTPException(status_code=400, detail={"error": "invalid data image URL"}) from exc + if not data: + raise HTTPException(status_code=400, detail={"error": "image URL is empty"}) + if len(data) > MAX_IMAGE_REFERENCE_BYTES: + raise HTTPException(status_code=400, detail={"error": "image URL exceeds 50MB limit"}) + return data, f"image_url.{_extension_from_mime(mime_type)}", mime_type + + +def _response_mime_type(response: requests.Response, parsed_path: str) -> str: + """识别下载图片类型:优先响应头,必要时按 URL 后缀推断。""" + header_type = str(response.headers.get("content-type") or "").split(";", 1)[0].strip().lower() + guessed_type = mimetypes.guess_type(parsed_path)[0] or "" + if header_type.startswith("image/"): + return header_type + if header_type and header_type not in {"application/octet-stream", "binary/octet-stream"}: + raise HTTPException(status_code=400, detail={"error": "image_url must point to an image"}) + if guessed_type.startswith("image/"): + return guessed_type + if not header_type or header_type in {"application/octet-stream", "binary/octet-stream"}: + return "image/png" + raise HTTPException(status_code=400, detail={"error": "image_url must point to an image"}) + + +def _filename_from_url(parsed_path: str, mime_type: str) -> str: + """生成 URL 图片文件名:从链接路径提取名称并做安全化。""" + raw_name = PurePosixPath(unquote(parsed_path)).name + return _safe_filename(raw_name, mime_type, "image_url") + + +def _download_image_url(url: str) -> ImageInput: + """下载远程图片:把 http/https 图片链接转成标准图片输入元组。""" + source = _clean(url) + if source.startswith("data:"): + return _decode_data_url(source) + parsed = urlparse(source) + if parsed.scheme not in {"http", "https"} or not parsed.netloc: + raise HTTPException(status_code=400, detail={"error": "image_url must be an http or https URL"}) + try: + response = requests.get( + source, + headers={"Accept": "image/*,*/*;q=0.8", "User-Agent": "chatgpt2api image fetcher"}, + timeout=60, + allow_redirects=True, + **proxy_settings.build_session_kwargs(), + ) + except Exception as exc: + raise HTTPException(status_code=400, detail={"error": f"image_url fetch failed: {exc}"}) from exc + if not 200 <= response.status_code < 300: + raise HTTPException(status_code=400, detail={"error": f"image_url fetch failed: HTTP {response.status_code}"}) + content_length = _clean(response.headers.get("content-length")) + if content_length and content_length.isdigit() and int(content_length) > MAX_IMAGE_REFERENCE_BYTES: + raise HTTPException(status_code=400, detail={"error": "image_url exceeds 50MB limit"}) + data = response.content + if not data: + raise HTTPException(status_code=400, detail={"error": "image_url returned empty content"}) + if len(data) > MAX_IMAGE_REFERENCE_BYTES: + raise HTTPException(status_code=400, detail={"error": "image_url exceeds 50MB limit"}) + mime_type = _response_mime_type(response, parsed.path) + return data, _filename_from_url(parsed.path, mime_type), mime_type + + +async def read_image_sources(sources: list[ImageSource]) -> list[ImageInput]: + """读取图片来源:上传文件直接读取,URL 下载后统一返回图片元组。""" + images: list[ImageInput] = [] + for source in sources: + if isinstance(source, tuple): + images.append(source) + continue + if _is_upload(source): + try: + image_data = await source.read() + finally: + await source.close() + if not image_data: + raise HTTPException(status_code=400, detail={"error": "image file is empty"}) + images.append((image_data, source.filename or "image.png", source.content_type or "image/png")) + continue + images.append(await run_in_threadpool(_download_image_url, source)) + if not images: + raise HTTPException(status_code=400, detail={"error": "image file or image_url is required"}) + return images diff --git a/api/image_tasks.py b/api/image_tasks.py new file mode 100644 index 0000000000000000000000000000000000000000..2c9a0025157cdf50abb8cf610ae7157c0e86a073 --- /dev/null +++ b/api/image_tasks.py @@ -0,0 +1,96 @@ +from __future__ import annotations + +from fastapi import APIRouter, Header, HTTPException, Query, Request +from fastapi.concurrency import run_in_threadpool +from pydantic import BaseModel, Field + +from api.image_inputs import parse_image_edit_request, read_image_sources +from api.support import require_identity, resolve_image_base_url +from services.content_filter import check_request +from services.image_task_service import image_task_service +from services.log_service import LoggedCall + + +class ImageGenerationTaskRequest(BaseModel): + client_task_id: str = Field(..., min_length=1) + prompt: str = Field(..., min_length=1) + model: str = "gpt-image-2" + size: str | None = None + quality: str = "auto" + + +def _parse_task_ids(value: str) -> list[str]: + return [item.strip() for item in value.split(",") if item.strip()] + + +async def filter_or_log(call: LoggedCall, text: str) -> None: + try: + await run_in_threadpool(check_request, text) + except HTTPException as exc: + call.log("调用失败", status="failed", error=str(exc.detail)) + raise + + +def create_router() -> APIRouter: + router = APIRouter() + + @router.get("/api/image-tasks") + async def list_image_tasks( + ids: str = Query(default=""), + authorization: str | None = Header(default=None), + ): + identity = require_identity(authorization) + return await run_in_threadpool(image_task_service.list_tasks, identity, _parse_task_ids(ids)) + + @router.post("/api/image-tasks/generations") + async def create_generation_task( + body: ImageGenerationTaskRequest, + request: Request, + authorization: str | None = Header(default=None), + ): + identity = require_identity(authorization) + await filter_or_log(LoggedCall(identity, "/api/image-tasks/generations", body.model, "文生图任务", request_text=body.prompt), body.prompt) + try: + return await run_in_threadpool( + image_task_service.submit_generation, + identity, + client_task_id=body.client_task_id, + prompt=body.prompt, + model=body.model, + size=body.size, + quality=body.quality, + base_url=resolve_image_base_url(request), + ) + except ValueError as exc: + raise HTTPException(status_code=400, detail={"error": str(exc)}) from exc + + @router.post("/api/image-tasks/edits") + async def create_edit_task( + request: Request, + authorization: str | None = Header(default=None), + ): + identity = require_identity(authorization) + payload, image_sources = await parse_image_edit_request(request) + client_task_id = str(payload.get("client_task_id") or "").strip() + if not client_task_id: + raise HTTPException(status_code=400, detail={"error": "client_task_id is required"}) + prompt = str(payload["prompt"]) + model = str(payload["model"]) + await filter_or_log(LoggedCall(identity, "/api/image-tasks/edits", model, "图生图任务", request_text=prompt), prompt) + images = await read_image_sources(image_sources) + try: + return await run_in_threadpool( + image_task_service.submit_edit, + identity, + client_task_id=client_task_id, + prompt=prompt, + model=model, + size=payload["size"], + quality=payload["quality"], + base_url=resolve_image_base_url(request), + images=images, + ) + except ValueError as exc: + raise HTTPException(status_code=400, detail={"error": str(exc)}) from exc + + return router diff --git a/api/register.py b/api/register.py new file mode 100644 index 0000000000000000000000000000000000000000..859e1a21701b986e7bcaf6ed2315500d1bcc4eda --- /dev/null +++ b/api/register.py @@ -0,0 +1,68 @@ +from __future__ import annotations + +import asyncio +import json + +from fastapi import APIRouter, Header +from fastapi.responses import StreamingResponse +from pydantic import BaseModel + +from api.support import require_admin +from services.register_service import register_service + + +class RegisterConfigRequest(BaseModel): + mail: dict | None = None + proxy: str | None = None + total: int | None = None + threads: int | None = None + mode: str | None = None + target_quota: int | None = None + target_available: int | None = None + check_interval: int | None = None + + +def create_router() -> APIRouter: + router = APIRouter() + + @router.get("/api/register") + async def get_register_config(authorization: str | None = Header(default=None)): + require_admin(authorization) + return {"register": register_service.get()} + + @router.post("/api/register") + async def update_register_config(body: RegisterConfigRequest, authorization: str | None = Header(default=None)): + require_admin(authorization) + return {"register": register_service.update(body.model_dump(exclude_none=True))} + + @router.post("/api/register/start") + async def start_register(authorization: str | None = Header(default=None)): + require_admin(authorization) + return {"register": register_service.start()} + + @router.post("/api/register/stop") + async def stop_register(authorization: str | None = Header(default=None)): + require_admin(authorization) + return {"register": register_service.stop()} + + @router.post("/api/register/reset") + async def reset_register(authorization: str | None = Header(default=None)): + require_admin(authorization) + return {"register": register_service.reset()} + + @router.get("/api/register/events") + async def register_events(token: str = ""): + require_admin(f"Bearer {token}") + + async def stream(): + last = "" + while True: + payload = json.dumps(register_service.get(), ensure_ascii=False) + if payload != last: + last = payload + yield f"data: {payload}\n\n" + await asyncio.sleep(0.5) + + return StreamingResponse(stream(), media_type="text/event-stream") + + return router diff --git a/api/support.py b/api/support.py new file mode 100644 index 0000000000000000000000000000000000000000..0132da00eaa50703455f7eb668900a176c372896 --- /dev/null +++ b/api/support.py @@ -0,0 +1,132 @@ +from __future__ import annotations + +from pathlib import Path +from threading import Event, Thread + +from fastapi import HTTPException, Request + +from services.account_service import account_service +from services.auth_service import auth_service +from services.config import config + +BASE_DIR = Path(__file__).resolve().parents[1] +WEB_DIST_DIR = BASE_DIR / "web_dist" + + +def extract_bearer_token(authorization: str | None) -> str: + scheme, _, value = str(authorization or "").partition(" ") + if scheme.lower() != "bearer" or not value.strip(): + return "" + return value.strip() + + +def _legacy_admin_identity(token: str) -> dict[str, object] | None: + auth_key = str(config.auth_key or "").strip() + if auth_key and token == auth_key: + return {"id": "admin", "name": "管理员", "role": "admin"} + return None + + +def require_identity(authorization: str | None) -> dict[str, object]: + token = extract_bearer_token(authorization) + identity = _legacy_admin_identity(token) or auth_service.authenticate(token) + if identity is None: + raise HTTPException(status_code=401, detail={"error": "密钥无效或已失效,请重新登录"}) + return identity + + +def require_auth_key(authorization: str | None) -> None: + require_identity(authorization) + + +def require_admin(authorization: str | None) -> dict[str, object]: + identity = require_identity(authorization) + if identity.get("role") != "admin": + raise HTTPException(status_code=403, detail={"error": "需要管理员权限才能执行这个操作"}) + return identity + + +def resolve_image_base_url(request: Request) -> str: + return config.base_url or f"{request.url.scheme}://{request.headers.get('host', request.url.netloc)}" + + +def raise_image_quota_error(exc: Exception) -> None: + message = str(exc) + if "no available image quota" in message.lower(): + raise HTTPException(status_code=429, detail={"error": "no available image quota"}) from exc + raise HTTPException(status_code=502, detail={"error": message}) from exc + + +def sanitize_cpa_pool(pool: dict | None) -> dict | None: + if not isinstance(pool, dict): + return None + return {key: value for key, value in pool.items() if key != "secret_key"} + + +def sanitize_cpa_pools(pools: list[dict]) -> list[dict]: + return [sanitized for pool in pools if (sanitized := sanitize_cpa_pool(pool)) is not None] + + +def sanitize_sub2api_server(server: dict | None) -> dict | None: + if not isinstance(server, dict): + return None + sanitized = {key: value for key, value in server.items() if key not in {"password", "api_key"}} + sanitized["has_api_key"] = bool(str(server.get("api_key") or "").strip()) + return sanitized + + +def sanitize_sub2api_servers(servers: list[dict]) -> list[dict]: + return [sanitized for server in servers if (sanitized := sanitize_sub2api_server(server)) is not None] + + +def start_limited_account_watcher(stop_event: Event) -> Thread: + interval_seconds = config.refresh_account_interval_minute * 60 + + def worker() -> None: + while not stop_event.is_set(): + try: + limited_tokens = account_service.list_limited_tokens() + expiring_tokens = account_service.list_expiring_access_tokens() + keepalive_tokens = account_service.list_refresh_token_keepalive_tokens() + tokens = list(dict.fromkeys([*limited_tokens, *expiring_tokens])) + expiring_token_set = set(expiring_tokens) + keepalive_tokens = [token for token in keepalive_tokens if token not in expiring_token_set] + if tokens: + print( + "[account-watcher] checking " + f"{len(limited_tokens)} limited accounts, " + f"{len(expiring_tokens)} expiring access tokens" + ) + account_service.refresh_accounts(tokens) + if keepalive_tokens: + print(f"[account-watcher] keepalive {len(keepalive_tokens)} refresh tokens") + result = account_service.keepalive_refresh_tokens(keepalive_tokens) + if result.get("errors"): + print(f"[account-watcher] keepalive errors: {result['errors']}") + except Exception as exc: + print(f"[account-watcher] fail {exc}") + stop_event.wait(interval_seconds) + + thread = Thread(target=worker, name="account-watcher", daemon=True) + thread.start() + return thread + + +def resolve_web_asset(requested_path: str) -> Path | None: + if not WEB_DIST_DIR.exists(): + return None + clean_path = requested_path.strip("/") + base_dir = WEB_DIST_DIR.resolve() + candidates = [base_dir / "index.html"] if not clean_path else [ + base_dir / Path(clean_path), + base_dir / clean_path / "index.html", + base_dir / f"{clean_path}.html", + ] + for candidate in candidates: + try: + candidate.resolve().relative_to(base_dir) + except ValueError: + continue + if candidate.is_file(): + return candidate + return None diff --git a/api/system.py b/api/system.py new file mode 100644 index 0000000000000000000000000000000000000000..c6a38f4dc569447d59c53d3416266190841003dc --- /dev/null +++ b/api/system.py @@ -0,0 +1,334 @@ +from __future__ import annotations + +from urllib.parse import quote + +from fastapi import APIRouter, Header, HTTPException, Query, Request +from fastapi.concurrency import run_in_threadpool +from fastapi.responses import HTMLResponse, Response, StreamingResponse +from pydantic import BaseModel, ConfigDict + +from api.support import require_admin, require_identity, resolve_image_base_url +from services.backup_service import BackupError, backup_service +from services.config import config +from services.image_service import ( + compress_images, + delete_images, + delete_to_target, + download_images_zip, + get_image_download_response, + get_image_response, + get_thumbnail_response, + list_images, + storage_stats, +) +from services.image_storage_service import ImageStorageError, image_storage_service +from services.image_tags_service import delete_tag, get_all_tags, set_tags +from services.log_service import log_service +from services.proxy_service import test_proxy + + +class SettingsUpdateRequest(BaseModel): + model_config = ConfigDict(extra="allow") + + +class ProxyTestRequest(BaseModel): + url: str = "" + + +class ImageDeleteRequest(BaseModel): + paths: list[str] = [] + start_date: str = "" + end_date: str = "" + all_matching: bool = False + +class ImageDownloadRequest(BaseModel): + paths: list[str] + +class ImageTagsRequest(BaseModel): + path: str + tags: list[str] + +class LogDeleteRequest(BaseModel): + ids: list[str] = [] +class BackupDeleteRequest(BaseModel): + key: str = "" + + +def create_router(app_version: str) -> APIRouter: + router = APIRouter() + + @router.post("/auth/login") + async def login(authorization: str | None = Header(default=None)): + identity = require_identity(authorization) + return { + "ok": True, + "version": app_version, + "role": identity.get("role"), + "subject_id": identity.get("id"), + "name": identity.get("name"), + } + + @router.get("/version") + async def get_version(): + return {"version": app_version} + + @router.get("/api/settings") + async def get_settings(authorization: str | None = Header(default=None)): + require_admin(authorization) + return {"config": config.get()} + + @router.post("/api/settings") + async def save_settings(body: SettingsUpdateRequest, authorization: str | None = Header(default=None)): + require_admin(authorization) + try: + return {"config": config.update(body.model_dump(mode="python"))} + except ValueError as exc: + raise HTTPException(status_code=400, detail={"error": str(exc)}) from exc + + @router.get("/api/images") + async def get_images(request: Request, start_date: str = "", end_date: str = "", authorization: str | None = Header(default=None)): + require_admin(authorization) + return list_images(resolve_image_base_url(request), start_date=start_date.strip(), end_date=end_date.strip()) + + @router.get("/images/{image_path:path}", include_in_schema=False) + async def get_image(image_path: str): + return get_image_response(image_path) + + @router.get("/image-thumbnails/{image_path:path}", include_in_schema=False) + async def get_image_thumbnail(image_path: str): + return get_thumbnail_response(image_path) + + @router.post("/api/images/delete") + async def delete_images_endpoint(body: ImageDeleteRequest, authorization: str | None = Header(default=None)): + require_admin(authorization) + return delete_images(body.paths, start_date=body.start_date.strip(), end_date=body.end_date.strip(), all_matching=body.all_matching) + + @router.post("/api/images/download") + async def download_images_endpoint(body: ImageDownloadRequest, authorization: str | None = Header(default=None)): + require_admin(authorization) + buf = download_images_zip(body.paths) + return StreamingResponse( + buf, + media_type="application/zip", + headers={"Content-Disposition": 'attachment; filename="images.zip"'}, + ) + + @router.get("/api/images/download/{image_path:path}") + async def download_single_image_endpoint(image_path: str, authorization: str | None = Header(default=None)): + require_admin(authorization) + return get_image_download_response(image_path) + + @router.get("/api/logs") + async def get_logs(type: str = "", start_date: str = "", end_date: str = "", authorization: str | None = Header(default=None)): + require_admin(authorization) + return {"items": log_service.list(type=type.strip(), start_date=start_date.strip(), end_date=end_date.strip())} + + @router.post("/api/logs/delete") + async def delete_logs(body: LogDeleteRequest, authorization: str | None = Header(default=None)): + require_admin(authorization) + return log_service.delete(body.ids) + + @router.post("/api/proxy/test") + async def test_proxy_endpoint(body: ProxyTestRequest, authorization: str | None = Header(default=None)): + require_admin(authorization) + candidate = (body.url or "").strip() or config.get_proxy_settings() + if not candidate: + raise HTTPException(status_code=400, detail={"error": "proxy url is required"}) + return {"result": await run_in_threadpool(test_proxy, candidate)} + + @router.get("/api/storage/info") + async def get_storage_info(authorization: str | None = Header(default=None)): + require_admin(authorization) + storage = config.get_storage_backend() + return { + "backend": storage.get_backend_info(), + "health": storage.health_check(), + } + + @router.post("/api/backup/test") + async def test_backup_connection(authorization: str | None = Header(default=None)): + require_admin(authorization) + try: + return {"result": await run_in_threadpool(backup_service.test_connection)} + except BackupError as exc: + raise HTTPException(status_code=400, detail={"error": str(exc)}) from exc + + @router.post("/api/image-storage/test") + async def test_image_storage_endpoint(authorization: str | None = Header(default=None)): + require_admin(authorization) + return {"result": await run_in_threadpool(image_storage_service.test_webdav)} + + @router.post("/api/image-storage/sync") + async def sync_image_storage_endpoint(authorization: str | None = Header(default=None)): + require_admin(authorization) + try: + return {"result": await run_in_threadpool(image_storage_service.sync_all)} + except ImageStorageError as exc: + raise HTTPException(status_code=400, detail={"error": str(exc)}) from exc + + @router.get("/api/backups") + async def get_backups(authorization: str | None = Header(default=None)): + require_admin(authorization) + try: + return { + "items": await run_in_threadpool(backup_service.list_backups), + "state": backup_service.get_status(), + "settings": backup_service.get_settings(), + } + except BackupError as exc: + raise HTTPException(status_code=400, detail={"error": str(exc)}) from exc + + @router.post("/api/backups/run") + async def run_backup_endpoint(authorization: str | None = Header(default=None)): + require_admin(authorization) + try: + return {"result": await run_in_threadpool(backup_service.run_backup)} + except BackupError as exc: + raise HTTPException(status_code=400, detail={"error": str(exc)}) from exc + + @router.post("/api/backups/delete") + async def delete_backup_endpoint(body: BackupDeleteRequest, authorization: str | None = Header(default=None)): + require_admin(authorization) + try: + await run_in_threadpool(backup_service.delete_backup, body.key) + return {"ok": True} + except BackupError as exc: + raise HTTPException(status_code=400, detail={"error": str(exc)}) from exc + + @router.get("/api/backups/detail") + async def get_backup_detail(key: str = "", authorization: str | None = Header(default=None)): + require_admin(authorization) + try: + return {"item": await run_in_threadpool(backup_service.get_backup_detail, key)} + except BackupError as exc: + raise HTTPException(status_code=400, detail={"error": str(exc)}) from exc + + @router.get("/api/backups/download") + async def download_backup_endpoint(key: str = "", authorization: str | None = Header(default=None)): + require_admin(authorization) + try: + item = await run_in_threadpool(backup_service.download_backup, key) + except BackupError as exc: + raise HTTPException(status_code=400, detail={"error": str(exc)}) from exc + filename = str(item.get("name") or "backup.bin") + quoted = quote(filename) + headers = { + "Content-Disposition": f"attachment; filename*=UTF-8''{quoted}", + "Content-Length": str(int(item.get("size") or 0)), + } + return Response( + content=bytes(item.get("payload") or b""), + media_type=str(item.get("content_type") or "application/octet-stream"), + headers=headers, + ) + + + @router.get("/api/images/tags") + async def list_image_tags(authorization: str | None = Header(default=None)): + require_admin(authorization) + return {"tags": get_all_tags()} + + @router.post("/api/images/tags") + async def update_image_tags(body: ImageTagsRequest, authorization: str | None = Header(default=None)): + require_admin(authorization) + rel = body.path.strip().lstrip("/") + if not rel: + raise HTTPException(status_code=400, detail={"error": "path is required"}) + tags = set_tags(rel, body.tags) + return {"ok": True, "tags": tags} + + @router.delete("/api/images/tags/{tag}") + async def delete_image_tag(tag: str, authorization: str | None = Header(default=None)): + require_admin(authorization) + count = delete_tag(tag) + return {"ok": True, "removed_from": count} + + @router.get("/api/images/storage") + async def get_image_storage(authorization: str | None = Header(default=None)): + require_admin(authorization) + return storage_stats() + + @router.post("/api/images/storage/compress") + async def compress_all_images(authorization: str | None = Header(default=None)): + require_admin(authorization) + return await run_in_threadpool(compress_images) + + @router.post("/api/images/storage/cleanup-to-target") + async def cleanup_to_target( + target_free_mb: int = 500, + dry_run: bool = False, + authorization: str | None = Header(default=None), + ): + require_admin(authorization) + return await run_in_threadpool(delete_to_target, target_free_mb, dry_run) + + @router.get("/health", response_model=None) + async def health_dashboard(format: str = Query(default="html")): + from services.account_service import account_service as acct_svc + stats = acct_svc.get_stats() + storage = config.get_storage_backend() + storage_health = storage.health_check() + healthy = stats["active"] > 0 or stats["unlimited_quota_count"] > 0 + + stats_json = { + "status": "ok" if healthy else "degraded", + "healthy": healthy, + "version": app_version, + "storage": {"backend": storage.get_backend_info(), "health": storage_health}, + "accounts": stats, + } + if format == "json": + return stats_json + return HTMLResponse(f""" + + +号池健康监控 - chatgpt2api + + + + +
+

号池健康监控

+
v{app_version} · 30s 自动刷新
+
+
+
+
号池状态
{'正常' if healthy else '异常'}
+
当前账号
{stats['total']}
+
累计入库
{stats['cumulative_total']}
+
可用账号
{stats['active']}
+
无限额
{stats['unlimited_quota_count']}
+
剩余额度
{stats['total_quota']}
+
限流
{stats['limited']}
+
异常
{stats['abnormal']}
+
禁用
{stats['disabled']}
+
成功/失败
{stats['total_success']}/{stats['total_fail']}
+
+

账号类型分布

+ + +{''.join(f'' for t,c in sorted(stats['by_type'].items()))} +
类型数量
{t}{c}
+
JSON: /health?format=json
+
""") + + return router diff --git a/docker-compose.local.yml b/docker-compose.local.yml new file mode 100644 index 0000000000000000000000000000000000000000..288901909a7c7a5619bbb5f20ac224f180165243 --- /dev/null +++ b/docker-compose.local.yml @@ -0,0 +1,34 @@ +services: + app: + build: + context: . + dockerfile: Dockerfile + image: chatgpt2api:local + container_name: chatgpt2api-local + ports: + - "8000:80" + volumes: + - ./data:/app/data + - ./config.json:/app/config.json + environment: + STORAGE_BACKEND: sqlite + DATABASE_URL: sqlite:////app/data/accounts.db + # 存储后端配置 (可选值: json, sqlite, postgres, git) + # environment: + # STORAGE_BACKEND: json + + # 数据库配置 (当 STORAGE_BACKEND=sqlite/postgres 时使用) + # DATABASE_URL: postgresql://user:password@host:5432/dbname + # DATABASE_URL: sqlite:////app/data/accounts.db + + # Git 仓库配置 (当 STORAGE_BACKEND=git 时使用) + # GIT_REPO_URL: https://github.com/user/repo.git + # GIT_TOKEN: ghp_xxxxxxxxxxxx + # GIT_BRANCH: main + # GIT_FILE_PATH: accounts.json + + # 认证密钥 (可选,覆盖 config.json) + # CHATGPT2API_AUTH_KEY: your_secret_key + + # 基础 URL (可选) + # CHATGPT2API_BASE_URL: https://your-domain.com diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000000000000000000000000000000000000..a80dc6435132a3da33ee3fc357e367a7763882c6 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,29 @@ +services: + app: + image: ghcr.io/basketikun/chatgpt2api:latest + container_name: chatgpt2api + restart: unless-stopped + ports: + - "3000:80" + volumes: + - ./data:/app/data + - ./config.json:/app/config.json + environment: + # 存储后端配置 (可选值: json, sqlite, postgres, git) + - STORAGE_BACKEND=json + + # 数据库配置 (当 STORAGE_BACKEND=sqlite/postgres 时使用) + # - DATABASE_URL=postgresql://user:password@host:5432/dbname + # - DATABASE_URL=sqlite:///app/data/accounts.db + + # Git 仓库配置 (当 STORAGE_BACKEND=git 时使用) + # - GIT_REPO_URL=https://github.com/user/repo.git + # - GIT_TOKEN=ghp_xxxxxxxxxxxx + # - GIT_BRANCH=main + # - GIT_FILE_PATH=accounts.json + + # 认证密钥 (可选,覆盖 config.json) + # - CHATGPT2API_AUTH_KEY=your_secret_key + + # 基础 URL (可选) + # - CHATGPT2API_BASE_URL=https://your-domain.com diff --git a/docs/feature-status.en.md b/docs/feature-status.en.md new file mode 100644 index 0000000000000000000000000000000000000000..34f0bc9a6e64a1b96a459935b72c8b4d61ad1900 --- /dev/null +++ b/docs/feature-status.en.md @@ -0,0 +1,37 @@ +# 功能状态 + +本文基于当前仓库当前实现整理,用于帮助用户快速了解哪些功能已经可用、哪些仍在完善、哪些待实现。 + +| 功能 | 状态 | 说明 | +|:----------------------------------------|:--:|:--------------------------------------------------------------| +| OpenAI 兼容 `POST /v1/images/generations` | ✅ | 已支持,用于图片生成,并可通过 `n` 返回多张图片。 | +| OpenAI 兼容 `POST /v1/images/edits` | ✅ | 已支持,可上传图片进行编辑。 | +| 面向图片工作流的 `POST /v1/chat/completions` | ✅ | 已支持图片相关请求。 | +| 面向图片工作流的 `POST /v1/responses` | ✅ | 已支持图片生成工具调用。 | +| `GET /v1/models` 接口 | ✅ | 当前返回 `gpt-image-2`、`codex-gpt-image-2`、`auto`、`gpt-5`、`gpt-5-1`、`gpt-5-2`、`gpt-5-3`、`gpt-5-3-mini`、`gpt-5-mini`。 | +| 同时生成多张图片 | ✅ | 已支持,后端与前端都可进行多图生成。 | +| 前端图片工作台 | ✅ | 已支持图片生成、图片编辑、模型选择、历史记录与查看大图。 | +| 前端图片输入 / 参考图交互 | ✅ | 已支持参考图上传、预览、移除和编辑模式工作流。 | +| Codex 画图接口逆向 | ✅ | 已支持,仅 `Plus` / `Team` / `Pro` 订阅可用,模型别名为 `codex-gpt-image-2`;如有需要可自行在其他场景映射回 `gpt-image-2`。这是 Codex 逆向链路,用于和官网画图区分,同一账号通常会同时支持官网和 Codex 两份生图额度。 | +| Cherry Studio 接入 | ✅ | 已支持作为绘图接口接入 Cherry Studio。 | +| New API 接入 | ✅ | 已支持接入 New API。 | +| 账号池管理 | ✅ | 已支持列表、筛选、批量操作、导出、手动编辑、刷新和删除。 | +| 账号额度刷新与恢复时间同步 | ✅ | 已支持账号信息刷新,限流账号也会自动继续检查。 | +| 失效 Token 自动清理 | ✅ | 已支持自动移除失效 Token。 | +| CPA 连接管理 | ✅ | 已支持 CPA 连接的新增、修改、查询和删除。 | +| CPA 文件浏览与按需导入 | ✅ | 已支持读取远程文件列表、筛选、勾选并导入到本地号池。 | +| CPA 导入进度跟踪 | ✅ | 已支持导入进度展示与轮询更新。 | +| `sub2api` 连接管理与账号浏览 | ✅ | 已支持 `sub2api` 服务器的新增、修改、删除、分组查询和 OpenAI OAuth 账号列表读取。 | +| `sub2api` 导入 | ✅ | 已支持勾选 `sub2api` 中的 OpenAI OAuth 账号,批量拉取 `access_token` 导入本地号池,并展示导入进度。 | +| Docker 自托管部署 | ✅ | 已支持 Docker Compose 部署,并提供多架构镜像。 | +| 兼容接口中的多参考图能力 | ✅ | 已实现,支持在兼容接口中传入多参考图。 | +| 更高级的 Token 调度策略 | ⚠️ | 当前已有基础轮询与限流刷新机制,更复杂的调度策略仍在完善中。 | +| Render / Vercel 等部署表述 | ⚠️ | 当前主要以 Docker 部署为主,其他平台部署方式暂未重点说明。 | +| `/v1/complete` 文本补全与流式输出 | ✅ | 已实现。 | +| 流式输出支持 | ✅ | 已实现。 | +| 文本补全缓存与重复请求合并 | ✅ | `/v1/chat/completions` 文本链路默认启用 60 秒短缓存、流式结果回放、in-flight 请求合并和相邻重复消息清理;可通过 `chat_completion_cache` 配置关闭或调整。 | +| 图片尺寸参数 | ❌ | 待实现。 | +| 服务端图片 URL 缓存 | ✅ | 已实现。 | +| `rt_token` 刷新 | ❌ | 待实现。 | +| 代理配置功能 | ✅ | 已支持网页端配置全局 HTTP / HTTPS / SOCKS5 / SOCKS5H 代理,并应用到出站请求。 | +| Anthropic 协议支持 | ❌ | 待实现。 | diff --git a/docs/review.md b/docs/review.md new file mode 100644 index 0000000000000000000000000000000000000000..c4e92bf2436c237f51109e00387019d5177680fa --- /dev/null +++ b/docs/review.md @@ -0,0 +1,9 @@ +# Review + +- [P2] Poll when the image tool was invoked + `services/protocol/conversation.py:586` + 对于没有输入图片的图像生成任务,延迟结果仍可能在 SSE 返回 `tool_invoked: true` 之后到达,但这里新的判断条件忽略了 `tool_invoked`。这样会直接返回中间文本,而不会继续轮询会话来拿图片 ID。 + +- [P2] Align CloudMail domain validation with the UI + `web/src/app/register/components/register-card.tsx:266` + `cloudmail_gen` 的 placeholder 写的是留空会使用服务默认域名,但后端 `create_mailbox` 不接受空域名。用户按 UI 提示保存后,这个 provider 会在真正创建地址前失败。 diff --git a/docs/upstream-sse-conversation.md b/docs/upstream-sse-conversation.md new file mode 100644 index 0000000000000000000000000000000000000000..c1ec7dce557b5a62c24dea22f16a402154f036b6 --- /dev/null +++ b/docs/upstream-sse-conversation.md @@ -0,0 +1,271 @@ +# 上游 Conversation SSE 协议说明 + +Conversation SSE 是上游对话链路的流式返回协议。每条 SSE `data:` 通常是一段 JSON payload,也可能是协议标记或结束标记。客户端需要按顺序消费这些 payload,维护当前会话状态、文本内容、工具调用状态和图片结果指针。 + +## 基本形态 + +常见 payload 示例: + +```text +"v1" +{"type":"resume_conversation_token",...} +{"p":"","o":"add","v":{...}} +{"v":{...}} +{"p":"/message/content/parts/0","o":"append","v":"..."} +{"type":"server_ste_metadata","metadata":{...}} +[DONE] +``` + +处理建议: + +| payload | 含义 | 处理方式 | +|:--|:--|:--| +| `"v1"` | 协议版本标记 | 可记录,通常不影响业务 | +| `[DONE]` | 当前 SSE 流结束 | 停止继续读取 | +| JSON object | 事件、消息或 patch | 按字段更新会话状态 | +| JSON string | 短文本 patch 或协议标记 | 结合上下文处理 | +| 非 JSON 内容 | 原始内容 | 保留为 raw 事件,避免中断流 | + +## 常用字段 + +| 字段 | 说明 | +|:--|:--| +| `type` | 上游事件类型,如 `resume_conversation_token`、`input_message`、`message_marker`、`title_generation`、`server_ste_metadata` | +| `conversation_id` | 当前会话 ID,可从多个事件中获得 | +| `p` | patch 路径,例如 `/message/content/parts/0` | +| `o` | patch 操作,例如 `add`、`append`、`replace`、`patch` | +| `v` | patch 值,可能是字符串、数组,也可能包含完整 message | +| `c` | 消息序号或游标,常见于 add 类事件 | +| `message.id` | 消息 ID | +| `message.author.role` | 消息角色,常见 `system`、`user`、`assistant`、`tool` | +| `message.content.content_type` | 内容类型,如 `text`、`multimodal_text`、`model_editable_context` | +| `message.content.parts` | 内容片段,可能包含文本、图片指针或多模态对象 | +| `message.status` | 消息状态,如 `in_progress`、`finished_successfully` | +| `message.end_turn` | 是否结束当前轮次 | +| `metadata.tool_invoked` | 本轮是否调用工具 | +| `metadata.turn_use_case` | 本轮用途,如 `text`、`multimodal` | +| `metadata.async_task_type` | 异步工具任务类型,图片生成通常为 `image_gen` | + +## 会话启动事件 + +上游通常会先返回恢复令牌或会话令牌: + +```json +{ + "type": "resume_conversation_token", + "kind": "topic", + "token": "...", + "conversation_id": "..." +} +``` + +这个事件主要用于标识会话和恢复上下文。业务层通常只需要保存 `conversation_id`,`token` 不应该暴露给下游用户。 + +## 消息 add 场景 + +完整消息可能通过 `add` 或带 `v.message` 的事件出现: + +```json +{ + "p": "", + "o": "add", + "v": { + "message": { + "author": {"role": "assistant"}, + "content": {"content_type": "text", "parts": [""]}, + "status": "in_progress" + }, + "conversation_id": "..." + }, + "c": 3 +} +``` + +此类事件常用于创建一条新消息。若消息角色为 `assistant`,后续文本通常会通过 patch 继续追加。 + +## 文本增量场景 + +文本输出通常由多条 patch 组成: + +```json +{"p":"/message/content/parts/0","o":"append","v":"Hello"} +{"v":" world"} +{"p":"","o":"patch","v":[ + {"p":"/message/content/parts/0","o":"append","v":"!"}, + {"p":"/message/status","o":"replace","v":"finished_successfully"}, + {"p":"/message/end_turn","o":"replace","v":true} +]} +``` + +处理要点: + +| 形态 | 含义 | +|:--|:--| +| `p == "/message/content/parts/0"` 且 `o == "append"` | 向当前文本追加内容 | +| `o == "replace"` | 用新值替换目标字段 | +| `o == "patch"` 且 `v` 是数组 | 批量 patch,需要按数组顺序处理 | +| 只有 `v` 且 `v` 是字符串 | 可能是省略路径的文本增量,应结合当前文本流处理 | + +## 输入消息场景 + +用户输入会以 `input_message` 或普通 `user` message 出现。图片编辑请求会包含用户上传的参考图: + +```json +{ + "type": "input_message", + "input_message": { + "author": {"role": "user"}, + "content": { + "content_type": "multimodal_text", + "parts": [ + {"asset_pointer": "sediment://file_input"}, + "编辑提示词" + ] + } + }, + "conversation_id": "..." +} +``` + +这类 `sediment://...` 表示输入附件,不是生成结果。即使它可以被下载,也不能当作输出图片返回。 + +## 图片工具成功场景 + +图片生成或图片编辑成功时,上游一般会出现工具消息: + +```json +{ + "v": { + "message": { + "author": {"role": "tool"}, + "content": { + "content_type": "multimodal_text", + "parts": [ + {"asset_pointer": "file-service://file_result"}, + {"asset_pointer": "sediment://file_result"} + ] + }, + "metadata": {"async_task_type": "image_gen"} + } + }, + "conversation_id": "..." +} +``` + +只有同时满足以下条件的图片指针,才应该视为输出结果: + +| 条件 | 说明 | +|:--|:--| +| `message.author.role == "tool"` | 来源是工具消息 | +| `metadata.async_task_type == "image_gen"` | 工具任务是图片生成 | +| `asset_pointer` 为 `file-service://...` 或 `sediment://...` | 指向可解析图片资源 | + +## 图片指针类型 + +| 指针 | 常见来源 | 说明 | +|:--|:--|:--| +| `file-service://file_xxx` | 图片工具输出 | 可通过文件下载接口解析 | +| `sediment://file_xxx` | 输入附件或图片工具输出 | 需要结合消息角色判断来源 | +| `file_upload` | 上传过程占位 | 通常不应作为输出 | + +不要只凭字符串里出现 `file_` 或 `sediment://` 就判定为输出图。必须结合消息角色和任务类型。 + +## 策略拒绝场景 + +当上游拒绝请求时,通常不会产生图片工具消息,而是返回普通 assistant 文本: + +```text +I can't assist with that request. If you have another type of modification... +``` + +常见伴随事件: + +```json +{"type":"title_generation","title":"Request Denied","conversation_id":"..."} +``` + +```json +{ + "type": "server_ste_metadata", + "metadata": { + "tool_invoked": false, + "turn_use_case": "multimodal", + "did_prompt_contain_image": true + }, + "conversation_id": "..." +} +``` + +处理要点: + +| 条件 | 行为 | +|:--|:--| +| 有 assistant 拒绝文本 | 应返回文本消息 | +| `tool_invoked == false` | 说明没有实际工具结果 | +| 没有 `role=tool` 且 `async_task_type=image_gen` 的消息 | 不应收集输出图片 | +| 用户输入消息里有图片指针 | 仍然只视为输入附件 | + +## moderation 场景 + +部分请求可能返回 moderation 事件: + +```json +{ + "type": "moderation", + "moderation_response": { + "blocked": true + }, + "conversation_id": "..." +} +``` + +若 `blocked == true`,应认为本轮被策略拦截。后续如有 assistant 文本,应优先返回该文本;若没有文本,可返回合适的错误信息。 + +## marker 和 title 事件 + +上游会返回一些辅助事件: + +```json +{"type":"message_marker","marker":"user_visible_token","event":"first"} +{"type":"message_marker","marker":"last_token","event":"last"} +{"type":"title_generation","title":"...","conversation_id":"..."} +``` + +这些事件通常用于前端展示、标题生成或流式状态标记,不代表实际文本内容或图片结果。 + +## metadata 事件 + +`server_ste_metadata` 用于描述本轮调度和工具状态: + +```json +{ + "type": "server_ste_metadata", + "metadata": { + "tool_invoked": true, + "turn_use_case": "multimodal", + "model_slug": "i-mini-m", + "did_prompt_contain_image": true + } +} +``` + +常用判断: + +| 字段 | 说明 | +|:--|:--| +| `tool_invoked == true` | 上游认为本轮调用过工具 | +| `tool_invoked == false` | 上游未调用工具,常见于拒绝或纯文本响应 | +| `turn_use_case == "text"` | 按文本响应处理 | +| `turn_use_case == "multimodal"` | 多模态请求,不代表一定有图片输出 | +| `did_prompt_contain_image == true` | 输入包含图片,不代表输出包含图片 | + +## 结束后的结果判断 + +SSE 结束后可按以下顺序判断结果: + +1. 如果已经收集到图片工具输出指针,解析并下载输出图片。 +2. 如果没有输出图片指针,但有 assistant 文本,并且本轮被拦截或未调用工具,返回文本消息。 +3. 如果没有输出图片指针,但有 `conversation_id`,可查询完整会话明细,继续寻找图片工具输出。 +4. 查询完整会话时,仍然只读取 `role=tool` 且 `async_task_type=image_gen` 的消息。 +5. 如果没有图片结果也没有文本,返回上游异常或空结果错误。 + diff --git a/main.py b/main.py new file mode 100644 index 0000000000000000000000000000000000000000..fe5205672b37940ad59c317b086072bf2a04a8f7 --- /dev/null +++ b/main.py @@ -0,0 +1,9 @@ +from __future__ import annotations + +import uvicorn +from api import create_app + +app = create_app() + +if __name__ == "__main__": + uvicorn.run(app, access_log=False, log_level="info") diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000000000000000000000000000000000000..5ae2627976627f0d0b5656268fc5918f179c62e5 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,27 @@ +[project] +name = "chatgpt2api" +version = "0.1.0" +description = "Add your description here" +readme = "README.md" +requires-python = ">=3.13" +dependencies = [ + "curl-cffi>=0.15.0", + "fastapi>=0.136.0", + "pillow>=12.2.0", + "pybase64>=1.4.3", + "python-multipart>=0.0.26", + "tiktoken>=0.12.0", + "uvicorn>=0.44.0", + "sqlalchemy>=2.0.0", + "psycopg2-binary>=2.9.0", + "gitpython>=3.1.0", +] + +[dependency-groups] +dev = [ + "httpx>=0.28.1", +] + +[[tool.uv.index]] +url = "https://mirrors.aliyun.com/pypi/simple" +default = true diff --git a/scripts/migrate_storage.py b/scripts/migrate_storage.py new file mode 100644 index 0000000000000000000000000000000000000000..32adeaff3b622e19b2555476b4d7058d46eafd13 --- /dev/null +++ b/scripts/migrate_storage.py @@ -0,0 +1,163 @@ +#!/usr/bin/env python3 +""" +存储后端数据迁移脚本 + +用法: + python scripts/migrate_storage.py --from json --to postgres + python scripts/migrate_storage.py --from postgres --to git + python scripts/migrate_storage.py --export accounts.json + python scripts/migrate_storage.py --import accounts.json +""" + +import argparse +import json +import os +import sys +from pathlib import Path + +# 添加项目根目录到 Python 路径 +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) + +DATA_DIR = Path(__file__).resolve().parents[1] / "data" + +from services.storage.factory import create_storage_backend + + +def export_to_json(output_file: str): + """导出当前存储后端的数据到 JSON 文件""" + print(f"[migrate] Exporting data to {output_file}") + DATA_DIR.mkdir(parents=True, exist_ok=True) + storage = create_storage_backend(DATA_DIR) + accounts = storage.load_accounts() + + output_path = Path(output_file) + output_path.parent.mkdir(parents=True, exist_ok=True) + output_path.write_text( + json.dumps(accounts, ensure_ascii=False, indent=2) + "\n", + encoding="utf-8", + ) + + print(f"[migrate] Exported {len(accounts)} accounts to {output_file}") + + +def import_from_json(input_file: str): + """从 JSON 文件导入数据到当前存储后端""" + print(f"[migrate] Importing data from {input_file}") + DATA_DIR.mkdir(parents=True, exist_ok=True) + input_path = Path(input_file) + if not input_path.exists(): + print(f"[migrate] Error: File not found: {input_file}") + sys.exit(1) + + try: + accounts = json.loads(input_path.read_text(encoding="utf-8")) + if not isinstance(accounts, list): + print(f"[migrate] Error: Invalid JSON format, expected array") + sys.exit(1) + except json.JSONDecodeError as e: + print(f"[migrate] Error: Invalid JSON: {e}") + sys.exit(1) + + storage = create_storage_backend(DATA_DIR) + storage.save_accounts(accounts) + + print(f"[migrate] Imported {len(accounts)} accounts") + + +def migrate_data(from_backend: str, to_backend: str): + """从一个存储后端迁移到另一个""" + print(f"[migrate] Migrating from {from_backend} to {to_backend}") + DATA_DIR.mkdir(parents=True, exist_ok=True) + # 保存原始环境变量 + original_backend = os.environ.get("STORAGE_BACKEND") + + try: + # 从源后端读取数据 + os.environ["STORAGE_BACKEND"] = from_backend + from_storage = create_storage_backend(DATA_DIR) + accounts = from_storage.load_accounts() + print(f"[migrate] Loaded {len(accounts)} accounts from {from_backend}") + + # 写入目标后端 + os.environ["STORAGE_BACKEND"] = to_backend + to_storage = create_storage_backend(DATA_DIR) + to_storage.save_accounts(accounts) + print(f"[migrate] Saved {len(accounts)} accounts to {to_backend}") + + print(f"[migrate] Migration completed successfully!") + + finally: + # 恢复原始环境变量 + if original_backend: + os.environ["STORAGE_BACKEND"] = original_backend + elif "STORAGE_BACKEND" in os.environ: + del os.environ["STORAGE_BACKEND"] + + +def main(): + parser = argparse.ArgumentParser( + description="ChatGPT2API 存储后端数据迁移工具", + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=""" +示例: + # 从 JSON 迁移到 PostgreSQL + python scripts/migrate_storage.py --from json --to postgres + + # 从 PostgreSQL 迁移到 Git + python scripts/migrate_storage.py --from postgres --to git + + # 导出当前数据到 JSON 文件 + python scripts/migrate_storage.py --export backup.json + + # 从 JSON 文件导入数据 + python scripts/migrate_storage.py --import backup.json + +环境变量: + STORAGE_BACKEND - 存储后端类型 (json, sqlite, postgres, git) + DATABASE_URL - 数据库连接字符串 + GIT_REPO_URL - Git 仓库地址 + GIT_TOKEN - Git 访问令牌 + """ + ) + + parser.add_argument( + "--from", + dest="from_backend", + choices=["json", "sqlite", "postgres", "git"], + help="源存储后端", + ) + parser.add_argument( + "--to", + dest="to_backend", + choices=["json", "sqlite", "postgres", "git"], + help="目标存储后端", + ) + parser.add_argument( + "--export", + dest="export_file", + metavar="FILE", + help="导出数据到 JSON 文件", + ) + parser.add_argument( + "--import", + dest="import_file", + metavar="FILE", + help="从 JSON 文件导入数据", + ) + + args = parser.parse_args() + + # 检查参数 + if args.from_backend and args.to_backend: + migrate_data(args.from_backend, args.to_backend) + elif args.export_file: + export_to_json(args.export_file) + elif args.import_file: + import_from_json(args.import_file) + else: + parser.print_help() + sys.exit(1) + + +if __name__ == "__main__": + main() diff --git a/scripts/test_storage.py b/scripts/test_storage.py new file mode 100644 index 0000000000000000000000000000000000000000..15455f6bd7d339afeb10415b34462f96fe31fe52 --- /dev/null +++ b/scripts/test_storage.py @@ -0,0 +1,129 @@ +#!/usr/bin/env python3 +""" +存储后端测试脚本 + +用法: + python scripts/test_storage.py +""" + +import os +import sys +from pathlib import Path + +# 添加项目根目录到 Python 路径 +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) + +DATA_DIR = Path(__file__).resolve().parents[1] / "data" + +from services.storage.factory import create_storage_backend + + +def test_storage(): + """测试当前配置的存储后端""" + DATA_DIR.mkdir(parents=True, exist_ok=True) + print("=" * 60) + print("ChatGPT2API 存储后端测试") + print("=" * 60) + + # 显示当前配置 + backend_type = os.getenv("STORAGE_BACKEND", "json") + print(f"\n当前存储后端: {backend_type}") + + if backend_type in ("sqlite", "postgres", "postgresql", "mysql", "database"): + database_url = os.getenv("DATABASE_URL", "") + if database_url: + # 隐藏密码 + if "://" in database_url and "@" in database_url: + protocol, rest = database_url.split("://", 1) + if "@" in rest: + credentials, host = rest.split("@", 1) + if ":" in credentials: + username, _ = credentials.split(":", 1) + database_url = f"{protocol}://{username}:****@{host}" + print(f"数据库连接: {database_url}") + else: + print(f"数据库连接: 本地 SQLite (data/accounts.db)") + + elif backend_type == "git": + repo_url = os.getenv("GIT_REPO_URL", "") + branch = os.getenv("GIT_BRANCH", "main") + file_path = os.getenv("GIT_FILE_PATH", "accounts.json") + print(f"Git 仓库: {repo_url}") + print(f"Git 分支: {branch}") + print(f"文件路径: {file_path}") + + print("\n" + "=" * 60) + + try: + # 创建存储后端 + print("\n[1/5] 创建存储后端...") + storage = create_storage_backend(DATA_DIR) + print("✅ 存储后端创建成功") + + # 获取后端信息 + print("\n[2/5] 获取后端信息...") + info = storage.get_backend_info() + print(f"✅ 后端类型: {info.get('type')}") + print(f" 描述: {info.get('description')}") + for key, value in info.items(): + if key not in ('type', 'description'): + print(f" {key}: {value}") + + # 健康检查 + print("\n[3/5] 执行健康检查...") + health = storage.health_check() + status = health.get("status") + if status == "healthy": + print(f"✅ 健康状态: {status}") + else: + print(f"❌ 健康状态: {status}") + print(f" 错误: {health.get('error')}") + return False + + # 读取数据 + print("\n[4/5] 读取账号数据...") + accounts = storage.load_accounts() + print(f"✅ 成功读取 {len(accounts)} 个账号") + + # 写入测试(可选) + print("\n[5/5] 测试写入功能...") + test_account = { + "access_token": "test_token_" + str(os.getpid()), + "type": "Free", + "status": "测试", + "quota": 0, + "email": "test@example.com", + } + + # 添加测试账号 + test_accounts = accounts + [test_account] + storage.save_accounts(test_accounts) + print("✅ 写入测试账号成功") + + # 验证写入 + reloaded = storage.load_accounts() + if len(reloaded) == len(test_accounts): + print("✅ 验证写入成功") + else: + print(f"❌ 验证失败: 期望 {len(test_accounts)} 个账号,实际 {len(reloaded)} 个") + return False + + # 恢复原始数据 + storage.save_accounts(accounts) + print("✅ 恢复原始数据") + + print("\n" + "=" * 60) + print("✅ 所有测试通过!") + print("=" * 60) + return True + + except Exception as e: + print(f"\n❌ 测试失败: {e}") + import traceback + traceback.print_exc() + return False + + +if __name__ == "__main__": + success = test_storage() + sys.exit(0 if success else 1) diff --git a/scripts/verify_oauth_refresh.py b/scripts/verify_oauth_refresh.py new file mode 100644 index 0000000000000000000000000000000000000000..4f2650d2facda2e08173d1b8d074d5944e277309 --- /dev/null +++ b/scripts/verify_oauth_refresh.py @@ -0,0 +1,92 @@ +"""验证 OAuth 账号的自动刷新是否生效。 + +用法(在容器内,工作目录 /app): + uv run python scripts/verify_oauth_refresh.py # 只读诊断,安全 + uv run python scripts/verify_oauth_refresh.py --force # 真正触发一次刷新 + +只读模式:列出每个账号的 access_token 剩余有效期、是否带 refresh_token、 + 上次刷新时间与上次刷新错误,判断"有没有料可刷"。 +--force :对每个带 refresh_token 的账号强制走一次 refresh_access_token(force=True), + 直接验证 refresh_token + 本项目 client_id 能否从 OpenAI 换出新 access_token。 + 这会真实轮换 access_token(新 token 有效,不损坏账号)。 +""" +from __future__ import annotations + +import sys + +from services.account_service import account_service + + +def _fmt_remaining(seconds: int | None) -> str: + if seconds is None: + return "无法解析 exp" + if seconds <= 0: + return f"已过期 {(-seconds) // 3600}h" + return f"{seconds // 3600}h{(seconds % 3600) // 60}m 后过期" + + +def diagnose() -> list[str]: + """只读:打印每个账号的刷新就绪状态,返回带 refresh_token 的 token 列表。""" + tokens = account_service.list_tokens() + print(f"账号总数: {len(tokens)}\n") + refreshable: list[str] = [] + for token in tokens: + account = account_service.get_account(token) or {} + remaining = account_service._token_expires_in(token) + has_rt = bool(str(account.get("refresh_token") or "").strip()) + if has_rt: + refreshable.append(token) + print(f"- email={account.get('email') or '(未知)'}") + print(f" access_token[:20] = {token[:20]}...") + print(f" 距过期 = {_fmt_remaining(remaining)}") + print(f" refresh_token = {'有 ✅' if has_rt else '无 ❌(无法自动刷新)'}") + print(f" last_token_refresh_at = {account.get('last_token_refresh_at')}") + print(f" last_token_refresh_error = {account.get('last_token_refresh_error')}") + print() + return refreshable + + +def force_refresh(tokens: list[str]) -> None: + """对每个账号 force 刷新一次,并对比前后状态判断成败。""" + if not tokens: + print("没有带 refresh_token 的账号,无法验证刷新。") + return + print("=" * 60) + print(f"开始对 {len(tokens)} 个账号 force 刷新(真实调用 OpenAI)...\n") + ok = 0 + for token in tokens: + before = account_service.get_account(token) or {} + new_token = account_service.refresh_access_token(token, force=True, event="manual_verify") + after = account_service.get_account(new_token) or {} + err = str(after.get("last_token_refresh_error") or "").strip() + rotated = new_token != token + success = bool(new_token) and not err + if success: + ok += 1 + print(f"- email={before.get('email') or '(未知)'}") + print(f" 旧 access_token[:20] = {token[:20]}...") + print(f" 新 access_token[:20] = {new_token[:20]}...") + print(f" token 是否轮换 = {'是' if rotated else '否(exp 未到刷新窗口时可能返回原值)'}") + print(f" last_token_refresh_at = {after.get('last_token_refresh_at')}") + print(f" last_token_refresh_error = {after.get('last_token_refresh_error') or '无'}") + print(f" >>> 刷新结果 = {'成功 ✅' if success else '失败 ❌'}") + print() + print("=" * 60) + print(f"汇总: {ok}/{len(tokens)} 个账号刷新成功") + if ok == len(tokens): + print("✅ 自动刷新机制对这些账号完全可用——refresh_token 与 client_id 匹配。") + else: + print("❌ 有账号刷新失败,看上面的 last_token_refresh_error,或 docker logs 里的 [oauth-login]/refresh 日志。") + + +def main() -> None: + do_force = "--force" in sys.argv[1:] + refreshable = diagnose() + if do_force: + force_refresh(refreshable) + else: + print("提示: 加 --force 参数可真正触发一次刷新以验证能否成功。") + + +if __name__ == "__main__": + main() diff --git a/services/__init__.py b/services/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/services/account_service.py b/services/account_service.py new file mode 100644 index 0000000000000000000000000000000000000000..b82910ddf9051abda39431999224104ecd75f560 --- /dev/null +++ b/services/account_service.py @@ -0,0 +1,1031 @@ +from __future__ import annotations + +import time +from concurrent.futures import ThreadPoolExecutor, as_completed +from datetime import datetime, timedelta, timezone +from pathlib import Path +from threading import Condition, Lock +from typing import Any + +from services.config import config +from services.log_service import ( + LOG_TYPE_ACCOUNT, + log_service, +) +from services.storage.base import StorageBackend +from utils.helper import anonymize_token + + +class AccountService: + """账号池服务,使用 token -> account 的 dict 保存账号。""" + + _NEW_ACCOUNT_INVALID_GRACE_SECONDS = 10 * 60 + _INVALID_CONFIRM_SECONDS = 30 + _ACCESS_TOKEN_REFRESH_SKEW_SECONDS = 24 * 60 * 60 + _REFRESH_TOKEN_KEEPALIVE_SECONDS = 3 * 24 * 60 * 60 + _REFRESH_TOKEN_KEEPALIVE_ERROR_BACKOFF_SECONDS = 6 * 60 * 60 + _REFRESH_TOKEN_KEEPALIVE_BATCH_SIZE = 3 + _TOKEN_REFRESH_ERROR_BACKOFF_SECONDS = 5 * 60 + _OAUTH_TOKEN_URL = "https://auth.openai.com/oauth/token" + _OAUTH_CLIENT_ID = "app_2SKx67EdpoN0G6j64rFvigXD" + _OAUTH_USER_AGENT = ( + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) " + "AppleWebKit/537.36 (KHTML, like Gecko) " + "Chrome/145.0.0.0 Safari/537.36" + ) + + def __init__(self, storage_backend: StorageBackend): + self.storage = storage_backend + self._lock = Lock() + self._token_refresh_lock = Lock() + self._image_slot_condition = Condition(self._lock) + self._index = 0 + self._accounts = self._load_accounts() + self._image_inflight: dict[str, int] = {} + self._token_aliases: dict[str, str] = {} + self._cumulative_total = self._load_cumulative_total() + + def _get_cumulative_file(self) -> Path: + from services.config import DATA_DIR + return DATA_DIR / ".cumulative_total" + + def _load_cumulative_total(self) -> int: + try: + f = self._get_cumulative_file() + if f.exists(): + return int(f.read_text().strip()) + except Exception: + pass + return len(self._accounts) + + def _save_cumulative_total(self) -> None: + try: + self._get_cumulative_file().write_text(str(self._cumulative_total)) + except Exception: + pass + + @staticmethod + def _now() -> str: + return datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S") + + @staticmethod + def _decode_jwt_payload(token: str) -> dict: + try: + payload = str(token or "").split(".")[1] + payload += "=" * ((4 - len(payload) % 4) % 4) + import base64 + import json + data = json.loads(base64.urlsafe_b64decode(payload.encode("ascii"))) + return data if isinstance(data, dict) else {} + except Exception: + return {} + + @staticmethod + def _parse_time(value: object) -> datetime | None: + raw = str(value or "").strip() + if not raw: + return None + try: + parsed = datetime.fromisoformat(raw.replace("Z", "+00:00")) + except Exception: + try: + parsed = datetime.strptime(raw, "%Y-%m-%d %H:%M:%S") + except Exception: + return None + if parsed.tzinfo is None: + parsed = parsed.replace(tzinfo=timezone.utc) + return parsed.astimezone(timezone.utc) + + @staticmethod + def _timestamp_to_iso(value: object) -> str: + try: + ts = int(value) + except (TypeError, ValueError): + return "" + tz = timezone(timedelta(hours=8)) + return datetime.fromtimestamp(ts, tz=timezone.utc).astimezone(tz).isoformat() + + def _load_accounts(self) -> dict[str, dict]: + accounts = self.storage.load_accounts() + return { + normalized["access_token"]: normalized + for item in accounts + if (normalized := self._normalize_account(item)) is not None + } + + def _save_accounts(self) -> None: + self.storage.save_accounts(list(self._accounts.values())) + + @staticmethod + def _is_image_account_available(account: dict) -> bool: + if not isinstance(account, dict): + return False + if account.get("status") in {"禁用", "限流", "异常"}: + return False + if bool(account.get("image_quota_unknown")): + return True + return int(account.get("quota") or 0) > 0 + + @classmethod + def _account_matches_plan_type(cls, account: dict, plan_type: str | None = None) -> bool: + if not plan_type: + return True + normalized_plan = cls._normalize_account_type(plan_type) + normalized_account = cls._normalize_account_type(account.get("type")) + if not normalized_plan or not normalized_account: + return False + return normalized_plan.lower() == normalized_account.lower() + + @classmethod + def _account_matches_source_type(cls, account: dict, source_type: str | None = None) -> bool: + if not source_type: + return True + return cls._normalize_source_type(account.get("source_type")) == cls._normalize_source_type(source_type) + + @classmethod + def _account_matches_any_plan_type(cls, account: dict, plan_types: set[str] | tuple[str, ...] | None = None) -> bool: + if not plan_types: + return True + normalized_account = cls._normalize_account_type(account.get("type")) + normalized_plans = { + normalized + for plan_type in plan_types + if (normalized := cls._normalize_account_type(plan_type)) + } + return bool(normalized_account and normalized_account in normalized_plans) + + @staticmethod + def _normalize_source_type(value: object) -> str: + return str(value or "web").strip().lower() or "web" + + @staticmethod + def _normalize_account_type(value: object) -> str | None: + raw = str(value or "").strip() + if not raw: + return None + key = raw.lower().replace("-", "_").replace(" ", "_") + compact = key.replace("_", "") + aliases = { + "free": "free", + "plus": "Plus", + "pro": "Pro", + "prolite": "ProLite", + "team": "Team", + "business": "Team", + "enterprise": "Enterprise", + } + return aliases.get(compact) or aliases.get(key) or raw + + def _search_account_type(self, payload: object) -> str | None: + if isinstance(payload, dict): + for key in ("plan_type", "account_plan", "account_type", "subscription_type", "type"): + plan = self._normalize_account_type(payload.get(key)) + if plan: + return plan + for value in payload.values(): + plan = self._search_account_type(value) + if plan: + return plan + elif isinstance(payload, list): + for value in payload: + plan = self._search_account_type(value) + if plan: + return plan + return None + + def _normalize_account(self, item: dict) -> dict | None: + if not isinstance(item, dict): + return None + access_token = item.get("access_token") or item.get("accessToken") or "" + if not access_token: + return None + normalized = dict(item) + normalized.pop("accessToken", None) + normalized["access_token"] = access_token + if str(normalized.get("type") or "").strip().lower() == "codex": + normalized["export_type"] = "codex" + normalized.pop("type", None) + normalized["type"] = normalized.get("type") or "free" + normalized["status"] = normalized.get("status") or "正常" + normalized["quota"] = max(0, int(normalized.get("quota") if normalized.get("quota") is not None else 0)) + normalized["image_quota_unknown"] = bool(normalized.get("image_quota_unknown")) + normalized["email"] = normalized.get("email") or None + normalized["user_id"] = normalized.get("user_id") or None + normalized["proxy"] = str(normalized.get("proxy") or "").strip() + source_type = normalized.get("source_type") + if not source_type and str(normalized.get("export_type") or "").strip().lower() == "codex": + source_type = "codex" + normalized["source_type"] = self._normalize_source_type(source_type) + limits_progress = normalized.get("limits_progress") + normalized["limits_progress"] = limits_progress if isinstance(limits_progress, list) else [] + normalized["default_model_slug"] = normalized.get("default_model_slug") or None + normalized["restore_at"] = normalized.get("restore_at") or None + normalized["success"] = int(normalized.get("success") or 0) + normalized["fail"] = int(normalized.get("fail") or 0) + normalized["invalid_count"] = int(normalized.get("invalid_count") or 0) + normalized["last_used_at"] = normalized.get("last_used_at") + normalized["last_invalid_at"] = normalized.get("last_invalid_at") or None + normalized["last_refresh_error"] = normalized.get("last_refresh_error") or None + normalized["last_refresh_error_at"] = normalized.get("last_refresh_error_at") or None + normalized["last_token_refresh_at"] = normalized.get("last_token_refresh_at") or None + normalized["last_token_refresh_error"] = normalized.get("last_token_refresh_error") or None + normalized["last_token_refresh_error_at"] = normalized.get("last_token_refresh_error_at") or None + normalized["created_at"] = normalized.get("created_at") or AccountService._now() + return normalized + + @staticmethod + def _jwt_exp(access_token: str) -> int: + try: + return int(AccountService._decode_jwt_payload(access_token).get("exp") or 0) + except (TypeError, ValueError): + return 0 + + @classmethod + def _token_expires_in(cls, access_token: str) -> int | None: + exp = cls._jwt_exp(access_token) + if exp <= 0: + return None + return exp - int(time.time()) + + @classmethod + def _token_needs_refresh(cls, access_token: str, *, force: bool = False) -> bool: + if force: + return True + remaining = cls._token_expires_in(access_token) + return remaining is not None and remaining <= cls._ACCESS_TOKEN_REFRESH_SKEW_SECONDS + + @classmethod + def _token_issued_at(cls, access_token: str) -> datetime | None: + try: + iat = int(cls._decode_jwt_payload(access_token).get("iat") or 0) + except (TypeError, ValueError): + return None + if iat <= 0: + return None + return datetime.fromtimestamp(iat, tz=timezone.utc) + + @staticmethod + def _safe_response_text(response: object, limit: int = 300) -> str: + try: + return str(getattr(response, "text", "") or "")[:limit] + except Exception: + return "" + + def _resolve_access_token_locked(self, access_token: str) -> str: + token = str(access_token or "").strip() + seen: set[str] = set() + while token and token not in self._accounts and token in self._token_aliases and token not in seen: + seen.add(token) + token = self._token_aliases.get(token, token) + return token + + def resolve_access_token(self, access_token: str) -> str: + if not access_token: + return "" + with self._lock: + return self._resolve_access_token_locked(access_token) + + def _get_account_for_token(self, access_token: str) -> tuple[str, dict | None]: + with self._lock: + resolved = self._resolve_access_token_locked(access_token) + account = self._accounts.get(resolved) + return resolved, dict(account) if account else None + + def _record_token_refresh_error(self, access_token: str, event: str, error: str) -> None: + now = datetime.now(timezone.utc).isoformat() + with self._lock: + resolved = self._resolve_access_token_locked(access_token) + current = self._accounts.get(resolved) + if current is None: + return + next_item = dict(current) + next_item["last_token_refresh_error"] = str(error or "refresh token failed") + next_item["last_token_refresh_error_at"] = now + account = self._normalize_account(next_item) + if account is not None: + self._accounts[resolved] = account + self._save_accounts() + log_service.add( + LOG_TYPE_ACCOUNT, + "refresh_token 刷新 access_token 失败", + {"source": event, "token": anonymize_token(access_token), "error": str(error or "")}, + ) + + def _recent_token_refresh_error(self, account: dict) -> bool: + last_error_at = self._parse_time(account.get("last_token_refresh_error_at")) + if last_error_at is None: + return False + return (datetime.now(timezone.utc) - last_error_at).total_seconds() < self._TOKEN_REFRESH_ERROR_BACKOFF_SECONDS + + def _recent_refresh_token_keepalive_error(self, account: dict, now: datetime) -> bool: + last_error_at = self._parse_time(account.get("last_token_refresh_error_at")) + if last_error_at is None: + return False + return (now - last_error_at).total_seconds() < self._REFRESH_TOKEN_KEEPALIVE_ERROR_BACKOFF_SECONDS + + def _refresh_token_keepalive_anchor(self, account: dict) -> datetime | None: + return ( + self._parse_time(account.get("last_token_refresh_at")) + or self._token_issued_at(str(account.get("access_token") or "")) + or self._parse_time(account.get("created_at")) + ) + + def _refresh_token_keepalive_due_at(self, account: dict, now: datetime) -> datetime | None: + if not str(account.get("refresh_token") or "").strip(): + return None + if account.get("status") == "禁用": + return None + if self._recent_refresh_token_keepalive_error(account, now): + return None + anchor = self._refresh_token_keepalive_anchor(account) + if anchor is None: + return now + due_at = anchor + timedelta(seconds=self._REFRESH_TOKEN_KEEPALIVE_SECONDS) + return due_at if due_at <= now else None + + def _request_access_token_refresh(self, refresh_token: str, account: dict | None = None) -> dict[str, str]: + from curl_cffi import requests + from services.proxy_service import proxy_settings + + session = requests.Session(**proxy_settings.build_session_kwargs(account=account, impersonate="chrome", verify=True)) + try: + response = session.post( + self._OAUTH_TOKEN_URL, + headers={ + "Accept": "application/json", + "Content-Type": "application/x-www-form-urlencoded", + "User-Agent": self._OAUTH_USER_AGENT, + }, + data={ + "grant_type": "refresh_token", + "refresh_token": refresh_token, + "client_id": self._OAUTH_CLIENT_ID, + }, + timeout=60, + ) + data = response.json() if response.text else {} + if response.status_code != 200 or not isinstance(data, dict) or not data.get("access_token"): + detail = "" + if isinstance(data, dict): + detail = str(data.get("error_description") or data.get("error") or data.get("message") or "") + detail = detail or self._safe_response_text(response) + raise RuntimeError(f"oauth_refresh_http_{response.status_code}{': ' + detail if detail else ''}") + return { + "access_token": str(data.get("access_token") or "").strip(), + "refresh_token": str(data.get("refresh_token") or refresh_token).strip(), + "id_token": str(data.get("id_token") or "").strip(), + } + finally: + session.close() + + def _apply_refreshed_tokens(self, old_access_token: str, token_data: dict, event: str) -> str: + now = datetime.now(timezone.utc).isoformat() + with self._image_slot_condition: + old_token = self._resolve_access_token_locked(old_access_token) + current = self._accounts.get(old_token) + if current is None: + return old_token + new_token = str(token_data.get("access_token") or old_token).strip() + if not new_token: + return old_token + + next_item = dict(current) + next_item["access_token"] = new_token + if token_data.get("refresh_token"): + next_item["refresh_token"] = str(token_data.get("refresh_token") or "").strip() + if token_data.get("id_token"): + next_item["id_token"] = str(token_data.get("id_token") or "").strip() + next_item["last_token_refresh_at"] = now + next_item["last_token_refresh_error"] = None + next_item["last_token_refresh_error_at"] = None + next_item["invalid_count"] = 0 + next_item["last_invalid_at"] = None + next_item["last_refresh_error"] = None + next_item["last_refresh_error_at"] = None + + account = self._normalize_account(next_item) + if account is None: + return old_token + + rotated = new_token != old_token + if rotated: + self._accounts.pop(old_token, None) + self._token_aliases[old_token] = new_token + old_inflight = int(self._image_inflight.pop(old_token, 0)) + if old_inflight: + self._image_inflight[new_token] = int(self._image_inflight.get(new_token, 0)) + old_inflight + self._accounts[new_token] = account + self._save_accounts() + self._image_slot_condition.notify_all() + + log_service.add( + LOG_TYPE_ACCOUNT, + "refresh_token 已刷新 access_token", + {"source": event, "token": anonymize_token(new_token), "rotated": rotated}, + ) + return new_token + + def refresh_access_token(self, access_token: str, *, force: bool = False, event: str = "refresh_access_token") -> str: + if not access_token: + return "" + with self._token_refresh_lock: + resolved_token, account = self._get_account_for_token(access_token) + if not account: + return access_token + active_token = str(account.get("access_token") or resolved_token or access_token) + if not self._token_needs_refresh(active_token, force=force): + return active_token + refresh_token = str(account.get("refresh_token") or "").strip() + if not refresh_token: + return active_token + if not force and self._recent_token_refresh_error(account): + return active_token + try: + token_data = self._request_access_token_refresh(refresh_token, account) + except Exception as exc: + self._record_token_refresh_error(active_token, event, str(exc)) + return active_token + return self._apply_refreshed_tokens(active_token, token_data, event) + + def list_expiring_access_tokens(self) -> list[str]: + with self._lock: + return [ + token + for account in self._accounts.values() + if str(account.get("refresh_token") or "").strip() + and (token := str(account.get("access_token") or "").strip()) + and self._token_needs_refresh(token) + ] + + def list_refresh_token_keepalive_tokens(self) -> list[str]: + now = datetime.now(timezone.utc) + due_items: list[tuple[datetime, str]] = [] + with self._lock: + for account in self._accounts.values(): + due_at = self._refresh_token_keepalive_due_at(account, now) + token = str(account.get("access_token") or "").strip() + if due_at is not None and token: + due_items.append((due_at, token)) + due_items.sort(key=lambda item: item[0]) + return [token for _, token in due_items[: self._REFRESH_TOKEN_KEEPALIVE_BATCH_SIZE]] + + def keepalive_refresh_tokens(self, access_tokens: list[str]) -> dict[str, Any]: + access_tokens = list(dict.fromkeys(token for token in access_tokens if token)) + if not access_tokens: + return {"refreshed": 0, "errors": [], "items": self.list_accounts()} + + refreshed = 0 + errors = [] + for access_token in access_tokens: + before = self.resolve_access_token(access_token) + after = self.refresh_access_token(before, force=True, event="refresh_token_keepalive") + account = self.get_account(after) + if account and str(account.get("last_token_refresh_error") or "").strip(): + errors.append({ + "token": anonymize_token(before), + "error": str(account.get("last_token_refresh_error") or "refresh token failed"), + }) + continue + if account: + refreshed += 1 + + return { + "refreshed": refreshed, + "errors": errors, + "items": self.list_accounts(), + } + + def list_tokens(self) -> list[str]: + with self._lock: + return list(self._accounts) + + def _list_ready_candidate_tokens( + self, + excluded_tokens: set[str] | None = None, + plan_type: str | None = None, + source_type: str | None = None, + plan_types: set[str] | tuple[str, ...] | None = None, + ) -> list[str]: + excluded = set(excluded_tokens or set()) + return [ + token + for item in self._accounts.values() + if self._is_image_account_available(item) + and self._account_matches_plan_type(item, plan_type) + and self._account_matches_any_plan_type(item, plan_types) + and self._account_matches_source_type(item, source_type) + and (token := item.get("access_token") or "") + and token not in excluded + ] + + def _list_available_candidate_tokens( + self, + excluded_tokens: set[str] | None = None, + plan_type: str | None = None, + source_type: str | None = None, + plan_types: set[str] | tuple[str, ...] | None = None, + ) -> list[str]: + max_concurrency = max(1, int(config.image_account_concurrency or 1)) + return [ + token + for token in self._list_ready_candidate_tokens(excluded_tokens, plan_type, source_type, plan_types) + if int(self._image_inflight.get(token, 0)) < max_concurrency + ] + + def _acquire_next_candidate_token( + self, + excluded_tokens: set[str] | None = None, + plan_type: str | None = None, + source_type: str | None = None, + plan_types: set[str] | tuple[str, ...] | None = None, + ) -> str: + with self._image_slot_condition: + while True: + if not self._list_ready_candidate_tokens(excluded_tokens, plan_type, source_type, plan_types): + raise RuntimeError( + f"no available {plan_type or source_type or ''} image quota".replace(" ", " ").strip() + if plan_type or source_type else "no available image quota" + ) + tokens = self._list_available_candidate_tokens(excluded_tokens, plan_type, source_type, plan_types) + if tokens: + access_token = tokens[self._index % len(tokens)] + self._index += 1 + self._image_inflight[access_token] = int(self._image_inflight.get(access_token, 0)) + 1 + return access_token + self._image_slot_condition.wait(timeout=1.0) + + def release_image_slot(self, access_token: str) -> None: + if not access_token: + return + with self._image_slot_condition: + access_token = self._resolve_access_token_locked(access_token) + current_inflight = int(self._image_inflight.get(access_token, 0)) + if current_inflight <= 1: + self._image_inflight.pop(access_token, None) + else: + self._image_inflight[access_token] = current_inflight - 1 + self._image_slot_condition.notify_all() + + def get_available_access_token( + self, + plan_type: str | None = None, + source_type: str | None = None, + plan_types: set[str] | tuple[str, ...] | None = None, + ) -> str: + attempted_tokens: set[str] = set() + while True: + access_token = self._acquire_next_candidate_token( + excluded_tokens=attempted_tokens, + plan_type=plan_type, + source_type=source_type, + plan_types=plan_types, + ) + attempted_tokens.add(access_token) + try: + account = self.fetch_remote_info(access_token, "get_available_access_token") + except Exception: + self.release_image_slot(access_token) + continue + if ( + self._is_image_account_available(account or {}) + and self._account_matches_plan_type(account or {}, plan_type) + and self._account_matches_any_plan_type(account or {}, plan_types) + and self._account_matches_source_type(account or {}, source_type) + ): + return str((account or {}).get("access_token") or access_token) + self.release_image_slot(access_token) + + def get_text_access_token(self, excluded_tokens: set[str] | None = None) -> str: + excluded = set(excluded_tokens or set()) + with self._lock: + candidates = [ + token + for account in self._accounts.values() + if account.get("status") not in {"禁用", "异常"} + and (token := account.get("access_token") or "") + and token not in excluded + ] + if not candidates: + return "" + access_token = candidates[self._index % len(candidates)] + self._index += 1 + return self.refresh_access_token(access_token, event="get_text_access_token") or access_token + + def mark_text_used(self, access_token: str) -> None: + if not access_token: + return + with self._lock: + access_token = self._resolve_access_token_locked(access_token) + current = self._accounts.get(access_token) + if current is None: + return + next_item = dict(current) + next_item["last_used_at"] = datetime.now().strftime("%Y-%m-%d %H:%M:%S") + account = self._normalize_account(next_item) + if account is None: + return + self._accounts[access_token] = account + self._save_accounts() + + def remove_invalid_token(self, access_token: str, event: str) -> bool: + if not config.auto_remove_invalid_accounts: + self.update_account(access_token, {"status": "异常", "quota": 0}) + return False + removed = bool(self.delete_accounts([access_token])["removed"]) + if removed: + log_service.add(LOG_TYPE_ACCOUNT, "自动移除异常账号", + {"source": event, "token": anonymize_token(access_token)}) + elif access_token: + self.update_account(access_token, {"status": "异常", "quota": 0}) + return removed + + def get_account(self, access_token: str) -> dict | None: + if not access_token: + return None + with self._lock: + access_token = self._resolve_access_token_locked(access_token) + account = self._accounts.get(access_token) + return dict(account) if account else None + + def list_accounts(self) -> list[dict]: + with self._lock: + return [dict(item) for item in self._accounts.values()] + + def list_limited_tokens(self) -> list[str]: + with self._lock: + return [ + token + for item in self._accounts.values() + if item.get("status") == "限流" + and (token := item.get("access_token") or "") + ] + + @staticmethod + def _account_payload_token(item: dict) -> str: + return str(item.get("access_token") or item.get("accessToken") or "").strip() + + @staticmethod + def _prepare_account_payload(item: dict) -> dict | None: + if not isinstance(item, dict): + return None + access_token = AccountService._account_payload_token(item) + if not access_token: + return None + payload = dict(item) + payload.pop("accessToken", None) + payload["access_token"] = access_token + # CPA/Codex 导出文件里的 `type=codex` 是导出格式,不是号池套餐类型。 + if str(payload.get("type") or "").strip().lower() == "codex": + payload["export_type"] = "codex" + payload["source_type"] = "codex" + payload.pop("type", None) + if str(payload.get("export_type") or "").strip().lower() == "codex": + payload["source_type"] = "codex" + if payload.get("plan_type") and not payload.get("type"): + payload["type"] = str(payload.get("plan_type") or "").strip() + return payload + + def add_account_items(self, items: list[dict]) -> dict: + payloads = [ + payload + for item in items + if (payload := self._prepare_account_payload(item)) is not None + ] + return self._add_account_payloads(payloads) + + def add_accounts(self, tokens: list[str], source_type: str = "web") -> dict: + tokens = list(dict.fromkeys(token for token in tokens if token)) + if not tokens: + return {"added": 0, "skipped": 0, "items": self.list_accounts()} + return self._add_account_payloads([ + {"access_token": token, "source_type": self._normalize_source_type(source_type)} + for token in tokens + ]) + + def _add_account_payloads(self, payloads: list[dict]) -> dict: + deduped: dict[str, dict] = {} + for payload in payloads: + if not isinstance(payload, dict): + continue + access_token = self._account_payload_token(payload) + if not access_token: + continue + current = deduped.get(access_token, {}) + deduped[access_token] = {**current, **payload, "access_token": access_token} + + if not deduped: + return {"added": 0, "skipped": 0, "items": self.list_accounts()} + + with self._lock: + added = 0 + skipped = 0 + for access_token, payload in deduped.items(): + current = self._accounts.get(access_token) + if current is None: + added += 1 + self._cumulative_total += 1 + self._save_cumulative_total() + current = {"created_at": self._now()} + else: + skipped += 1 + incoming = dict(payload) + if not incoming.get("created_at"): + incoming.pop("created_at", None) + account = self._normalize_account( + { + **current, + **incoming, + "access_token": access_token, + "type": str(incoming.get("type") or current.get("type") or "free"), + } + ) + if account is not None: + self._accounts[access_token] = account + self._save_accounts() + items = [dict(item) for item in self._accounts.values()] + log_service.add(LOG_TYPE_ACCOUNT, f"新增 {added} 个账号,跳过 {skipped} 个", + {"added": added, "skipped": skipped}) + return {"added": added, "skipped": skipped, "items": items} + + def delete_accounts(self, tokens: list[str]) -> dict: + target_set = set(token for token in tokens if token) + if not target_set: + return {"removed": 0, "items": self.list_accounts()} + with self._lock: + target_set = {self._resolve_access_token_locked(token) for token in target_set if token} + removed = sum(self._accounts.pop(token, None) is not None for token in target_set) + for token in target_set: + self._image_inflight.pop(token, None) + self._token_aliases = { + old: new + for old, new in self._token_aliases.items() + if old not in target_set and new not in target_set + } + if removed: + if self._accounts: + self._index %= len(self._accounts) + else: + self._index = 0 + self._save_accounts() + log_service.add(LOG_TYPE_ACCOUNT, f"删除 {removed} 个账号", {"removed": removed}) + items = [dict(item) for item in self._accounts.values()] + return {"removed": removed, "items": items} + + def update_account(self, access_token: str, updates: dict) -> dict | None: + if not access_token: + return None + with self._lock: + access_token = self._resolve_access_token_locked(access_token) + current = self._accounts.get(access_token) + if current is None: + return None + account = self._normalize_account({**current, **updates, "access_token": access_token}) + if account is None: + return None + if account.get("status") == "限流" and config.auto_remove_rate_limited_accounts: + self._accounts.pop(access_token, None) + self._save_accounts() + log_service.add(LOG_TYPE_ACCOUNT, "自动移除限流账号", {"token": anonymize_token(access_token)}) + return None + self._accounts[access_token] = account + self._save_accounts() + log_service.add(LOG_TYPE_ACCOUNT, "更新账号", + {"token": anonymize_token(access_token), "status": account.get("status")}) + return dict(account) + return None + + def _record_refresh_success(self, access_token: str) -> None: + with self._lock: + access_token = self._resolve_access_token_locked(access_token) + current = self._accounts.get(access_token) + if current is None: + return + next_item = dict(current) + next_item["invalid_count"] = 0 + next_item["last_invalid_at"] = None + next_item["last_refresh_error"] = None + next_item["last_refresh_error_at"] = None + account = self._normalize_account(next_item) + if account is not None: + self._accounts[access_token] = account + + def _should_defer_invalid_token(self, account: dict | None, now: datetime) -> bool: + if not isinstance(account, dict): + return False + created_at = self._parse_time(account.get("created_at")) + if created_at is not None and (now - created_at).total_seconds() < self._NEW_ACCOUNT_INVALID_GRACE_SECONDS: + return True + last_invalid_at = self._parse_time(account.get("last_invalid_at")) + invalid_count = int(account.get("invalid_count") or 0) + if invalid_count <= 1: + return True + if last_invalid_at is not None and (now - last_invalid_at).total_seconds() < self._INVALID_CONFIRM_SECONDS: + return True + return False + + def _record_invalid_token_seen(self, access_token: str, event: str, error: str) -> bool: + now = datetime.now(timezone.utc) + with self._lock: + access_token = self._resolve_access_token_locked(access_token) + current = self._accounts.get(access_token) + if current is None: + return True + should_defer = self._should_defer_invalid_token(current, now) + next_item = dict(current) + next_item["invalid_count"] = int(next_item.get("invalid_count") or 0) + 1 + next_item["last_invalid_at"] = now.isoformat() + next_item["last_refresh_error"] = str(error or "invalid access token") + next_item["last_refresh_error_at"] = now.isoformat() + account = self._normalize_account(next_item) + if account is not None: + self._accounts[access_token] = account + self._save_accounts() + if should_defer: + log_service.add( + LOG_TYPE_ACCOUNT, + "暂缓标记异常账号", + {"source": event, "token": anonymize_token(access_token), "error": str(error or "")}, + ) + return False + return True + + def mark_image_result(self, access_token: str, success: bool) -> dict | None: + if not access_token: + return None + self.release_image_slot(access_token) + with self._lock: + access_token = self._resolve_access_token_locked(access_token) + current = self._accounts.get(access_token) + if current is None: + return None + next_item = dict(current) + next_item["last_used_at"] = datetime.now().strftime("%Y-%m-%d %H:%M:%S") + image_quota_unknown = bool(next_item.get("image_quota_unknown")) + if success: + next_item["success"] = int(next_item.get("success") or 0) + 1 + if not image_quota_unknown: + next_item["quota"] = max(0, int(next_item.get("quota") or 0) - 1) + if not image_quota_unknown and next_item["quota"] == 0: + next_item["status"] = "限流" + next_item["restore_at"] = next_item.get("restore_at") or None + elif next_item.get("status") == "限流": + next_item["status"] = "正常" + else: + next_item["fail"] = int(next_item.get("fail") or 0) + 1 + account = self._normalize_account(next_item) + if account is None: + return None + if account.get("status") == "限流" and config.auto_remove_rate_limited_accounts: + self._accounts.pop(access_token, None) + self._save_accounts() + log_service.add(LOG_TYPE_ACCOUNT, "自动移除限流账号", {"token": anonymize_token(access_token)}) + return None + self._accounts[access_token] = account + self._save_accounts() + return dict(account) + return None + + def fetch_remote_info(self, access_token: str, event: str = "fetch_remote_info") -> dict[str, Any] | None: + if not access_token: + raise ValueError("access_token is required") + + active_token = self.refresh_access_token(access_token, event=f"{event}:preflight") or access_token + try: + from services.openai_backend_api import InvalidAccessTokenError, OpenAIBackendAPI + result = OpenAIBackendAPI(active_token).get_user_info() + except InvalidAccessTokenError as exc: + refreshed_token = self.refresh_access_token(active_token, force=True, event=f"{event}:invalid_access_token") + if refreshed_token and refreshed_token != active_token: + try: + result = OpenAIBackendAPI(refreshed_token).get_user_info() + except InvalidAccessTokenError as retry_exc: + if self._record_invalid_token_seen(refreshed_token, event, str(retry_exc)): + self.remove_invalid_token(refreshed_token, event) + raise + active_token = refreshed_token + else: + if self._record_invalid_token_seen(active_token, event, str(exc)): + self.remove_invalid_token(active_token, event) + raise + self._record_refresh_success(active_token) + return self.update_account(active_token, result) + + def refresh_accounts(self, access_tokens: list[str]) -> dict[str, Any]: + access_tokens = list(dict.fromkeys(token for token in access_tokens if token)) + if not access_tokens: + return {"refreshed": 0, "errors": [], "items": self.list_accounts()} + + refreshed = 0 + errors = [] + max_workers = min(10, len(access_tokens)) + + with ThreadPoolExecutor(max_workers=max_workers) as executor: + futures = { + executor.submit(self.fetch_remote_info, token, "refresh_accounts"): token + for token in access_tokens + } + for future in as_completed(futures): + try: + account = future.result() + except Exception as exc: + errors.append({"token": anonymize_token(futures[future]), "error": str(exc)}) + continue + if account is not None: + refreshed += 1 + + return { + "refreshed": refreshed, + "errors": errors, + "items": self.list_accounts(), + } + + def build_export_items(self, access_tokens: list[str] | None = None) -> list[dict[str, str]]: + target_tokens = set(token for token in (access_tokens or []) if token) + with self._lock: + accounts = [ + dict(item) + for item in self._accounts.values() + if not target_tokens or str(item.get("access_token") or "") in target_tokens + ] + + items: list[dict[str, str]] = [] + for account in accounts: + access_token = str(account.get("access_token") or "").strip() + refresh_token = str(account.get("refresh_token") or "").strip() + id_token = str(account.get("id_token") or "").strip() + if not access_token or not refresh_token or not id_token: + continue + + access_payload = self._decode_jwt_payload(access_token) + id_payload = self._decode_jwt_payload(id_token) + auth_claim = access_payload.get("https://api.openai.com/auth") + auth_claim = auth_claim if isinstance(auth_claim, dict) else {} + profile_claim = access_payload.get("https://api.openai.com/profile") + profile_claim = profile_claim if isinstance(profile_claim, dict) else {} + + email = ( + str(account.get("email") or "").strip() + or str(profile_claim.get("email") or "").strip() + or str(id_payload.get("email") or "").strip() + ) + account_id = ( + str(account.get("account_id") or "").strip() + or str(auth_claim.get("chatgpt_account_id") or "").strip() + or str(account.get("user_id") or "").strip() + ) + item = { + "type": str(account.get("export_type") or "codex"), + "email": email, + "account_id": account_id, + "access_token": access_token, + "refresh_token": refresh_token, + "id_token": id_token, + "expired": self._timestamp_to_iso(access_payload.get("exp")), + "last_refresh": self._timestamp_to_iso(access_payload.get("iat")), + } + password = str(account.get("password") or "").strip() + if password: + item["password"] = password + items.append(item) + return items + + def get_stats(self) -> dict: + with self._lock: + items = list(self._accounts.values()) + total = len(items) + active = sum(1 for a in items if a.get("status") == "正常") + limited = sum(1 for a in items if a.get("status") == "限流") + abnormal = sum(1 for a in items if a.get("status") == "异常") + disabled = sum(1 for a in items if a.get("status") == "禁用") + total_quota = sum(max(0, int(a.get("quota") or 0)) for a in items if a.get("status") == "正常") + unlimited = sum(1 for a in items if a.get("status") == "正常" and bool(a.get("image_quota_unknown"))) + total_success = sum(int(a.get("success") or 0) for a in items) + total_fail = sum(int(a.get("fail") or 0) for a in items) + by_type = {} + for a in items: + t = a.get("type", "unknown") + by_type[t] = by_type.get(t, 0) + 1 + return { + "total": total, + "cumulative_total": self._cumulative_total, + "active": active, + "limited": limited, + "abnormal": abnormal, + "disabled": disabled, + "total_quota": total_quota, + "unlimited_quota_count": unlimited, + "total_success": total_success, + "total_fail": total_fail, + "by_type": by_type, + } + + def account_health(self) -> dict: + stats = self.get_stats() + return { + "healthy": stats["active"] > 0 or stats["unlimited_quota_count"] > 0, + "status": "ok" if stats["active"] > 0 else "degraded", + **stats, + } + + +account_service = AccountService(config.get_storage_backend()) diff --git a/services/auth_service.py b/services/auth_service.py new file mode 100644 index 0000000000000000000000000000000000000000..ef9a29bf533624b74ae0ccee8015799f12d63871 --- /dev/null +++ b/services/auth_service.py @@ -0,0 +1,253 @@ +from __future__ import annotations + +import hashlib +import hmac +import secrets +import uuid +from datetime import datetime, timezone +from threading import Lock +from typing import Literal + +from services.config import config +from services.storage.base import StorageBackend + +AuthRole = Literal["admin", "user"] + + +def _now_iso() -> str: + return datetime.now(timezone.utc).isoformat() + + +def _hash_key(value: str) -> str: + return hashlib.sha256(value.encode("utf-8")).hexdigest() + + +class AuthService: + def __init__(self, storage: StorageBackend): + self.storage = storage + self._lock = Lock() + self._items = self._load() + self._last_used_flush_at: dict[str, datetime] = {} + + @staticmethod + def _clean(value: object) -> str: + return str(value or "").strip() + + @staticmethod + def _default_name(role: object) -> str: + return "管理员密钥" if str(role or "").strip().lower() == "admin" else "普通用户" + + def _normalize_item(self, raw: object) -> dict[str, object] | None: + if not isinstance(raw, dict): + return None + role = self._clean(raw.get("role")).lower() + if role not in {"admin", "user"}: + return None + key_hash = self._clean(raw.get("key_hash")) + if not key_hash: + return None + item_id = self._clean(raw.get("id")) or uuid.uuid4().hex[:12] + name = self._clean(raw.get("name")) or self._default_name(role) + created_at = self._clean(raw.get("created_at")) or _now_iso() + last_used_at = self._clean(raw.get("last_used_at")) or None + return { + "id": item_id, + "name": name, + "role": role, + "key_hash": key_hash, + "enabled": bool(raw.get("enabled", True)), + "created_at": created_at, + "last_used_at": last_used_at, + } + + def _load(self) -> list[dict[str, object]]: + try: + items = self.storage.load_auth_keys() + except Exception: + return [] + if not isinstance(items, list): + return [] + return [normalized for item in items if (normalized := self._normalize_item(item)) is not None] + + def _save(self) -> None: + self.storage.save_auth_keys(self._items) + + def _reload_locked(self) -> None: + self._items = self._load() + + @staticmethod + def _public_item(item: dict[str, object]) -> dict[str, object]: + return { + "id": item.get("id"), + "name": item.get("name"), + "role": item.get("role"), + "enabled": bool(item.get("enabled", True)), + "created_at": item.get("created_at"), + "last_used_at": item.get("last_used_at"), + } + + def list_keys(self, role: AuthRole | None = None) -> list[dict[str, object]]: + with self._lock: + self._reload_locked() + items = [item for item in self._items if role is None or item.get("role") == role] + return [self._public_item(item) for item in items] + + def _has_key_hash_locked(self, key_hash: str, *, exclude_id: str = "") -> bool: + for item in self._items: + item_id = self._clean(item.get("id")) + if exclude_id and item_id == exclude_id: + continue + stored_hash = self._clean(item.get("key_hash")) + if stored_hash and hmac.compare_digest(stored_hash, key_hash): + return True + return False + + def _build_key_hash_locked(self, raw_key: str, *, exclude_id: str = "") -> str: + candidate = self._clean(raw_key) + if not candidate: + raise ValueError("请输入新的专用密钥") + admin_key = self._clean(config.auth_key) + if admin_key and hmac.compare_digest(candidate, admin_key): + raise ValueError("这个密钥和管理员密钥冲突了,请换一个新的密钥") + key_hash = _hash_key(candidate) + if self._has_key_hash_locked(key_hash, exclude_id=exclude_id): + raise ValueError("这个专用密钥已经存在,请换一个新的密钥") + return key_hash + + def _has_name_locked(self, name: str, *, role: AuthRole | None = None, exclude_id: str = "") -> bool: + candidate = self._clean(name) + if not candidate: + return False + for item in self._items: + item_id = self._clean(item.get("id")) + if exclude_id and item_id == exclude_id: + continue + if role is not None and item.get("role") != role: + continue + if self._clean(item.get("name")) == candidate: + return True + return False + + def _build_default_name_locked(self, role: AuthRole, *, exclude_id: str = "") -> str: + base_name = self._default_name(role) + if not self._has_name_locked(base_name, role=role, exclude_id=exclude_id): + return base_name + suffix = 2 + while True: + candidate = f"{base_name} {suffix}" + if not self._has_name_locked(candidate, role=role, exclude_id=exclude_id): + return candidate + suffix += 1 + + def _build_name_locked(self, name: str, *, role: AuthRole, exclude_id: str = "") -> str: + candidate = self._clean(name) + if not candidate: + return self._build_default_name_locked(role, exclude_id=exclude_id) + if self._has_name_locked(candidate, role=role, exclude_id=exclude_id): + raise ValueError("这个名称已经在使用中了,换一个更容易区分的名称吧") + return candidate + + def create_key(self, *, role: AuthRole, name: str = "") -> tuple[dict[str, object], str]: + with self._lock: + self._reload_locked() + normalized_name = self._build_name_locked(name, role=role) + while True: + raw_key = f"sk-{secrets.token_urlsafe(24)}" + try: + key_hash = self._build_key_hash_locked(raw_key) + break + except ValueError: + continue + item = { + "id": uuid.uuid4().hex[:12], + "name": normalized_name, + "role": role, + "key_hash": key_hash, + "enabled": True, + "created_at": _now_iso(), + "last_used_at": None, + } + self._items.append(item) + self._save() + return self._public_item(item), raw_key + + def update_key( + self, + key_id: str, + updates: dict[str, object], + *, + role: AuthRole | None = None, + ) -> dict[str, object] | None: + normalized_id = self._clean(key_id) + if not normalized_id: + return None + with self._lock: + self._reload_locked() + for index, item in enumerate(self._items): + if item.get("id") != normalized_id: + continue + if role is not None and item.get("role") != role: + return None + next_item = dict(item) + next_role = "admin" if str(next_item.get("role") or "").strip().lower() == "admin" else "user" + if "name" in updates and updates.get("name") is not None: + next_item["name"] = self._build_name_locked( + str(updates.get("name") or ""), + role=next_role, + exclude_id=normalized_id, + ) + if "enabled" in updates and updates.get("enabled") is not None: + next_item["enabled"] = bool(updates.get("enabled")) + if "key" in updates and updates.get("key") is not None: + next_item["key_hash"] = self._build_key_hash_locked(str(updates.get("key") or ""), exclude_id=normalized_id) + self._items[index] = next_item + self._save() + return self._public_item(next_item) + return None + + def delete_key(self, key_id: str, *, role: AuthRole | None = None) -> bool: + normalized_id = self._clean(key_id) + if not normalized_id: + return False + with self._lock: + self._reload_locked() + before = len(self._items) + self._items = [ + item + for item in self._items + if not (item.get("id") == normalized_id and (role is None or item.get("role") == role)) + ] + if len(self._items) == before: + return False + self._save() + return True + + def authenticate(self, raw_key: str) -> dict[str, object] | None: + candidate = self._clean(raw_key) + if not candidate: + return None + candidate_hash = _hash_key(candidate) + with self._lock: + for index, item in enumerate(self._items): + if not bool(item.get("enabled", True)): + continue + stored_hash = self._clean(item.get("key_hash")) + if not stored_hash or not hmac.compare_digest(stored_hash, candidate_hash): + continue + next_item = dict(item) + now = datetime.now(timezone.utc) + next_item["last_used_at"] = now.isoformat() + self._items[index] = next_item + item_id = self._clean(next_item.get("id")) + last_flush_at = self._last_used_flush_at.get(item_id) + if last_flush_at is None or (now - last_flush_at).total_seconds() >= 60: + try: + self._save() + self._last_used_flush_at[item_id] = now + except Exception: + pass + return self._public_item(next_item) + return None + + +auth_service = AuthService(config.get_storage_backend()) diff --git a/services/backup_service.py b/services/backup_service.py new file mode 100644 index 0000000000000000000000000000000000000000..785cb63233a40360c66199ce6375fee1eef7403f --- /dev/null +++ b/services/backup_service.py @@ -0,0 +1,673 @@ +from __future__ import annotations + +import hashlib +import hmac +import io +import json +import os +import random +import subprocess +import tarfile +import threading +from datetime import UTC, datetime +from pathlib import Path +from urllib.parse import quote, urlencode + +from curl_cffi import requests + +from services.config import BASE_DIR, CONFIG_FILE, DATA_DIR, config, load_backup_state, save_backup_state +from services.image_storage_service import IMAGE_INDEX_FILE +from services.image_tags_service import TAGS_FILE + + +def _utc_now() -> datetime: + return datetime.now(UTC) + + +def _iso_now() -> str: + return _utc_now().replace(microsecond=0).isoformat().replace("+00:00", "Z") + + +def _clean(value: object) -> str: + return str(value or "").strip() + + +def _sha256_hex(value: bytes) -> str: + return hashlib.sha256(value).hexdigest() + + +def _is_backup_object(key: object) -> bool: + name = _clean(key).rsplit("/", 1)[-1] + return name.startswith("backup-") and (name.endswith(".tar.gz") or name.endswith(".tar.gz.enc")) + + +def _hmac_sha256(key: bytes, message: str) -> bytes: + return hmac.new(key, message.encode("utf-8"), hashlib.sha256).digest() + + +def _openssl_encrypt(data: bytes, passphrase: str) -> bytes: + env = dict(os.environ) + env["CHATGPT2API_BACKUP_PASSPHRASE"] = passphrase + try: + result = subprocess.run( + [ + "openssl", + "enc", + "-aes-256-cbc", + "-pbkdf2", + "-salt", + "-md", + "sha256", + "-pass", + "env:CHATGPT2API_BACKUP_PASSPHRASE", + ], + input=data, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + check=True, + env=env, + ) + except FileNotFoundError as exc: + raise BackupError("当前环境缺少 openssl,无法执行加密备份") from exc + except subprocess.CalledProcessError as exc: + detail = (exc.stderr or b"").decode("utf-8", errors="replace").strip() + raise BackupError(f"加密备份失败:{detail or 'openssl 执行失败'}") from exc + return result.stdout + + +def _openssl_decrypt(data: bytes, passphrase: str) -> bytes: + env = dict(os.environ) + env["CHATGPT2API_BACKUP_PASSPHRASE"] = passphrase + try: + result = subprocess.run( + [ + "openssl", + "enc", + "-d", + "-aes-256-cbc", + "-pbkdf2", + "-md", + "sha256", + "-pass", + "env:CHATGPT2API_BACKUP_PASSPHRASE", + ], + input=data, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + check=True, + env=env, + ) + except FileNotFoundError as exc: + raise BackupError("当前环境缺少 openssl,无法解密备份内容") from exc + except subprocess.CalledProcessError as exc: + detail = (exc.stderr or b"").decode("utf-8", errors="replace").strip() + raise BackupError(f"解密备份失败:{detail or 'openssl 执行失败'}") from exc + return result.stdout + + +def _guess_content_type(name: str) -> str: + if name.endswith(".json"): + return "application/json" + if name.endswith(".jsonl"): + return "application/x-ndjson" + if name.endswith(".tar.gz"): + return "application/gzip" + if name.endswith(".gz"): + return "application/gzip" + return "application/octet-stream" + + +def _json_bytes(value: object) -> bytes: + return json.dumps(value, ensure_ascii=False, indent=2).encode("utf-8") + + +def _count_items(value: object) -> int: + if isinstance(value, list): + return len(value) + if isinstance(value, dict): + return len(value) + return 0 + + +class BackupError(RuntimeError): + pass + + +class CloudflareR2Client: + def __init__(self, settings: dict[str, object]) -> None: + self.account_id = _clean(settings.get("account_id")) + self.access_key_id = _clean(settings.get("access_key_id")) + self.secret_access_key = _clean(settings.get("secret_access_key")) + self.bucket = _clean(settings.get("bucket")) + self.prefix = _clean(settings.get("prefix")) or "backups" + self.session = requests.Session(impersonate="chrome", verify=True) + + def validate(self) -> None: + missing = [] + if not self.account_id: + missing.append("Account ID") + if not self.access_key_id: + missing.append("Access Key ID") + if not self.secret_access_key: + missing.append("Secret Access Key") + if not self.bucket: + missing.append("Bucket") + if missing: + raise BackupError(f"R2 配置不完整:缺少 {'、'.join(missing)}") + + @property + def endpoint(self) -> str: + return f"https://{self.account_id}.r2.cloudflarestorage.com" + + def _aws_v4_headers( + self, + method: str, + path: str, + *, + query: dict[str, str] | None = None, + body: bytes = b"", + extra_headers: dict[str, str] | None = None, + ) -> tuple[str, dict[str, str]]: + now = _utc_now() + amz_date = now.strftime("%Y%m%dT%H%M%SZ") + date_stamp = now.strftime("%Y%m%d") + encoded_query = urlencode(sorted((query or {}).items())) + payload_hash = _sha256_hex(body) + host = f"{self.account_id}.r2.cloudflarestorage.com" + headers = { + "host": host, + "x-amz-content-sha256": payload_hash, + "x-amz-date": amz_date, + } + if extra_headers: + for key, value in extra_headers.items(): + headers[key.lower()] = value.strip() + sorted_items = sorted((key.lower(), " ".join(str(value).strip().split())) for key, value in headers.items()) + canonical_headers = "".join(f"{key}:{value}\n" for key, value in sorted_items) + signed_headers = ";".join(key for key, _ in sorted_items) + canonical_request = "\n".join([ + method.upper(), + path, + encoded_query, + canonical_headers, + signed_headers, + payload_hash, + ]) + credential_scope = f"{date_stamp}/auto/s3/aws4_request" + string_to_sign = "\n".join([ + "AWS4-HMAC-SHA256", + amz_date, + credential_scope, + _sha256_hex(canonical_request.encode("utf-8")), + ]) + k_date = _hmac_sha256(("AWS4" + self.secret_access_key).encode("utf-8"), date_stamp) + k_region = hmac.new(k_date, b"auto", hashlib.sha256).digest() + k_service = hmac.new(k_region, b"s3", hashlib.sha256).digest() + k_signing = hmac.new(k_service, b"aws4_request", hashlib.sha256).digest() + signature = hmac.new(k_signing, string_to_sign.encode("utf-8"), hashlib.sha256).hexdigest() + authorization = ( + "AWS4-HMAC-SHA256 " + f"Credential={self.access_key_id}/{credential_scope}, " + f"SignedHeaders={signed_headers}, " + f"Signature={signature}" + ) + request_headers = {key: value for key, value in headers.items()} + request_headers["authorization"] = authorization + return encoded_query, request_headers + + def _request( + self, + method: str, + key: str = "", + *, + query: dict[str, str] | None = None, + body: bytes = b"", + extra_headers: dict[str, str] | None = None, + timeout: float = 60.0, + ): + object_path = f"/{self.bucket}" + if key: + object_path += f"/{quote(key.lstrip('/'), safe='/')}" + encoded_query, headers = self._aws_v4_headers(method, object_path, query=query, body=body, extra_headers=extra_headers) + url = f"{self.endpoint}{object_path}" + if encoded_query: + url += f"?{encoded_query}" + response = self.session.request(method.upper(), url, headers=headers, data=body, timeout=timeout) + return response + + def test_connection(self) -> dict[str, object]: + self.validate() + response = self._request("GET", query={"list-type": "2", "max-keys": "1"}, timeout=30.0) + if response.status_code >= 400: + raise BackupError(f"连接 R2 失败:HTTP {response.status_code}") + return {"ok": True, "status": int(response.status_code)} + + def upload_bytes(self, key: str, payload: bytes, *, content_type: str, metadata: dict[str, str] | None = None) -> dict[str, object]: + headers = {"content-type": content_type} + if metadata: + for item_key, item_value in metadata.items(): + headers[f"x-amz-meta-{item_key}"] = str(item_value) + response = self._request("PUT", key, body=payload, extra_headers=headers) + if response.status_code >= 400: + raise BackupError(f"上传备份失败:HTTP {response.status_code}") + return {"key": key, "etag": str(response.headers.get("etag") or "").strip('"')} + + def delete_object(self, key: str) -> None: + response = self._request("DELETE", key, timeout=30.0) + if response.status_code >= 400 and response.status_code != 404: + raise BackupError(f"删除备份失败:HTTP {response.status_code}") + + def download_bytes(self, key: str) -> bytes: + response = self._request("GET", key, timeout=60.0) + if response.status_code >= 400: + raise BackupError(f"读取备份失败:HTTP {response.status_code}") + return bytes(response.content or b"") + + def list_objects(self) -> list[dict[str, object]]: + items: list[dict[str, object]] = [] + continuation = "" + while True: + query = {"list-type": "2", "prefix": f"{self.prefix.rstrip('/')}/", "max-keys": "1000"} + if continuation: + query["continuation-token"] = continuation + response = self._request("GET", query=query, timeout=30.0) + if response.status_code >= 400: + raise BackupError(f"获取备份列表失败:HTTP {response.status_code}") + text = response.text + for block in text.split("")[1:]: + key = _clean(block.split("", 1)[1].split("", 1)[0]) if "" in block else "" + if not key: + continue + size_text = _clean(block.split("", 1)[1].split("", 1)[0]) if "" in block else "0" + updated = _clean(block.split("", 1)[1].split("", 1)[0]) if "" in block else "" + items.append({ + "key": key, + "size": int(size_text or 0), + "updated_at": updated, + }) + truncated = "true" in text + if not truncated: + break + if "" not in text: + break + continuation = _clean(text.split("", 1)[1].split("", 1)[0]) + if not continuation: + break + items.sort(key=lambda item: str(item.get("updated_at") or ""), reverse=True) + return items + + def close(self) -> None: + self.session.close() + + +class BackupService: + def __init__(self) -> None: + self._lock = threading.RLock() + self._stop_event = threading.Event() + self._thread: threading.Thread | None = None + self._running = False + + def start(self) -> None: + with self._lock: + if self._thread and self._thread.is_alive(): + return + self._stop_event.clear() + self._thread = threading.Thread(target=self._run, daemon=True, name="r2-backup-scheduler") + self._thread.start() + + def stop(self) -> None: + with self._lock: + self._stop_event.set() + thread = self._thread + self._thread = None + if thread and thread.is_alive(): + thread.join(timeout=2) + + def _run(self) -> None: + while not self._stop_event.is_set(): + try: + self.run_scheduled_backup_if_needed() + except Exception: + pass + self._stop_event.wait(30) + + def run_scheduled_backup_if_needed(self) -> None: + settings = config.get_backup_settings() + if not settings.get("enabled"): + return + state = self.get_status() + if state.get("running"): + return + interval_minutes = int(settings.get("interval_minutes") or 360) + last_finished_raw = _clean(state.get("last_finished_at")) + if last_finished_raw: + try: + last_finished = datetime.fromisoformat(last_finished_raw.replace("Z", "+00:00")) + elapsed = (_utc_now() - last_finished.astimezone(UTC)).total_seconds() + if elapsed < interval_minutes * 60: + return + except Exception: + pass + self.run_backup(trigger="schedule") + + def get_status(self) -> dict[str, object]: + return { + **load_backup_state(), + "running": self._running, + } + + def is_configured(self) -> bool: + settings = config.get_backup_settings() + return all([ + _clean(settings.get("account_id")), + _clean(settings.get("access_key_id")), + _clean(settings.get("secret_access_key")), + _clean(settings.get("bucket")), + ]) + + def get_settings(self) -> dict[str, object]: + settings = dict(config.get_backup_settings()) + settings["secret_access_key"] = "********" if _clean(settings.get("secret_access_key")) else "" + settings["passphrase"] = "********" if _clean(settings.get("passphrase")) else "" + return settings + + def update_settings(self, payload: dict[str, object]) -> dict[str, object]: + current = config.get_backup_settings() + merged = dict(current) + merged.update(dict(payload or {})) + if "include" in payload and isinstance(payload.get("include"), dict): + include = dict(current.get("include") or {}) + include.update(payload.get("include") or {}) + merged["include"] = include + if payload.get("secret_access_key") == "********": + merged["secret_access_key"] = current.get("secret_access_key") + if payload.get("passphrase") == "********": + merged["passphrase"] = current.get("passphrase") + updated = config.update({"backup": merged}) + return dict(updated.get("backup") or {}) + + def test_connection(self) -> dict[str, object]: + client = CloudflareR2Client(config.get_backup_settings()) + try: + return client.test_connection() + finally: + client.close() + + def list_backups(self) -> list[dict[str, object]]: + if not self.is_configured(): + return [] + client = CloudflareR2Client(config.get_backup_settings()) + try: + items = client.list_objects() + finally: + client.close() + parsed: list[dict[str, object]] = [] + for item in items: + key = _clean(item.get("key")) + name = key.rsplit("/", 1)[-1] + encrypted = name.endswith(".enc") + parsed.append({ + "key": key, + "name": name, + "size": int(item.get("size") or 0), + "updated_at": item.get("updated_at"), + "encrypted": encrypted, + }) + return parsed + + def delete_backup(self, key: str) -> None: + candidate = _clean(key) + if not candidate: + raise BackupError("备份对象 key 不能为空") + client = CloudflareR2Client(config.get_backup_settings()) + try: + client.delete_object(candidate) + finally: + client.close() + + def download_backup(self, key: str) -> dict[str, object]: + candidate = _clean(key) + if not candidate: + raise BackupError("备份对象 key 不能为空") + client = CloudflareR2Client(config.get_backup_settings()) + try: + payload = client.download_bytes(candidate) + finally: + client.close() + name = candidate.rsplit("/", 1)[-1] or "backup.bin" + if candidate.endswith(".enc"): + passphrase = _clean(config.get_backup_settings().get("passphrase")) + if not passphrase: + raise BackupError("当前未配置加密口令,无法下载并解密已加密备份") + payload = _openssl_decrypt(payload, passphrase) + if name.endswith(".enc"): + name = name[:-4] or "backup.tar.gz" + return { + "key": candidate, + "name": name, + "content_type": _guess_content_type(name), + "payload": payload, + "size": len(payload), + } + + def get_backup_detail(self, key: str) -> dict[str, object]: + candidate = _clean(key) + if not candidate: + raise BackupError("备份对象 key 不能为空") + client = CloudflareR2Client(config.get_backup_settings()) + try: + payload = client.download_bytes(candidate) + finally: + client.close() + detail = self._decode_backup_payload(candidate, payload) + detail["key"] = candidate + detail["name"] = candidate.rsplit("/", 1)[-1] + detail["encrypted"] = candidate.endswith(".enc") + return detail + + def run_backup(self, *, trigger: str = "manual") -> dict[str, object]: + with self._lock: + current = self.get_status() + if self._running: + raise BackupError("当前已有备份任务正在执行") + started_at = _iso_now() + self._running = True + save_backup_state({ + "last_started_at": started_at, + "last_finished_at": current.get("last_finished_at"), + "last_status": "idle", + "last_error": None, + "last_object_key": current.get("last_object_key"), + }) + try: + result = self._run_backup_once(trigger=trigger) + save_backup_state({ + "last_started_at": started_at, + "last_finished_at": _iso_now(), + "last_status": "success", + "last_error": None, + "last_object_key": result["key"], + }) + return result + except Exception as exc: + save_backup_state({ + "last_started_at": started_at, + "last_finished_at": _iso_now(), + "last_status": "error", + "last_error": str(exc) or exc.__class__.__name__, + "last_object_key": current.get("last_object_key"), + }) + raise + finally: + self._running = False + + def _run_backup_once(self, *, trigger: str) -> dict[str, object]: + settings = config.get_backup_settings() + client = CloudflareR2Client(settings) + client.validate() + payload_raw = self._build_backup_archive(settings, trigger=trigger) + encrypted = bool(settings.get("encrypt")) + if encrypted: + passphrase = _clean(settings.get("passphrase")) + if not passphrase: + raise BackupError("已启用备份加密,但未设置加密口令") + payload = _openssl_encrypt(payload_raw, passphrase) + suffix = ".tar.gz.enc" + else: + payload = payload_raw + suffix = ".tar.gz" + timestamp = _utc_now().strftime("%Y%m%dT%H%M%SZ") + random_tag = f"{random.randint(0, 0xFFFF):04x}" + object_key = f"{client.prefix.rstrip('/')}/backup-{timestamp}-{random_tag}{suffix}" + metadata = { + "created-at": _iso_now(), + "encrypted": "true" if encrypted else "false", + "trigger": trigger, + } + try: + result = client.upload_bytes(object_key, payload, content_type="application/octet-stream", metadata=metadata) + self._apply_rotation(client, int(settings.get("rotation_keep") or 0)) + return { + "key": result["key"], + "size": len(payload), + "encrypted": encrypted, + } + finally: + client.close() + + def _decode_backup_payload(self, key: str, payload: bytes) -> dict[str, object]: + decoded = payload + if key.endswith(".enc"): + passphrase = _clean(config.get_backup_settings().get("passphrase")) + if not passphrase: + raise BackupError("当前未配置加密口令,无法查看已加密备份") + decoded = _openssl_decrypt(decoded, passphrase) + return self._decode_archive_detail(decoded) + + def _apply_rotation(self, client: CloudflareR2Client, keep: int) -> None: + if keep <= 0: + return + items = [item for item in client.list_objects() if _is_backup_object(item.get("key"))] + if len(items) <= keep: + return + for item in items[keep:]: + key = _clean(item.get("key")) + if key: + client.delete_object(key) + + def _decode_archive_detail(self, payload: bytes) -> dict[str, object]: + files: list[dict[str, object]] = [] + snapshots: list[dict[str, object]] = [] + metadata: dict[str, object] = {} + try: + with tarfile.open(fileobj=io.BytesIO(payload), mode="r:gz") as archive: + members = [member for member in archive.getmembers() if member.isfile()] + for member in members: + extracted = archive.extractfile(member) + if extracted is None: + continue + raw = extracted.read() + name = member.name + if name == "backup-metadata.json": + try: + parsed = json.loads(raw.decode("utf-8")) + if isinstance(parsed, dict): + metadata = parsed + except Exception: + metadata = {} + continue + if name.startswith("snapshots/") and name.endswith(".json"): + count = 0 + try: + parsed_snapshot = json.loads(raw.decode("utf-8")) + count = _count_items(parsed_snapshot) + except Exception: + count = 0 + snapshots.append({ + "name": name.removeprefix("snapshots/").removesuffix(".json"), + "count": count, + }) + continue + files.append({ + "name": name, + "exists": True, + "content_type": _guess_content_type(name), + "size": len(raw), + "sha256": _sha256_hex(raw), + }) + except tarfile.TarError as exc: + raise BackupError("解析备份压缩包失败,备份可能已损坏") from exc + files.sort(key=lambda item: str(item.get("name") or "")) + snapshots.sort(key=lambda item: str(item.get("name") or "")) + return { + "created_at": metadata.get("created_at"), + "trigger": metadata.get("trigger"), + "app_version": metadata.get("app_version"), + "storage_backend": metadata.get("storage_backend"), + "files": files, + "snapshots": snapshots, + } + + def _build_backup_archive(self, settings: dict[str, object], *, trigger: str) -> bytes: + include = settings.get("include") if isinstance(settings.get("include"), dict) else {} + metadata = { + "version": 2, + "created_at": _iso_now(), + "trigger": trigger, + "app_version": config.app_version, + "storage_backend": config.get_storage_backend().get_backend_info(), + } + buffer = io.BytesIO() + with tarfile.open(fileobj=buffer, mode="w:gz") as archive: + self._add_bytes_to_archive(archive, "backup-metadata.json", _json_bytes(metadata)) + if include.get("config"): + self._add_file_to_archive(archive, CONFIG_FILE, "config.json") + if include.get("register"): + self._add_file_to_archive(archive, DATA_DIR / "register.json", "data/register.json") + if include.get("cpa"): + self._add_file_to_archive(archive, DATA_DIR / "cpa_config.json", "data/cpa_config.json") + if include.get("sub2api"): + self._add_file_to_archive(archive, DATA_DIR / "sub2api_config.json", "data/sub2api_config.json") + if include.get("logs"): + self._add_file_to_archive(archive, DATA_DIR / "logs.jsonl", "data/logs.jsonl") + if include.get("image_tasks"): + self._add_file_to_archive(archive, DATA_DIR / "image_tasks.json", "data/image_tasks.json") + self._add_file_to_archive(archive, IMAGE_INDEX_FILE, "data/image_index.json") + if include.get("accounts_snapshot"): + self._add_bytes_to_archive( + archive, + "snapshots/accounts.json", + _json_bytes(config.get_storage_backend().load_accounts()), + ) + if include.get("auth_keys_snapshot"): + self._add_bytes_to_archive( + archive, + "snapshots/auth_keys.json", + _json_bytes(config.get_storage_backend().load_auth_keys()), + ) + if include.get("images"): + self._add_file_to_archive(archive, TAGS_FILE, "data/image_tags.json") + self._add_directory_to_archive(archive, config.images_dir, "data/images") + return buffer.getvalue() + + def _add_bytes_to_archive(self, archive: tarfile.TarFile, name: str, payload: bytes) -> None: + info = tarfile.TarInfo(name=name) + info.size = len(payload) + info.mtime = int(_utc_now().timestamp()) + archive.addfile(info, io.BytesIO(payload)) + + def _add_file_to_archive(self, archive: tarfile.TarFile, source: Path, arcname: str) -> None: + if not source.exists() or not source.is_file(): + return + archive.add(source, arcname=arcname) + + def _add_directory_to_archive(self, archive: tarfile.TarFile, source_dir: Path, arcname_root: str) -> None: + if not source_dir.exists() or not source_dir.is_dir(): + return + for path in sorted(source_dir.rglob("*")): + if path.is_file(): + relative = path.relative_to(source_dir).as_posix() + archive.add(path, arcname=f"{arcname_root}/{relative}") + + +backup_service = BackupService() diff --git a/services/config.py b/services/config.py new file mode 100644 index 0000000000000000000000000000000000000000..0d3b619863b8c5a4ade87b4bd78d83a61fb08602 --- /dev/null +++ b/services/config.py @@ -0,0 +1,446 @@ +from __future__ import annotations + +from dataclasses import dataclass +import json +import os +import sys +from pathlib import Path +import time + +from services.storage.base import StorageBackend + +BASE_DIR = Path(__file__).resolve().parents[1] +DATA_DIR = BASE_DIR / "data" +CONFIG_FILE = BASE_DIR / "config.json" +VERSION_FILE = BASE_DIR / "VERSION" +BACKUP_STATE_FILE = DATA_DIR / "backup_state.json" + +DEFAULT_BACKUP_INCLUDE = { + "config": True, + "register": True, + "cpa": True, + "sub2api": True, + "logs": True, + "image_tasks": True, + "accounts_snapshot": True, + "auth_keys_snapshot": True, + "images": False, +} + +DEFAULT_IMAGE_STORAGE = { + "enabled": False, + "mode": "local", + "webdav_url": "", + "webdav_username": "", + "webdav_password": "", + "webdav_root_path": "chatgpt2api/images", + "public_base_url": "", +} + +DEFAULT_CHAT_COMPLETION_CACHE = { + "enabled": True, + "ttl_seconds": 60, + "max_entries": 256, + "dedupe_inflight": True, + "stream_cache": True, + "normalize_messages": True, + "drop_adjacent_duplicates": True, + "drop_assistant_history": False, +} + + +def _normalize_bool(value: object, default: bool = False) -> bool: + if isinstance(value, str): + lowered = value.strip().lower() + if lowered in {"1", "true", "yes", "on"}: + return True + if lowered in {"0", "false", "no", "off"}: + return False + if value is None: + return default + return bool(value) + + +def _normalize_positive_int(value: object, default: int, minimum: int = 0) -> int: + try: + normalized = int(value) + except (TypeError, ValueError): + normalized = default + return max(minimum, normalized) + + +def _normalize_backup_include(value: object) -> dict[str, bool]: + source = value if isinstance(value, dict) else {} + normalized = dict(DEFAULT_BACKUP_INCLUDE) + for key in normalized: + normalized[key] = _normalize_bool(source.get(key), normalized[key]) + return normalized + + +def _normalize_backup_settings(value: object) -> dict[str, object]: + source = value if isinstance(value, dict) else {} + return { + "enabled": _normalize_bool(source.get("enabled"), False), + "provider": "cloudflare_r2", + "account_id": str(source.get("account_id") or "").strip(), + "access_key_id": str(source.get("access_key_id") or "").strip(), + "secret_access_key": str(source.get("secret_access_key") or "").strip(), + "bucket": str(source.get("bucket") or "").strip(), + "prefix": str(source.get("prefix") or "backups").strip().strip("/") or "backups", + "interval_minutes": _normalize_positive_int(source.get("interval_minutes"), 360, 1), + "rotation_keep": _normalize_positive_int(source.get("rotation_keep"), 10, 0), + "encrypt": _normalize_bool(source.get("encrypt"), False), + "passphrase": str(source.get("passphrase") or "").strip(), + "include": _normalize_backup_include(source.get("include")), + } + + +def _normalize_backup_state(value: object) -> dict[str, object]: + source = value if isinstance(value, dict) else {} + return { + "last_started_at": str(source.get("last_started_at") or "").strip() or None, + "last_finished_at": str(source.get("last_finished_at") or "").strip() or None, + "last_status": str(source.get("last_status") or "idle").strip() or "idle", + "last_error": str(source.get("last_error") or "").strip() or None, + "last_object_key": str(source.get("last_object_key") or "").strip() or None, + } + + +def _normalize_image_storage_settings(value: object) -> dict[str, object]: + source = value if isinstance(value, dict) else {} + mode = str(source.get("mode") or "local").strip().lower() + if mode not in {"local", "webdav", "both"}: + mode = "local" + enabled = _normalize_bool(source.get("enabled"), False) + if not enabled: + mode = "local" + root_path = str(source.get("webdav_root_path") or DEFAULT_IMAGE_STORAGE["webdav_root_path"]).strip().strip("/") + return { + "enabled": enabled, + "mode": mode, + "webdav_url": str(source.get("webdav_url") or "").strip().rstrip("/"), + "webdav_username": str(source.get("webdav_username") or "").strip(), + "webdav_password": str(source.get("webdav_password") or "").strip(), + "webdav_root_path": root_path or str(DEFAULT_IMAGE_STORAGE["webdav_root_path"]), + "public_base_url": str(source.get("public_base_url") or "").strip().rstrip("/"), + } + + +def _normalize_chat_completion_cache_settings(value: object) -> dict[str, object]: + source = value if isinstance(value, dict) else {} + return { + "enabled": _normalize_bool(source.get("enabled"), DEFAULT_CHAT_COMPLETION_CACHE["enabled"]), + "ttl_seconds": _normalize_positive_int( + source.get("ttl_seconds"), + int(DEFAULT_CHAT_COMPLETION_CACHE["ttl_seconds"]), + 0, + ), + "max_entries": _normalize_positive_int( + source.get("max_entries"), + int(DEFAULT_CHAT_COMPLETION_CACHE["max_entries"]), + 1, + ), + "dedupe_inflight": _normalize_bool( + source.get("dedupe_inflight"), + bool(DEFAULT_CHAT_COMPLETION_CACHE["dedupe_inflight"]), + ), + "stream_cache": _normalize_bool( + source.get("stream_cache"), + bool(DEFAULT_CHAT_COMPLETION_CACHE["stream_cache"]), + ), + "normalize_messages": _normalize_bool( + source.get("normalize_messages"), + bool(DEFAULT_CHAT_COMPLETION_CACHE["normalize_messages"]), + ), + "drop_adjacent_duplicates": _normalize_bool( + source.get("drop_adjacent_duplicates"), + bool(DEFAULT_CHAT_COMPLETION_CACHE["drop_adjacent_duplicates"]), + ), + "drop_assistant_history": _normalize_bool( + source.get("drop_assistant_history"), + bool(DEFAULT_CHAT_COMPLETION_CACHE["drop_assistant_history"]), + ), + } + + +def _validate_image_storage_settings(settings: dict[str, object]) -> None: + if not _normalize_bool(settings.get("enabled"), False): + return + if not str(settings.get("webdav_url") or "").strip(): + raise ValueError("启用 WebDAV 图片存储后必须填写 WebDAV URL") + if not str(settings.get("webdav_password") or "").strip(): + raise ValueError("启用 WebDAV 图片存储后必须填写 WebDAV 密码") + + +@dataclass(frozen=True) +class LoadedSettings: + auth_key: str + refresh_account_interval_minute: int + + +def _normalize_auth_key(value: object) -> str: + return str(value or "").strip() + + +def _is_invalid_auth_key(value: object) -> bool: + return _normalize_auth_key(value) == "" + + +def _read_json_object(path: Path, *, name: str) -> dict[str, object]: + if not path.exists(): + return {} + if path.is_dir(): + print( + f"Warning: {name} at '{path}' is a directory, ignoring it and falling back to other configuration sources.", + file=sys.stderr, + ) + return {} + try: + data = json.loads(path.read_text(encoding="utf-8")) + except Exception: + return {} + return data if isinstance(data, dict) else {} + + +def _load_settings() -> LoadedSettings: + DATA_DIR.mkdir(parents=True, exist_ok=True) + raw_config = _read_json_object(CONFIG_FILE, name="config.json") + auth_key = _normalize_auth_key(os.getenv("CHATGPT2API_AUTH_KEY") or raw_config.get("auth-key")) + if _is_invalid_auth_key(auth_key): + raise ValueError( + "❌ auth-key 未设置!\n" + "请在环境变量 CHATGPT2API_AUTH_KEY 中设置,或者在 config.json 中填写 auth-key。" + ) + + try: + refresh_interval = int(raw_config.get("refresh_account_interval_minute", 5)) + except (TypeError, ValueError): + refresh_interval = 5 + + return LoadedSettings( + auth_key=auth_key, + refresh_account_interval_minute=refresh_interval, + ) + + +class ConfigStore: + def __init__(self, path: Path): + self.path = path + DATA_DIR.mkdir(parents=True, exist_ok=True) + self.data = self._load() + self._storage_backend: StorageBackend | None = None + if _is_invalid_auth_key(self.auth_key): + raise ValueError( + "❌ auth-key 未设置!\n" + "请按以下任意一种方式解决:\n" + "1. 在 Render 的 Environment 变量中添加:\n" + " CHATGPT2API_AUTH_KEY = your_real_auth_key\n" + "2. 或者在 config.json 中填写:\n" + ' "auth-key": "your_real_auth_key"' + ) + + def _load(self) -> dict[str, object]: + return _read_json_object(self.path, name="config.json") + + def _save(self) -> None: + self.path.write_text(json.dumps(self.data, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") + + @property + def auth_key(self) -> str: + return _normalize_auth_key(os.getenv("CHATGPT2API_AUTH_KEY") or self.data.get("auth-key")) + + @property + def accounts_file(self) -> Path: + return DATA_DIR / "accounts.json" + + @property + def refresh_account_interval_minute(self) -> int: + try: + return int(self.data.get("refresh_account_interval_minute", 5)) + except (TypeError, ValueError): + return 5 + + @property + def image_retention_days(self) -> int: + try: + return max(1, int(self.data.get("image_retention_days", 30))) + except (TypeError, ValueError): + return 30 + + @property + def image_poll_timeout_secs(self) -> int: + try: + return max(1, int(self.data.get("image_poll_timeout_secs", 120))) + except (TypeError, ValueError): + return 120 + + @property + def image_poll_interval_secs(self) -> float: + try: + return max(0.5, float(self.data.get("image_poll_interval_secs", 10.0))) + except (TypeError, ValueError): + return 10.0 + + @property + def image_poll_initial_wait_secs(self) -> float: + """Image generation upstream takes ~30s; polling immediately wastes requests + and trips a transient 429. Default 10s gives the conversation document time + to commit before the first poll.""" + try: + return max(0.0, float(self.data.get("image_poll_initial_wait_secs", 10.0))) + except (TypeError, ValueError): + return 10.0 + + @property + def image_account_concurrency(self) -> int: + try: + return max(1, int(self.data.get("image_account_concurrency", 3))) + except (TypeError, ValueError): + return 3 + + @property + def auto_remove_invalid_accounts(self) -> bool: + value = self.data.get("auto_remove_invalid_accounts", False) + if isinstance(value, str): + return value.strip().lower() in {"1", "true", "yes", "on"} + return bool(value) + + @property + def auto_remove_rate_limited_accounts(self) -> bool: + value = self.data.get("auto_remove_rate_limited_accounts", False) + if isinstance(value, str): + return value.strip().lower() in {"1", "true", "yes", "on"} + return bool(value) + + @property + def log_levels(self) -> list[str]: + levels = self.data.get("log_levels") + if not isinstance(levels, list): + return [] + allowed = {"debug", "info", "warning", "error"} + return [level for item in levels if (level := str(item or "").strip().lower()) in allowed] + + @property + def sensitive_words(self) -> list[str]: + words = self.data.get("sensitive_words") + return [word for item in words if (word := str(item or "").strip())] if isinstance(words, list) else [] + + @property + def ai_review(self) -> dict[str, object]: + value = self.data.get("ai_review") + return value if isinstance(value, dict) else {} + + @property + def global_system_prompt(self) -> str: + return str(self.data.get("global_system_prompt") or "").strip() + + @property + def images_dir(self) -> Path: + path = DATA_DIR / "images" + path.mkdir(parents=True, exist_ok=True) + return path + + @property + def image_thumbnails_dir(self) -> Path: + path = DATA_DIR / "image_thumbnails" + path.mkdir(parents=True, exist_ok=True) + return path + + def cleanup_old_images(self) -> int: + cutoff = time.time() - self.image_retention_days * 86400 + removed = 0 + for path in self.images_dir.rglob("*"): + if path.is_file() and path.stat().st_mtime < cutoff: + path.unlink() + removed += 1 + for path in sorted((p for p in self.images_dir.rglob("*") if p.is_dir()), key=lambda p: len(p.parts), reverse=True): + try: + path.rmdir() + except OSError: + pass + return removed + + @property + def base_url(self) -> str: + return str( + os.getenv("CHATGPT2API_BASE_URL") + or self.data.get("base_url") + or "" + ).strip().rstrip("/") + + @property + def app_version(self) -> str: + try: + value = VERSION_FILE.read_text(encoding="utf-8").strip() + except FileNotFoundError: + return "0.0.0" + return value or "0.0.0" + + def get(self) -> dict[str, object]: + data = dict(self.data) + data["refresh_account_interval_minute"] = self.refresh_account_interval_minute + data["image_retention_days"] = self.image_retention_days + data["image_poll_timeout_secs"] = self.image_poll_timeout_secs + data["image_poll_interval_secs"] = self.image_poll_interval_secs + data["image_poll_initial_wait_secs"] = self.image_poll_initial_wait_secs + data["image_account_concurrency"] = self.image_account_concurrency + data["auto_remove_invalid_accounts"] = self.auto_remove_invalid_accounts + data["auto_remove_rate_limited_accounts"] = self.auto_remove_rate_limited_accounts + data["log_levels"] = self.log_levels + data["sensitive_words"] = self.sensitive_words + data["ai_review"] = self.ai_review + data["global_system_prompt"] = self.global_system_prompt + data["backup"] = self.get_backup_settings() + data["image_storage"] = self.get_image_storage_settings() + data["chat_completion_cache"] = self.get_chat_completion_cache_settings() + data.pop("auth-key", None) + return data + + def get_proxy_settings(self) -> str: + return str(self.data.get("proxy") or "").strip() + + def update(self, data: dict[str, object]) -> dict[str, object]: + next_data = dict(self.data) + next_data.update(dict(data or {})) + if "backup" in next_data: + next_data["backup"] = _normalize_backup_settings(next_data.get("backup")) + if "image_storage" in next_data: + next_data["image_storage"] = _normalize_image_storage_settings(next_data.get("image_storage")) + _validate_image_storage_settings(next_data["image_storage"]) + if "chat_completion_cache" in next_data: + next_data["chat_completion_cache"] = _normalize_chat_completion_cache_settings( + next_data.get("chat_completion_cache") + ) + next_data.pop("backup_state", None) + self.data = next_data + self._save() + return self.get() + + def get_backup_settings(self) -> dict[str, object]: + return _normalize_backup_settings(self.data.get("backup")) + + def get_image_storage_settings(self) -> dict[str, object]: + return _normalize_image_storage_settings(self.data.get("image_storage")) + + def get_chat_completion_cache_settings(self) -> dict[str, object]: + return _normalize_chat_completion_cache_settings(self.data.get("chat_completion_cache")) + + def get_storage_backend(self) -> StorageBackend: + """获取存储后端实例(单例)""" + if self._storage_backend is None: + from services.storage.factory import create_storage_backend + self._storage_backend = create_storage_backend(DATA_DIR) + return self._storage_backend + + +def load_backup_state() -> dict[str, object]: + return _normalize_backup_state(_read_json_object(BACKUP_STATE_FILE, name="backup_state.json")) + + +def save_backup_state(state: dict[str, object]) -> dict[str, object]: + normalized = _normalize_backup_state(state) + BACKUP_STATE_FILE.write_text(json.dumps(normalized, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") + return normalized + + +config = ConfigStore(CONFIG_FILE) diff --git a/services/content_filter.py b/services/content_filter.py new file mode 100644 index 0000000000000000000000000000000000000000..61f44f43e79e0c69b6b79f535d2a05825b827851 --- /dev/null +++ b/services/content_filter.py @@ -0,0 +1,196 @@ +from __future__ import annotations + +import re + +from curl_cffi import requests +from fastapi import HTTPException + +from services.config import config +from services.proxy_service import proxy_settings +from utils.log import logger + +DEFAULT_REVIEW_PROMPT = "判断用户请求是否允许。只回答 ALLOW 或 REJECT。" + +# Strip base64 image data URIs before review: a text-only review model can't +# analyze image bytes, and a single inlined image easily blows past the token +# budget of the upstream review service. +_BASE64_DATA_URI = re.compile(r"data:[\w/.+;-]+;base64,[A-Za-z0-9+/=]+") + +# Cap aligned to the upstream review service's max context. If text still +# exceeds the cap after base64 stripping, keep equal head/tail halves so both +# the system prompt and the most recent user message survive. +_MAX_REVIEW_TEXT_LEN = 100_000 +_TRUNCATION_MARKER = "\n…[truncated]…\n" + + +def _text(value: object) -> str: + if isinstance(value, str): + return value + if isinstance(value, list): + return "\n".join(_text(item) for item in value) + if isinstance(value, dict): + return "\n".join(_text(value.get(key)) for key in ("text", "input_text", "content", "input", "instructions", "system", "prompt")) + return "" + + +def request_text(*values: object) -> str: + return "\n".join(part for value in values if (part := _text(value).strip())) + + +def _sanitize_for_review(text: str) -> tuple[str, dict[str, int]]: + """Strip base64 data URIs and truncate to the review-service context limit. + + Returns (sanitized_text, stats) where stats carries base64_blocks_stripped + and truncated_chars so callers can emit structured logs. + """ + sanitized, base64_blocks_stripped = _BASE64_DATA_URI.subn("[image]", text) + truncated_chars = 0 + if len(sanitized) > _MAX_REVIEW_TEXT_LEN: + # Reserve marker space so the result stays within the cap. + half = (_MAX_REVIEW_TEXT_LEN - len(_TRUNCATION_MARKER)) // 2 + truncated_chars = len(sanitized) - 2 * half + sanitized = sanitized[:half] + _TRUNCATION_MARKER + sanitized[-half:] + stats = { + "base64_blocks_stripped": base64_blocks_stripped, + "truncated_chars": truncated_chars, + } + return sanitized, stats + + +def _extract_review_decision(data: object) -> str | None: + """Defensively pull the decision text out of the review service response. + + Returns None when the response shape doesn't match the OpenAI chat-completion + contract (e.g. {"error": ...} with no choices). The caller treats None as + "undecided" and applies the configured fail-open policy. + """ + if not isinstance(data, dict): + return None + choices = data.get("choices") + if not isinstance(choices, list) or not choices: + return None + first = choices[0] + if not isinstance(first, dict): + return None + message = first.get("message") + if not isinstance(message, dict): + return None + content = message.get("content") + if content is None: + return None + return str(content).strip().lower() + + +def _is_allow_decision(decision: str) -> bool: + return decision.startswith(("allow", "pass", "true", "yes", "通过", "允许", "安全")) + + +def _is_reject_decision(decision: str) -> bool: + return decision.startswith(("reject", "deny", "block", "false", "no", "拒绝", "不允许", "违规", "禁止")) + + +def _resolve_fail_open(review: dict) -> bool: + """Resolve fail_open from review config. Defaults to True.""" + value = review.get("fail_open") + if value is None: + return True + if isinstance(value, bool): + return value + if isinstance(value, str): + return value.strip().lower() in {"1", "true", "yes", "on"} + return bool(value) + + +def check_request(text: str) -> None: + text = str(text or "") + if not text.strip(): + return + # Local sensitive-word match runs on the raw text (cheap, no network). + for word in config.sensitive_words: + if word in text: + raise HTTPException(status_code=400, detail={"error": "检测到敏感词,拒绝本次任务"}) + review = config.ai_review + if not review.get("enabled"): + return + base_url = str(review.get("base_url") or "").strip().rstrip("/") + api_key = str(review.get("api_key") or "").strip() + model = str(review.get("model") or "").strip() + if not base_url or not api_key or not model: + raise HTTPException(status_code=400, detail={"error": "ai review config is incomplete"}) + + fail_open = _resolve_fail_open(review) + + review_text, sanitize_stats = _sanitize_for_review(text) + if sanitize_stats["base64_blocks_stripped"] or sanitize_stats["truncated_chars"]: + logger.info({ + "event": "ai_review_text_sanitized", + "original_text_len": len(text), + "review_text_len": len(review_text), + **sanitize_stats, + }) + prompt = str(review.get("prompt") or DEFAULT_REVIEW_PROMPT).strip() + content = f"{prompt}\n\n用户请求:\n{review_text}\n\n只回答 ALLOW 或 REJECT。" + + # fail_open=True (default): on upstream failure or ambiguous reply, let the + # request through. The review is a soft safety net; one missed review is + # preferable to a 5xx storm when the review service is flaky. Set + # config.ai_review.fail_open=false for strict-compliance deployments. + def _on_failure(event_payload: dict) -> None: + logger.warning(event_payload) + if not fail_open: + raise HTTPException( + status_code=503, + detail={"error": "AI 审核服务暂时不可用,请稍后重试"}, + ) + + try: + response = requests.post( + f"{base_url}/v1/chat/completions", + headers={"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"}, + json={"model": model, "messages": [{"role": "user", "content": content}], "temperature": 0}, + timeout=60, + **proxy_settings.build_session_kwargs(), + ) + except Exception as exc: + _on_failure({ + "event": "ai_review_request_failed", + "error": str(exc), + "error_type": exc.__class__.__name__, + "review_text_len": len(review_text), + "original_text_len": len(text), + }) + return + + try: + data = response.json() + except Exception as exc: + _on_failure({ + "event": "ai_review_response_not_json", + "status_code": response.status_code, + "body_preview": str(response.text or "")[:200], + "error": str(exc), + }) + return + + decision = _extract_review_decision(data) + if decision is None: + _on_failure({ + "event": "ai_review_malformed_response", + "status_code": response.status_code, + "body_preview": str(data)[:300], + "review_text_len": len(review_text), + "original_text_len": len(text), + }) + return + + if _is_allow_decision(decision): + return + if _is_reject_decision(decision): + raise HTTPException(status_code=400, detail={"error": "AI 审核未通过,拒绝本次任务"}) + # Ambiguous decisions (e.g. "MAYBE", empty content) fall back to fail-open policy. + _on_failure({ + "event": "ai_review_ambiguous_decision", + "decision": decision[:100], + "review_text_len": len(review_text), + }) + return diff --git a/services/cpa_service.py b/services/cpa_service.py new file mode 100644 index 0000000000000000000000000000000000000000..2326426d1e50d2810318ae2653d4bfdc6ed8ac12 --- /dev/null +++ b/services/cpa_service.py @@ -0,0 +1,315 @@ +"""CLIProxyAPI integration for browsing remote auth files and importing selected tokens.""" + +from __future__ import annotations + +import json +import threading +import uuid +from concurrent.futures import ThreadPoolExecutor, as_completed +from datetime import datetime, timezone +from pathlib import Path +from threading import Lock + +from curl_cffi.requests import Session + +from services.account_service import account_service +from services.config import DATA_DIR +from services.proxy_service import proxy_settings + + +CPA_CONFIG_FILE = DATA_DIR / "cpa_config.json" + + +def _new_id() -> str: + return uuid.uuid4().hex[:12] + + +def _now_iso() -> str: + return datetime.now(timezone.utc).isoformat() + + +def _normalize_import_job(raw: object, *, fail_unfinished: bool) -> dict | None: + if not isinstance(raw, dict): + return None + status = str(raw.get("status") or "failed").strip() or "failed" + if fail_unfinished and status in {"pending", "running"}: + status = "failed" + return { + "job_id": str(raw.get("job_id") or uuid.uuid4().hex).strip(), + "status": status, + "created_at": str(raw.get("created_at") or _now_iso()).strip() or _now_iso(), + "updated_at": str(raw.get("updated_at") or raw.get("created_at") or _now_iso()).strip() or _now_iso(), + "total": int(raw.get("total") or 0), + "completed": int(raw.get("completed") or 0), + "added": int(raw.get("added") or 0), + "skipped": int(raw.get("skipped") or 0), + "refreshed": int(raw.get("refreshed") or 0), + "failed": int(raw.get("failed") or 0), + "errors": raw.get("errors") if isinstance(raw.get("errors"), list) else [], + } + + +def _normalize_pool(raw: dict) -> dict: + return { + "id": str(raw.get("id") or _new_id()).strip(), + "name": str(raw.get("name") or "").strip(), + "base_url": str(raw.get("base_url") or "").strip(), + "secret_key": str(raw.get("secret_key") or "").strip(), + "import_job": _normalize_import_job(raw.get("import_job"), fail_unfinished=True), + } + + +def _management_headers(secret_key: str) -> dict[str, str]: + return { + "Authorization": f"Bearer {secret_key}", + "Accept": "application/json", + } + + +class CPAConfig: + def __init__(self, store_file: Path): + self._store_file = store_file + self._lock = Lock() + self._pools: list[dict] = self._load() + + def _load(self) -> list[dict]: + if not self._store_file.exists(): + return [] + try: + raw = json.loads(self._store_file.read_text(encoding="utf-8")) + if isinstance(raw, dict) and "base_url" in raw: + pool = _normalize_pool(raw) + return [pool] if pool["base_url"] else [] + if isinstance(raw, list): + return [_normalize_pool(item) for item in raw if isinstance(item, dict)] + except Exception: + pass + return [] + + def _save(self) -> None: + self._store_file.parent.mkdir(parents=True, exist_ok=True) + self._store_file.write_text(json.dumps(self._pools, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") + + def list_pools(self) -> list[dict]: + with self._lock: + return [dict(pool) for pool in self._pools] + + def get_pool(self, pool_id: str) -> dict | None: + with self._lock: + for pool in self._pools: + if pool["id"] == pool_id: + return dict(pool) + return None + + def add_pool(self, name: str, base_url: str, secret_key: str) -> dict: + pool = _normalize_pool({"id": _new_id(), "name": name, "base_url": base_url, "secret_key": secret_key}) + with self._lock: + self._pools.append(pool) + self._save() + return dict(pool) + + def update_pool(self, pool_id: str, updates: dict) -> dict | None: + with self._lock: + for index, pool in enumerate(self._pools): + if pool["id"] != pool_id: + continue + merged = {**pool, **{key: value for key, value in updates.items() if value is not None}, "id": pool_id} + self._pools[index] = _normalize_pool(merged) + self._save() + return dict(self._pools[index]) + return None + + def delete_pool(self, pool_id: str) -> bool: + with self._lock: + before = len(self._pools) + self._pools = [pool for pool in self._pools if pool["id"] != pool_id] + if len(self._pools) < before: + self._save() + return True + return False + + def set_import_job(self, pool_id: str, import_job: dict | None) -> dict | None: + with self._lock: + for index, pool in enumerate(self._pools): + if pool["id"] != pool_id: + continue + next_pool = dict(pool) + next_pool["import_job"] = _normalize_import_job(import_job, fail_unfinished=False) + self._pools[index] = next_pool + self._save() + return dict(next_pool) + return None + + def get_import_job(self, pool_id: str) -> dict | None: + with self._lock: + for pool in self._pools: + if pool["id"] == pool_id: + job = pool.get("import_job") + return dict(job) if isinstance(job, dict) else None + return None + + +def list_remote_files(pool: dict) -> list[dict]: + base_url = str(pool.get("base_url") or "").strip() + secret_key = str(pool.get("secret_key") or "").strip() + if not base_url or not secret_key: + return [] + + url = f"{base_url.rstrip('/')}/v0/management/auth-files" + session = Session(**proxy_settings.build_session_kwargs(verify=True)) + try: + response = session.get(url, headers=_management_headers(secret_key), timeout=30) + if not response.ok: + raise RuntimeError(f"remote list failed: HTTP {response.status_code}") + payload = response.json() + finally: + session.close() + + files = payload.get("files") if isinstance(payload, dict) else None + if not isinstance(files, list): + raise RuntimeError("remote list payload is invalid") + + items: list[dict] = [] + for item in files: + if not isinstance(item, dict): + continue + name = str(item.get("name") or "").strip() + email = str(item.get("email") or item.get("account") or "").strip() + if not name: + continue + items.append({"name": name, "email": email}) + return items + + +def fetch_remote_access_token(pool: dict, file_name: str) -> tuple[str | None, str | None]: + base_url = str(pool.get("base_url") or "").strip() + secret_key = str(pool.get("secret_key") or "").strip() + file_name = str(file_name or "").strip() + if not base_url or not secret_key or not file_name: + return None, "invalid request" + + url = f"{base_url.rstrip('/')}/v0/management/auth-files/download" + session = Session(**proxy_settings.build_session_kwargs(verify=True)) + try: + response = session.get(url, headers=_management_headers(secret_key), params={"name": file_name}, timeout=30) + if not response.ok: + return None, f"HTTP {response.status_code}" + payload = response.json() + except Exception as exc: + return None, str(exc) + finally: + session.close() + + if not isinstance(payload, dict): + return None, "invalid payload" + + access_token = str(payload.get("access_token") or "").strip() + if not access_token: + return None, "missing access_token" + return access_token, None + + +class CPAImportService: + def __init__(self, cpa_config: CPAConfig): + self._config = cpa_config + + def start_import(self, pool: dict, selected_files: list[str]) -> dict: + names = [str(name or "").strip() for name in selected_files if str(name or "").strip()] + if not names: + raise ValueError("selected files is required") + + pool_id = str(pool.get("id") or "").strip() + job = { + "job_id": uuid.uuid4().hex, + "status": "pending", + "created_at": _now_iso(), + "updated_at": _now_iso(), + "total": len(names), + "completed": 0, + "added": 0, + "skipped": 0, + "refreshed": 0, + "failed": 0, + "errors": [], + } + saved_pool = self._config.set_import_job(pool_id, job) + if saved_pool is None: + raise ValueError("pool not found") + + thread = threading.Thread( + target=self._run_import, + args=(pool_id, pool, names), + name=f"cpa-import-{pool_id}", + daemon=True, + ) + thread.start() + return dict(saved_pool.get("import_job") or job) + + def _update_job(self, pool_id: str, **updates) -> dict | None: + current = self._config.get_import_job(pool_id) + if current is None: + return None + next_job = {**current, **updates, "updated_at": _now_iso()} + pool = self._config.set_import_job(pool_id, next_job) + if pool is None: + return None + job = pool.get("import_job") + return dict(job) if isinstance(job, dict) else None + + def _append_error(self, pool_id: str, file_name: str, message: str) -> None: + current = self._config.get_import_job(pool_id) + if current is None: + return + errors = list(current.get("errors") or []) + errors.append({"name": file_name, "error": message}) + self._update_job(pool_id, errors=errors, failed=len(errors)) + + def _run_import(self, pool_id: str, pool: dict, names: list[str]) -> None: + self._update_job(pool_id, status="running") + + tokens: list[str] = [] + max_workers = min(16, max(1, len(names))) + with ThreadPoolExecutor(max_workers=max_workers) as executor: + future_map = {executor.submit(fetch_remote_access_token, pool, name): name for name in names} + for future in as_completed(future_map): + file_name = future_map[future] + try: + token, error = future.result() + except Exception as exc: + token, error = None, str(exc) + + if token: + tokens.append(token) + else: + self._append_error(pool_id, file_name, error or "unknown error") + + current = self._config.get_import_job(pool_id) or {} + failed = len(current.get("errors") or []) + self._update_job(pool_id, completed=int(current.get("completed") or 0) + 1, failed=failed) + + if not tokens: + current = self._config.get_import_job(pool_id) or {} + self._update_job( + pool_id, + status="failed", + completed=int(current.get("total") or 0), + failed=len(current.get("errors") or []), + ) + return + + add_result = account_service.add_accounts(tokens, source_type="codex") + refresh_result = account_service.refresh_accounts(tokens) + current = self._config.get_import_job(pool_id) or {} + self._update_job( + pool_id, + status="completed", + completed=len(names), + added=int(add_result.get("added") or 0), + skipped=int(add_result.get("skipped") or 0), + refreshed=int(refresh_result.get("refreshed") or 0), + failed=len(current.get("errors") or []), + ) + + +cpa_config = CPAConfig(CPA_CONFIG_FILE) +cpa_import_service = CPAImportService(cpa_config) diff --git a/services/image_service.py b/services/image_service.py new file mode 100644 index 0000000000000000000000000000000000000000..016b3059aa82bf4ea46ca0e91ae8410e2449f26f --- /dev/null +++ b/services/image_service.py @@ -0,0 +1,356 @@ +from __future__ import annotations + +import io +import shutil +import threading +import time +import zipfile +from pathlib import Path + +from fastapi import HTTPException +from fastapi.responses import FileResponse, Response +from PIL import Image, ImageOps + +from services.config import config +from services.image_storage_service import image_storage_service +from services.image_tags_service import load_tags, remove_tags +from utils.log import logger + +THUMBNAIL_SIZE = (320, 320) + + +def _cleanup_empty_dirs(root: Path) -> None: + for path in sorted((p for p in root.rglob("*") if p.is_dir()), key=lambda p: len(p.parts), reverse=True): + try: + path.rmdir() + except OSError: + pass + + +def _safe_relative_path(path: str) -> str: + value = str(path or "").strip().replace("\\", "/").lstrip("/") + if not value: + raise HTTPException(status_code=404, detail="image not found") + parts = Path(value).parts + if any(part in {"", ".", ".."} for part in parts): + raise HTTPException(status_code=404, detail="image not found") + return Path(*parts).as_posix() + + +def _safe_image_path(relative_path: str) -> Path: + rel = _safe_relative_path(relative_path) + root = config.images_dir.resolve() + path = (root / rel).resolve() + try: + path.relative_to(root) + except ValueError as exc: + raise HTTPException(status_code=404, detail="image not found") from exc + if not path.is_file(): + raise HTTPException(status_code=404, detail="image not found") + return path + + +def get_image_response(relative_path: str) -> FileResponse | Response: + if image_storage_service.has_local(relative_path): + return FileResponse(_safe_image_path(relative_path)) + return Response(content=image_storage_service.get_bytes(relative_path), media_type="image/png") + + +def _thumbnail_path(relative_path: str) -> Path: + rel = _safe_relative_path(relative_path) + return config.image_thumbnails_dir / f"{rel}.png" + + +def thumbnail_url(base_url: str, relative_path: str) -> str: + return f"{base_url.rstrip('/')}/image-thumbnails/{_safe_relative_path(relative_path)}" + + +def _image_dimensions(path: Path) -> tuple[int, int] | None: + try: + with Image.open(path) as image: + return image.size + except Exception: + return None + + +def ensure_thumbnail(relative_path: str) -> Path: + target = _thumbnail_path(relative_path) + source_mtime = 0.0 + source: Path | None = None + if image_storage_service.has_local(relative_path): + source = _safe_image_path(relative_path) + source_mtime = source.stat().st_mtime + if target.exists() and (not source_mtime or target.stat().st_mtime >= source_mtime): + return target + + target.parent.mkdir(parents=True, exist_ok=True) + try: + image_source = source if source is not None else io.BytesIO(image_storage_service.get_bytes(relative_path)) + with Image.open(image_source) as image: + image = ImageOps.exif_transpose(image) + if image.mode not in {"RGB", "RGBA"}: + image = image.convert("RGBA" if "A" in image.getbands() else "RGB") + image.thumbnail(THUMBNAIL_SIZE, Image.Resampling.LANCZOS) + image.save(target, format="PNG", optimize=True) + except HTTPException: + raise + except Exception as exc: + raise HTTPException(status_code=422, detail="failed to create thumbnail") from exc + return target + + +def get_thumbnail_response(relative_path: str) -> FileResponse: + return FileResponse(ensure_thumbnail(relative_path)) + + +def get_image_download_response(relative_path: str) -> FileResponse: + if image_storage_service.has_local(relative_path): + path = _safe_image_path(relative_path) + return FileResponse(path, filename=path.name) + rel = _safe_relative_path(relative_path) + return Response( + content=image_storage_service.get_bytes(rel), + media_type="image/png", + headers={"Content-Disposition": f'attachment; filename="{Path(rel).name}"'}, + ) + + +def cleanup_image_thumbnails() -> int: + thumbnails_root = config.image_thumbnails_dir + removed = 0 + for path in thumbnails_root.rglob("*"): + if not path.is_file(): + continue + rel = path.relative_to(thumbnails_root).as_posix() + if not rel.endswith(".png") or not image_storage_service.exists(rel[:-4]): + path.unlink() + removed += 1 + _cleanup_empty_dirs(thumbnails_root) + return removed + +def list_images(base_url: str, start_date: str = "", end_date: str = "") -> dict[str, object]: + config.cleanup_old_images() + cleanup_image_thumbnails() + all_tags = load_tags() + items = [ + { + **item, + "url": str(item.get("url") or f"{base_url.rstrip('/')}/images/{item['path']}"), + "thumbnail_url": thumbnail_url(base_url, str(item["path"])), + "tags": all_tags.get(str(item["path"]), []), + } + for item in image_storage_service.list_items(base_url, start_date, end_date) + ] + groups: dict[str, list[dict[str, object]]] = {} + for item in items: + groups.setdefault(str(item["date"]), []).append(item) + return {"items": items, "groups": [{"date": key, "items": value} for key, value in groups.items()]} + + +def delete_images(paths: list[str] | None = None, start_date: str = "", end_date: str = "", all_matching: bool = False) -> dict[str, int]: + root = config.images_dir.resolve() + targets = [ + str(item["path"]) + for item in image_storage_service.list_items("", start_date=start_date, end_date=end_date) + ] if all_matching else (paths or []) + removed = 0 + for item in targets: + path = (root / item).resolve() + try: + path.relative_to(root) + except ValueError: + continue + if image_storage_service.delete(item): + removed += 1 + for thumbnail in (_thumbnail_path(item), config.image_thumbnails_dir / _safe_relative_path(item)): + if thumbnail.is_file(): + thumbnail.unlink() + remove_tags(item) + _cleanup_empty_dirs(root) + _cleanup_empty_dirs(config.image_thumbnails_dir) + return {"removed": removed} + + +def download_images_zip(paths: list[str]) -> io.BytesIO: + root = config.images_dir.resolve() + buf = io.BytesIO() + added = 0 + used_names: set[str] = set() + with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zf: + for item in paths: + rel = _safe_relative_path(item) + path = (root / rel).resolve() + payload: bytes | None = None + try: + path.relative_to(root) + except ValueError: + continue + if path.is_file(): + payload = path.read_bytes() + else: + try: + payload = image_storage_service.get_bytes(rel) + except Exception: + continue + name = path.name + if name in used_names: + stem = path.stem + suffix = path.suffix + counter = 2 + while f"{stem}_{counter}{suffix}" in used_names: + counter += 1 + name = f"{stem}_{counter}{suffix}" + used_names.add(name) + zf.writestr(name, payload) + added += 1 + if added == 0: + raise HTTPException(status_code=404, detail="no images found") + buf.seek(0) + return buf +def storage_stats() -> dict: + import shutil + usage = shutil.disk_usage(config.images_dir) + total_mb = usage.total // (1024 * 1024) + used_mb = usage.used // (1024 * 1024) + free_mb = usage.free // (1024 * 1024) + + image_count = 0 + image_size = 0 + for p in config.images_dir.rglob("*"): + if p.is_file(): + image_count += 1 + image_size += p.stat().st_size + + return { + "disk_total_mb": total_mb, + "disk_used_mb": used_mb, + "disk_free_mb": free_mb, + "image_count": image_count, + "image_size_mb": image_size // (1024 * 1024), + "image_size_bytes": image_size, + } + + +def compress_images(quality: int = 60) -> dict: + """重新压缩所有图片,返回节省的空间""" + saved = 0 + count = 0 + for p in sorted(config.images_dir.rglob("*.png")): + if not p.is_file(): + continue + try: + orig = p.stat().st_size + with Image.open(p) as img: + img = ImageOps.exif_transpose(img) + img.save(str(p) + ".tmp", format="PNG", optimize=True) + new_size = Path(str(p) + ".tmp").stat().st_size + if new_size < orig: + Path(str(p) + ".tmp").replace(p) + saved += orig - new_size + count += 1 + else: + Path(str(p) + ".tmp").unlink() + except Exception: + pass + return {"compressed": count, "saved_bytes": saved, "saved_mb": saved // (1024 * 1024)} + + +def delete_to_target(target_free_mb: int, dry_run: bool = False) -> dict: + """删除最旧的图片直到剩余空间达到 target_free_mb""" + import shutil + usage = shutil.disk_usage(config.images_dir) + current_free = usage.free // (1024 * 1024) + if current_free >= target_free_mb and not dry_run: + return {"removed": 0, "current_free_mb": current_free, "target_free_mb": target_free_mb, "done": True} + + files = sorted( + (p for p in config.images_dir.rglob("*.png") if p.is_file()), + key=lambda p: p.stat().st_mtime, + ) + removed = 0 + freed = 0 + for p in files: + if current_free + freed // (1024 * 1024) >= target_free_mb: + break + size = p.stat().st_size + if not dry_run: + rel = p.relative_to(config.images_dir).as_posix() + for tp in (_thumbnail_path(rel), config.image_thumbnails_dir / _safe_relative_path(rel)): + if tp.is_file(): + tp.unlink() + remove_tags(rel) + p.unlink() + freed += size + removed += 1 + + if not dry_run: + _cleanup_empty_dirs(config.images_dir) + _cleanup_empty_dirs(config.image_thumbnails_dir) + + return { + "removed": removed, + "freed_mb": freed // (1024 * 1024), + "target_free_mb": target_free_mb, + "current_free_mb": current_free + (freed // (1024 * 1024)), + "done": (current_free + freed // (1024 * 1024)) >= target_free_mb, + "dry_run": dry_run, + } + + +def download_images_zip(paths: list[str]) -> io.BytesIO: + root = config.images_dir.resolve() + buf = io.BytesIO() + added = 0 + used_names: set[str] = set() + with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zf: + for item in paths: + rel = _safe_relative_path(item) + path = (root / rel).resolve() + try: + path.relative_to(root) + except ValueError: + continue + if not path.is_file(): + continue + name = path.name + if name in used_names: + stem = path.stem + suffix = path.suffix + counter = 2 + while f"{stem}_{counter}{suffix}" in used_names: + counter += 1 + name = f"{stem}_{counter}{suffix}" + used_names.add(name) + zf.write(path, name) + added += 1 + if added == 0: + raise HTTPException(status_code=404, detail="no images found") + buf.seek(0) + return buf + + +def _auto_cleanup_worker(stop_event: threading.Event) -> None: + """后台线程:每30分钟检查存储,空间低于阈值自动清理最旧图片""" + import shutil + min_free_mb = getattr(config, "image_min_free_mb", None) + if min_free_mb is None: + min_free_mb = 500 + + while not stop_event.wait(1800): # 每30分钟 + try: + config.cleanup_old_images() + cleanup_image_thumbnails() + usage = shutil.disk_usage(config.images_dir) + free_mb = usage.free // (1024 * 1024) + if free_mb < min_free_mb: + logger.info({"event": "image_auto_cleanup", "free_mb": free_mb, "min_free_mb": min_free_mb}) + result = delete_to_target(min_free_mb) + logger.info({"event": "image_auto_cleanup_done", **result}) + except Exception: + pass + + +def start_image_cleanup_scheduler(stop_event: threading.Event) -> threading.Thread: + t = threading.Thread(target=_auto_cleanup_worker, args=(stop_event,), daemon=True, name="image-cleanup") + t.start() + return t diff --git a/services/image_storage_service.py b/services/image_storage_service.py new file mode 100644 index 0000000000000000000000000000000000000000..1b4216d9aab593fb31f1fb5dbe030f190f76f178 --- /dev/null +++ b/services/image_storage_service.py @@ -0,0 +1,405 @@ +from __future__ import annotations + +import hashlib +import io +import json +import time +from dataclasses import dataclass +from datetime import datetime +from pathlib import Path +from threading import Lock +from urllib.parse import quote, urlparse + +from curl_cffi import requests +from fastapi import HTTPException +from PIL import Image + +from services.config import DATA_DIR, config + +IMAGE_INDEX_FILE = DATA_DIR / "image_index.json" +IMAGE_INDEX_LOCK = Lock() +IMAGE_EXTENSIONS = {".png", ".jpg", ".jpeg", ".webp"} + + +class ImageStorageError(RuntimeError): + pass + + +@dataclass(frozen=True) +class StoredImage: + rel: str + url: str + storage: str + size: int + + +def _clean(value: object) -> str: + return str(value or "").strip() + + +def _now_iso() -> str: + return datetime.now().strftime("%Y-%m-%d %H:%M:%S") + + +def _safe_relative_path(path: str) -> str: + value = str(path or "").strip().replace("\\", "/").lstrip("/") + if not value: + raise HTTPException(status_code=404, detail="image not found") + parts = Path(value).parts + if any(part in {"", ".", ".."} for part in parts): + raise HTTPException(status_code=404, detail="image not found") + return Path(*parts).as_posix() + + +def _image_dimensions(payload: bytes) -> tuple[int, int] | None: + try: + with Image.open(io.BytesIO(payload)) as image: + return image.size + except Exception: + return None + + +def _is_image_rel(path: str) -> bool: + try: + safe_rel = _safe_relative_path(path) + except HTTPException: + return False + return Path(safe_rel).suffix.lower() in IMAGE_EXTENSIONS + + +def _local_image_path(relative_path: str) -> Path: + rel = _safe_relative_path(relative_path) + root = config.images_dir.resolve() + path = (root / rel).resolve() + try: + path.relative_to(root) + except ValueError as exc: + raise HTTPException(status_code=404, detail="image not found") from exc + return path + + +def _read_json_object(path: Path) -> dict[str, object]: + if not path.exists(): + return {} + try: + data = json.loads(path.read_text(encoding="utf-8")) + except Exception: + return {} + return data if isinstance(data, dict) else {} + + +def _write_json_object(path: Path, data: dict[str, object]) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + tmp_path = path.with_suffix(path.suffix + ".tmp") + tmp_path.write_text(json.dumps(data, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") + tmp_path.replace(path) + + +class WebDAVClient: + def __init__(self, settings: dict[str, object]): + self.url = _clean(settings.get("webdav_url")).rstrip("/") + self.username = _clean(settings.get("webdav_username")) + self.password = _clean(settings.get("webdav_password")) + self.root_path = _clean(settings.get("webdav_root_path")).strip("/") + self.session = requests.Session() + + def _auth_kwargs(self) -> dict[str, object]: + return {"auth": (self.username, self.password)} if self.username or self.password else {} + + def _request(self, method: str, url: str, **kwargs): + response = self.session.request(method, url, timeout=30, **self._auth_kwargs(), **kwargs) + if response.status_code >= 400 and not (method == "MKCOL" and response.status_code in {405}): + raise ImageStorageError(f"WebDAV {method} failed: HTTP {response.status_code}") + return response + + def remote_url(self, rel: str = "") -> str: + parts = [part for part in [self.root_path, _safe_relative_path(rel) if rel else ""] if part] + encoded = "/".join(quote(part, safe="") for item in parts for part in item.split("/") if part) + return f"{self.url}/{encoded}" if encoded else self.url + + def ensure_dirs(self, rel: str) -> None: + parts = [part for part in [self.root_path, Path(_safe_relative_path(rel)).parent.as_posix()] if part and part != "."] + current = self.url + for item in "/".join(parts).split("/"): + if not item: + continue + current = f"{current}/{quote(item, safe='')}" + response = self.session.request("MKCOL", current, timeout=30, **self._auth_kwargs()) + if response.status_code in {201, 405}: + continue + if response.status_code >= 400: + raise ImageStorageError(f"WebDAV MKCOL failed: HTTP {response.status_code}") + + def put(self, rel: str, payload: bytes, content_type: str = "image/png") -> str: + self.ensure_dirs(rel) + url = self.remote_url(rel) + self._request("PUT", url, data=payload, headers={"Content-Type": content_type}) + return url + + def get(self, rel: str) -> bytes: + response = self._request("GET", self.remote_url(rel)) + return bytes(response.content) + + def delete(self, rel: str) -> bool: + response = self.session.request("DELETE", self.remote_url(rel), timeout=30, **self._auth_kwargs()) + if response.status_code in {200, 202, 204, 404}: + return response.status_code != 404 + raise ImageStorageError(f"WebDAV DELETE failed: HTTP {response.status_code}") + + def test(self) -> dict[str, object]: + if not self.url: + return {"ok": False, "status": 0, "error": "WebDAV URL is required"} + if urlparse(self.url).scheme not in {"http", "https"}: + return {"ok": False, "status": 0, "error": "invalid WebDAV URL"} + test_rel = ".chatgpt2api_webdav_test.txt" + try: + self.put(test_rel, b"chatgpt2api webdav test\n", content_type="text/plain") + self.delete(test_rel) + return {"ok": True, "status": 200, "error": None} + except ImageStorageError as exc: + return {"ok": False, "status": 0, "error": str(exc)} + except Exception as exc: + return {"ok": False, "status": 0, "error": str(exc) or exc.__class__.__name__} + finally: + self.session.close() + + +class ImageStorageService: + def __init__(self, index_file: Path = IMAGE_INDEX_FILE): + self.index_file = index_file + self._index_lock = IMAGE_INDEX_LOCK + + def settings(self) -> dict[str, object]: + return config.get_image_storage_settings() + + def mode(self) -> str: + return _clean(self.settings().get("mode")) or "local" + + def _load_index(self) -> dict[str, dict[str, object]]: + raw = _read_json_object(self.index_file) + items = raw.get("items") + if not isinstance(items, dict): + return {} + return {str(key): value for key, value in items.items() if isinstance(value, dict)} + + def _load_clean_index(self) -> dict[str, dict[str, object]]: + items = self._load_index() + return {rel: item for rel, item in items.items() if _is_image_rel(rel)} + + def _save_index(self, items: dict[str, dict[str, object]]) -> None: + _write_json_object(self.index_file, {"items": items}) + + def _public_url(self, rel: str, base_url: str | None = None) -> str: + settings = self.settings() + public_base_url = _clean(settings.get("public_base_url")) + if public_base_url: + return f"{public_base_url.rstrip('/')}/{_safe_relative_path(rel)}" + return f"{(base_url or config.base_url).rstrip('/')}/images/{_safe_relative_path(rel)}" + + def make_relative_path(self, image_data: bytes) -> str: + file_hash = hashlib.md5(image_data).hexdigest() + filename = f"{int(time.time())}_{file_hash}.png" + relative_dir = Path(time.strftime("%Y"), time.strftime("%m"), time.strftime("%d")) + return f"{relative_dir.as_posix()}/{filename}" + + def save(self, image_data: bytes, base_url: str | None = None) -> StoredImage: + config.cleanup_old_images() + rel = self.make_relative_path(image_data) + mode = self.mode() + if mode not in {"local", "webdav", "both"}: + mode = "local" + stored_local = False + stored_webdav = False + remote_url = "" + + if mode in {"local", "both"}: + path = _local_image_path(rel) + path.parent.mkdir(parents=True, exist_ok=True) + path.write_bytes(image_data) + stored_local = True + + if mode in {"webdav", "both"}: + remote_url = WebDAVClient(self.settings()).put(rel, image_data) + stored_webdav = True + + dimensions = _image_dimensions(image_data) + item = { + "rel": rel, + "path": rel, + "name": Path(rel).name, + "date": "-".join(rel.split("/")[:3]), + "size": len(image_data), + "created_at": _now_iso(), + "storage": "both" if stored_local and stored_webdav else ("webdav" if stored_webdav else "local"), + "local": stored_local, + "webdav": stored_webdav, + "remote_url": remote_url, + } + if dimensions: + item["width"], item["height"] = dimensions + with self._index_lock: + items = self._load_clean_index() + items[rel] = item + self._save_index(items) + return StoredImage(rel=rel, url=self._public_url(rel, base_url), storage=str(item["storage"]), size=len(image_data)) + + def get_bytes(self, rel: str) -> bytes: + safe_rel = _safe_relative_path(rel) + if not _is_image_rel(safe_rel): + raise HTTPException(status_code=404, detail="image not found") + path = _local_image_path(safe_rel) + if path.is_file(): + return path.read_bytes() + item = self._load_clean_index().get(safe_rel, {}) + if item.get("webdav"): + return WebDAVClient(self.settings()).get(safe_rel) + raise HTTPException(status_code=404, detail="image not found") + + def exists(self, rel: str) -> bool: + safe_rel = _safe_relative_path(rel) + if not _is_image_rel(safe_rel): + return False + if _local_image_path(safe_rel).is_file(): + return True + item = self._load_clean_index().get(safe_rel, {}) + return bool(item.get("webdav")) + + def has_local(self, rel: str) -> bool: + safe_rel = _safe_relative_path(rel) + return _is_image_rel(safe_rel) and _local_image_path(safe_rel).is_file() + + def list_items(self, base_url: str, start_date: str = "", end_date: str = "") -> list[dict[str, object]]: + with self._index_lock: + indexed = self._load_clean_index() + root = config.images_dir + changed = False + for path in root.rglob("*"): + if not path.is_file() or not _is_image_rel(path.name): + continue + rel = path.relative_to(root).as_posix() + if rel in indexed: + continue + dimensions = None + try: + dimensions = _image_dimensions(path.read_bytes()) + except Exception: + dimensions = None + indexed[rel] = { + "rel": rel, + "path": rel, + "name": path.name, + "date": "-".join(rel.split("/")[:3]) if len(rel.split("/")) >= 4 else datetime.fromtimestamp(path.stat().st_mtime).strftime("%Y-%m-%d"), + "size": path.stat().st_size, + "created_at": datetime.fromtimestamp(path.stat().st_mtime).strftime("%Y-%m-%d %H:%M:%S"), + "storage": "local", + "local": True, + "webdav": False, + **({"width": dimensions[0], "height": dimensions[1]} if dimensions else {}), + } + changed = True + + items: list[dict[str, object]] = [] + for rel, item in list(indexed.items()): + if not _is_image_rel(rel): + indexed.pop(rel, None) + changed = True + continue + local = _local_image_path(rel).is_file() + webdav = bool(item.get("webdav")) + if not local and not webdav: + indexed.pop(rel, None) + changed = True + continue + storage = "both" if local and webdav else ("webdav" if webdav else "local") + if item.get("local") != local or item.get("storage") != storage: + item = { + **item, + "local": local, + "storage": storage, + } + indexed[rel] = item + changed = True + day = str(item.get("date") or "") + if start_date and day < start_date: + continue + if end_date and day > end_date: + continue + items.append({ + **item, + "rel": rel, + "path": rel, + "url": self._public_url(rel, base_url), + }) + if changed: + self._save_index(indexed) + items.sort(key=lambda item: str(item.get("created_at") or ""), reverse=True) + return items + + def delete(self, rel: str) -> bool: + safe_rel = _safe_relative_path(rel) + removed = False + path = _local_image_path(safe_rel) + if path.is_file(): + path.unlink() + removed = True + with self._index_lock: + items = self._load_clean_index() + item = items.get(safe_rel, {}) + if item.get("webdav"): + try: + removed = WebDAVClient(self.settings()).delete(safe_rel) or removed + except ImageStorageError: + if not removed: + raise + if safe_rel in items: + items.pop(safe_rel, None) + self._save_index(items) + return removed + + def sync_all(self) -> dict[str, int]: + settings = self.settings() + if self.mode() not in {"webdav", "both"}: + raise ImageStorageError("WebDAV 图片存储未启用") + uploaded = 0 + skipped = 0 + failed = 0 + with self._index_lock: + items = self._load_clean_index() + client = WebDAVClient(settings) + for path in sorted(config.images_dir.rglob("*")): + if not path.is_file() or not _is_image_rel(path.name): + continue + rel = path.relative_to(config.images_dir).as_posix() + item = items.get(rel, {}) + if item.get("webdav"): + skipped += 1 + continue + try: + payload = path.read_bytes() + remote_url = client.put(rel, payload) + dimensions = _image_dimensions(payload) + items[rel] = { + **item, + "rel": rel, + "path": rel, + "name": path.name, + "date": "-".join(rel.split("/")[:3]) if len(rel.split("/")) >= 4 else datetime.fromtimestamp(path.stat().st_mtime).strftime("%Y-%m-%d"), + "size": len(payload), + "created_at": str(item.get("created_at") or datetime.fromtimestamp(path.stat().st_mtime).strftime("%Y-%m-%d %H:%M:%S")), + "storage": "both", + "local": True, + "webdav": True, + "remote_url": remote_url, + **({"width": dimensions[0], "height": dimensions[1]} if dimensions else {}), + } + uploaded += 1 + except Exception: + failed += 1 + self._save_index(items) + return {"uploaded": uploaded, "skipped": skipped, "failed": failed} + + def test_webdav(self) -> dict[str, object]: + return WebDAVClient(self.settings()).test() + + +image_storage_service = ImageStorageService() diff --git a/services/image_tags_service.py b/services/image_tags_service.py new file mode 100644 index 0000000000000000000000000000000000000000..21910ad54e0aa03433f31fa25a00a91e692a22b0 --- /dev/null +++ b/services/image_tags_service.py @@ -0,0 +1,76 @@ +from __future__ import annotations + +import json +from pathlib import Path + +from services.config import DATA_DIR + +TAGS_FILE = DATA_DIR / "image_tags.json" + + +def _ensure_file() -> None: + TAGS_FILE.parent.mkdir(parents=True, exist_ok=True) + if not TAGS_FILE.exists(): + TAGS_FILE.write_text("{}", encoding="utf-8") + + +def load_tags() -> dict[str, list[str]]: + _ensure_file() + try: + data = json.loads(TAGS_FILE.read_text(encoding="utf-8")) + except Exception: + return {} + return data if isinstance(data, dict) else {} + + +def save_tags(data: dict[str, list[str]]) -> None: + _ensure_file() + TAGS_FILE.write_text(json.dumps(data, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") + + +def get_tags(image_rel: str) -> list[str]: + return load_tags().get(image_rel, []) + + +def set_tags(image_rel: str, tags: list[str]) -> list[str]: + data = load_tags() + cleaned = list(dict.fromkeys(t.strip() for t in tags if t.strip())) + if cleaned: + data[image_rel] = cleaned + else: + data.pop(image_rel, None) + save_tags(data) + return cleaned + + +def remove_tags(image_rel: str) -> None: + data = load_tags() + if data.pop(image_rel, None) is not None: + save_tags(data) + + +def delete_tag(tag: str) -> int: + """从所有图片中删除指定标签,返回受影响的图片数。""" + data = load_tags() + count = 0 + for rel in list(data): + if tag in data[rel]: + data[rel] = [t for t in data[rel] if t != tag] + if not data[rel]: + del data[rel] + count += 1 + if count > 0: + save_tags(data) + return count + + +def get_all_tags() -> list[str]: + data = load_tags() + seen: set[str] = set() + result: list[str] = [] + for tags in data.values(): + for t in tags: + if t not in seen: + seen.add(t) + result.append(t) + return result diff --git a/services/image_task_service.py b/services/image_task_service.py new file mode 100644 index 0000000000000000000000000000000000000000..0898d450f0168fa4a1d532e01dac56c8f57a08a0 --- /dev/null +++ b/services/image_task_service.py @@ -0,0 +1,404 @@ +from __future__ import annotations + +import json +import threading +import time +from collections.abc import Callable +from datetime import datetime +from pathlib import Path +from typing import Any + +from services.config import DATA_DIR, config +from services.content_filter import request_text +from services.log_service import LOG_TYPE_CALL, log_service +from services.protocol import openai_v1_image_edit, openai_v1_image_generations + +TASK_STATUS_QUEUED = "queued" +TASK_STATUS_RUNNING = "running" +TASK_STATUS_SUCCESS = "success" +TASK_STATUS_ERROR = "error" +TERMINAL_STATUSES = {TASK_STATUS_SUCCESS, TASK_STATUS_ERROR} +UNFINISHED_STATUSES = {TASK_STATUS_QUEUED, TASK_STATUS_RUNNING} + + +def _now_iso() -> str: + return datetime.now().strftime("%Y-%m-%d %H:%M:%S") + + +def _timestamp(value: object) -> float: + if not isinstance(value, str) or not value.strip(): + return 0.0 + for fmt in ("%Y-%m-%d %H:%M:%S", "%Y-%m-%dT%H:%M:%S.%f", "%Y-%m-%dT%H:%M:%S"): + try: + return datetime.strptime(value[:26], fmt).timestamp() + except ValueError: + continue + try: + return datetime.fromisoformat(value.replace("Z", "+00:00")).timestamp() + except Exception: + return 0.0 + + +def _clean(value: object, default: str = "") -> str: + return str(value or default).strip() + + +def _owner_id(identity: dict[str, object]) -> str: + return _clean(identity.get("id")) or "anonymous" + + +def _task_key(owner_id: str, task_id: str) -> str: + return f"{owner_id}:{task_id}" + + +def _collect_image_urls(data: list[Any]) -> list[str]: + urls: list[str] = [] + for item in data: + if isinstance(item, dict): + url = item.get("url") + if isinstance(url, str) and url: + urls.append(url) + return urls + + +def _public_task(task: dict[str, Any]) -> dict[str, Any]: + item = { + "id": task.get("id"), + "status": task.get("status"), + "mode": task.get("mode"), + "model": task.get("model"), + "size": task.get("size"), + "quality": task.get("quality"), + "created_at": task.get("created_at"), + "updated_at": task.get("updated_at"), + } + if task.get("data") is not None: + item["data"] = task.get("data") + if task.get("usage") is not None: + item["usage"] = task.get("usage") + if task.get("error"): + item["error"] = task.get("error") + return item + + +class ImageTaskService: + def __init__( + self, + path: Path, + *, + generation_handler: Callable[[dict[str, Any]], dict[str, Any]] = openai_v1_image_generations.handle, + edit_handler: Callable[[dict[str, Any]], dict[str, Any]] = openai_v1_image_edit.handle, + retention_days_getter: Callable[[], int] | None = None, + ): + self.path = path + self.generation_handler = generation_handler + self.edit_handler = edit_handler + self.retention_days_getter = retention_days_getter or (lambda: config.image_retention_days) + self._lock = threading.RLock() + self._tasks: dict[str, dict[str, Any]] = {} + self.path.parent.mkdir(parents=True, exist_ok=True) + with self._lock: + self._tasks = self._load_locked() + changed = self._recover_unfinished_locked() + changed = self._cleanup_locked() or changed + if changed: + self._save_locked() + + def submit_generation( + self, + identity: dict[str, object], + *, + client_task_id: str, + prompt: str, + model: str, + size: str | None, + quality: str = "auto", + base_url: str = "", + ) -> dict[str, Any]: + payload = { + "prompt": prompt, + "model": model, + "n": 1, + "size": size, + "quality": quality, + "response_format": "url", + "base_url": base_url, + } + return self._submit(identity, client_task_id=client_task_id, mode="generate", payload=payload) + + def submit_edit( + self, + identity: dict[str, object], + *, + client_task_id: str, + prompt: str, + model: str, + size: str | None, + quality: str = "auto", + base_url: str = "", + images: list[tuple[bytes, str, str]] | None = None, + ) -> dict[str, Any]: + payload = { + "prompt": prompt, + "images": images or [], + "model": model, + "n": 1, + "size": size, + "quality": quality, + "response_format": "url", + "base_url": base_url, + } + return self._submit(identity, client_task_id=client_task_id, mode="edit", payload=payload) + + def list_tasks(self, identity: dict[str, object], task_ids: list[str]) -> dict[str, Any]: + owner = _owner_id(identity) + requested_ids = [_clean(task_id) for task_id in task_ids if _clean(task_id)] + with self._lock: + if self._cleanup_locked(): + self._save_locked() + items = [] + missing_ids = [] + for task_id in requested_ids: + task = self._tasks.get(_task_key(owner, task_id)) + if task is None: + missing_ids.append(task_id) + else: + items.append(_public_task(task)) + if not requested_ids: + items = [ + _public_task(task) + for task in self._tasks.values() + if task.get("owner_id") == owner + ] + items.sort(key=lambda item: str(item.get("updated_at") or ""), reverse=True) + missing_ids = [] + return {"items": items, "missing_ids": missing_ids} + + def _submit( + self, + identity: dict[str, object], + *, + client_task_id: str, + mode: str, + payload: dict[str, Any], + ) -> dict[str, Any]: + task_id = _clean(client_task_id) + if not task_id: + raise ValueError("client_task_id is required") + owner = _owner_id(identity) + key = _task_key(owner, task_id) + now = _now_iso() + should_start = False + with self._lock: + cleaned = self._cleanup_locked() + task = self._tasks.get(key) + if task is not None: + if cleaned: + self._save_locked() + return _public_task(task) + task = { + "id": task_id, + "owner_id": owner, + "status": TASK_STATUS_QUEUED, + "mode": mode, + "model": _clean(payload.get("model"), "gpt-image-2"), + "size": _clean(payload.get("size")), + "quality": _clean(payload.get("quality"), "auto"), + "created_at": now, + "updated_at": now, + } + self._tasks[key] = task + self._save_locked() + should_start = True + + if should_start: + thread = threading.Thread( + target=self._run_task, + args=(key, mode, payload, dict(identity), _clean(payload.get("model"), "gpt-image-2")), + name=f"image-task-{task_id[:16]}", + daemon=True, + ) + thread.start() + return _public_task(task) + + def _run_task( + self, + key: str, + mode: str, + payload: dict[str, Any], + identity: dict[str, object], + model: str, + ) -> None: + started = time.time() + self._update_task(key, status=TASK_STATUS_RUNNING, error="") + try: + handler = self.edit_handler if mode == "edit" else self.generation_handler + result = handler(payload) + if not isinstance(result, dict): + raise RuntimeError("image task returned streaming result unexpectedly") + data = result.get("data") + account_email = _clean(result.get("_account_email") or result.get("account_email")) + if not isinstance(data, list) or not data: + upstream = _clean(result.get("message")) + if upstream: + message = upstream + else: + message = "号池中没有可用账号或所有账号均被限流,请检查号池状态(账号额度、是否被封禁、是否到达生图上限)" + error = RuntimeError(message) + if account_email: + setattr(error, "account_email", account_email) + raise error + usage = result.get("usage") + self._update_task(key, status=TASK_STATUS_SUCCESS, data=data, usage=usage, error="") + self._log_call( + identity, + mode, + model, + started, + "调用完成", + request_preview=request_text(payload.get("prompt")), + urls=_collect_image_urls(data), + account_email=account_email, + ) + except Exception as exc: + error_message = str(exc) or "image task failed" + account_email = _clean(getattr(exc, "account_email", "")) + self._update_task(key, status=TASK_STATUS_ERROR, error=error_message, data=[]) + self._log_call( + identity, + mode, + model, + started, + "调用失败", + request_preview=request_text(payload.get("prompt")), + status="failed", + error=error_message, + account_email=account_email, + ) + + def _log_call( + self, + identity: dict[str, object], + mode: str, + model: str, + started: float, + suffix: str, + *, + request_preview: str = "", + status: str = "success", + error: str = "", + urls: list[str] | None = None, + account_email: str = "", + ) -> None: + endpoint = "/v1/images/edits" if mode == "edit" else "/v1/images/generations" + summary_prefix = "图生图" if mode == "edit" else "文生图" + detail = { + "key_id": identity.get("id"), + "key_name": identity.get("name"), + "role": identity.get("role"), + "endpoint": endpoint, + "model": model, + "started_at": datetime.fromtimestamp(started).strftime("%Y-%m-%d %H:%M:%S"), + "ended_at": _now_iso(), + "duration_ms": int((time.time() - started) * 1000), + "status": status, + } + if request_preview: + detail["request_text"] = request_preview + if error: + detail["error"] = error + if account_email: + detail["account_email"] = account_email + if urls: + detail["urls"] = list(dict.fromkeys(urls)) + try: + log_service.add(LOG_TYPE_CALL, f"{summary_prefix}{suffix}", detail) + except Exception: + pass + + def _update_task(self, key: str, **updates: Any) -> None: + with self._lock: + task = self._tasks.get(key) + if task is None: + return + task.update(updates) + task["updated_at"] = _now_iso() + self._save_locked() + + def _load_locked(self) -> dict[str, dict[str, Any]]: + if not self.path.exists(): + return {} + try: + raw = json.loads(self.path.read_text(encoding="utf-8")) + except Exception: + return {} + raw_items = raw.get("tasks") if isinstance(raw, dict) else raw + if not isinstance(raw_items, list): + return {} + tasks: dict[str, dict[str, Any]] = {} + for item in raw_items: + if not isinstance(item, dict): + continue + task_id = _clean(item.get("id")) + owner = _clean(item.get("owner_id")) + if not task_id or not owner: + continue + status = _clean(item.get("status")) + if status not in {TASK_STATUS_QUEUED, TASK_STATUS_RUNNING, TASK_STATUS_SUCCESS, TASK_STATUS_ERROR}: + status = TASK_STATUS_ERROR + task = { + "id": task_id, + "owner_id": owner, + "status": status, + "mode": "edit" if item.get("mode") == "edit" else "generate", + "model": _clean(item.get("model"), "gpt-image-2"), + "size": _clean(item.get("size")), + "quality": _clean(item.get("quality"), "auto"), + "created_at": _clean(item.get("created_at"), _now_iso()), + "updated_at": _clean(item.get("updated_at"), _clean(item.get("created_at"), _now_iso())), + } + data = item.get("data") + if isinstance(data, list): + task["data"] = data + usage = item.get("usage") + if isinstance(usage, dict): + task["usage"] = usage + error = _clean(item.get("error")) + if error: + task["error"] = error + tasks[_task_key(owner, task_id)] = task + return tasks + + def _save_locked(self) -> None: + items = sorted(self._tasks.values(), key=lambda item: str(item.get("updated_at") or ""), reverse=True) + tmp_path = self.path.with_suffix(self.path.suffix + ".tmp") + tmp_path.write_text(json.dumps({"tasks": items}, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") + tmp_path.replace(self.path) + + def _recover_unfinished_locked(self) -> bool: + changed = False + for task in self._tasks.values(): + if task.get("status") in UNFINISHED_STATUSES: + task["status"] = TASK_STATUS_ERROR + task["error"] = "服务已重启,未完成的图片任务已中断" + task["updated_at"] = _now_iso() + changed = True + return changed + + def _cleanup_locked(self) -> bool: + try: + retention_days = max(1, int(self.retention_days_getter())) + except Exception: + retention_days = 30 + cutoff = time.time() - retention_days * 86400 + removed_keys = [ + key + for key, task in self._tasks.items() + if task.get("status") in TERMINAL_STATUSES and _timestamp(task.get("updated_at")) < cutoff + ] + for key in removed_keys: + self._tasks.pop(key, None) + return bool(removed_keys) + + +image_task_service = ImageTaskService(DATA_DIR / "image_tasks.json") diff --git a/services/log_service.py b/services/log_service.py new file mode 100644 index 0000000000000000000000000000000000000000..415c670261c2503881806cdc96074af2943a6c6a --- /dev/null +++ b/services/log_service.py @@ -0,0 +1,308 @@ +from __future__ import annotations + +import hashlib +import json +import itertools +import time +from dataclasses import dataclass, field +from datetime import datetime +from pathlib import Path +from typing import Any +from uuid import uuid4 + +from fastapi import HTTPException +from fastapi.concurrency import run_in_threadpool +from fastapi.responses import JSONResponse, StreamingResponse + +from services.config import DATA_DIR +from services.protocol.error_response import anthropic_error_response, openai_error_response +from utils.helper import anthropic_sse_stream, sse_json_stream + +LOG_TYPE_CALL = "call" +LOG_TYPE_ACCOUNT = "account" +INTERNAL_RESPONSE_KEYS = {"_account_email"} + + +class LogService: + def __init__(self, path: Path): + self.path = path + self.path.parent.mkdir(parents=True, exist_ok=True) + + @staticmethod + def _legacy_id(raw_line: str, line_number: int) -> str: + payload = f"{line_number}:{raw_line}".encode("utf-8", errors="ignore") + return hashlib.sha1(payload).hexdigest()[:24] + + def _parse_line(self, raw_line: str, line_number: int) -> dict[str, Any] | None: + try: + item = json.loads(raw_line) + except Exception: + return None + if not isinstance(item, dict): + return None + parsed = dict(item) + parsed["id"] = str(parsed.get("id") or self._legacy_id(raw_line, line_number)) + return parsed + + @staticmethod + def _serialize_item(item: dict[str, Any]) -> str: + return json.dumps(item, ensure_ascii=False, separators=(",", ":")) + + @staticmethod + def _matches_filters(item: dict[str, Any], *, type: str = "", start_date: str = "", end_date: str = "") -> bool: + t = str(item.get("time") or "") + day = t[:10] + if type and item.get("type") != type: + return False + if start_date and day < start_date: + return False + if end_date and day > end_date: + return False + return True + + def add(self, type: str, summary: str = "", detail: dict[str, Any] | None = None, **data: Any) -> None: + item = { + "id": uuid4().hex, + "time": datetime.now().strftime("%Y-%m-%d %H:%M:%S"), + "type": type, + "summary": summary, + "detail": detail or data, + } + with self.path.open("a", encoding="utf-8") as file: + file.write(self._serialize_item(item) + "\n") + + def list(self, type: str = "", start_date: str = "", end_date: str = "", limit: int = 200) -> list[dict[str, Any]]: + if not self.path.exists(): + return [] + items: list[dict[str, Any]] = [] + lines = self.path.read_text(encoding="utf-8").splitlines() + for line_number in range(len(lines) - 1, -1, -1): + item = self._parse_line(lines[line_number], line_number) + if item is None: + continue + if not self._matches_filters(item, type=type, start_date=start_date, end_date=end_date): + continue + items.append(item) + if len(items) >= limit: + break + return items + + def delete(self, ids: list[str]) -> dict[str, int]: + target_ids = {str(item or "").strip() for item in ids if str(item or "").strip()} + if not self.path.exists() or not target_ids: + return {"removed": 0} + lines = self.path.read_text(encoding="utf-8").splitlines() + kept_lines: list[str] = [] + removed = 0 + for line_number, raw_line in enumerate(lines): + item = self._parse_line(raw_line, line_number) + if item is None: + kept_lines.append(raw_line) + continue + if str(item.get("id") or "") in target_ids: + removed += 1 + continue + kept_lines.append(self._serialize_item(item)) + content = "\n".join(kept_lines) + if content: + content += "\n" + self.path.write_text(content, encoding="utf-8") + return {"removed": removed} + + +log_service = LogService(DATA_DIR / "logs.jsonl") + + +def _collect_urls(value: object) -> list[str]: + urls: list[str] = [] + if isinstance(value, dict): + for key, item in value.items(): + if key == "url" and isinstance(item, str): + urls.append(item) + elif key == "urls" and isinstance(item, list): + urls.extend(str(url) for url in item if isinstance(url, str)) + else: + urls.extend(_collect_urls(item)) + elif isinstance(value, list): + for item in value: + urls.extend(_collect_urls(item)) + return urls + + +def _collect_account_emails(value: object) -> list[str]: + emails: list[str] = [] + if isinstance(value, dict): + for key, item in value.items(): + if key in {"_account_email", "account_email"} and isinstance(item, str) and item.strip(): + emails.append(item.strip()) + else: + emails.extend(_collect_account_emails(item)) + elif isinstance(value, list): + for item in value: + emails.extend(_collect_account_emails(item)) + return emails + + +def _strip_internal_response_fields(value: object) -> object: + if isinstance(value, dict): + return { + key: _strip_internal_response_fields(item) + for key, item in value.items() + if key not in INTERNAL_RESPONSE_KEYS + } + if isinstance(value, list): + return [_strip_internal_response_fields(item) for item in value] + return value + + +def _request_excerpt(text: object, limit: int = 1000) -> str: + value = str(text or "").strip() + if not value: + return "" + normalized = " ".join(value.split()) + if len(normalized) <= limit: + return normalized + return normalized[: limit - 1].rstrip() + "…" + + +def _image_error_response(exc: Exception) -> JSONResponse: + from services.protocol.conversation import public_image_error_message + + message = public_image_error_message(str(exc)) + if "no available image quota" in message.lower(): + return openai_error_response( + { + "error": { + "message": "no available image quota", + "type": "insufficient_quota", + "param": None, + "code": "insufficient_quota", + } + }, + 429, + ) + if hasattr(exc, "to_openai_error") and hasattr(exc, "status_code"): + return JSONResponse(status_code=int(exc.status_code), content=exc.to_openai_error()) + return openai_error_response(message, 502) + + +def _protocol_error_response(exc: Exception, status_code: int, sse: str) -> JSONResponse: + message = str(exc) + if sse == "anthropic": + return anthropic_error_response(message, status_code) + return openai_error_response(message, status_code) + + +def _next_item(items): + try: + return True, next(items) + except StopIteration: + return False, None + + +@dataclass +class LoggedCall: + identity: dict[str, object] + endpoint: str + model: str + summary: str + started: float = field(default_factory=time.time) + request_text: str = "" + + async def run(self, handler, *args, sse: str = "openai"): + from services.protocol.conversation import ImageGenerationError + + try: + result = await run_in_threadpool(handler, *args) + except ImageGenerationError as exc: + self.log("调用失败", status="failed", error=str(exc), account_email=getattr(exc, "account_email", "")) + return _image_error_response(exc) + except HTTPException as exc: + self.log("调用失败", status="failed", error=str(exc.detail)) + raise + except Exception as exc: + self.log("调用失败", status="failed", error=str(exc), account_email=getattr(exc, "account_email", "")) + if self.endpoint.startswith("/v1/images"): + return _image_error_response(exc) + return _protocol_error_response(exc, 502, sse) + + if isinstance(result, dict): + self.log("调用完成", result) + response = dict(result) + response.pop("_account_email", None) + return response + + sender = anthropic_sse_stream if sse == "anthropic" else sse_json_stream + try: + has_first, first = await run_in_threadpool(_next_item, result) + except ImageGenerationError as exc: + self.log("调用失败", status="failed", error=str(exc), account_email=getattr(exc, "account_email", "")) + return _image_error_response(exc) + except HTTPException as exc: + self.log("调用失败", status="failed", error=str(exc.detail)) + raise + except Exception as exc: + self.log("调用失败", status="failed", error=str(exc), account_email=getattr(exc, "account_email", "")) + if self.endpoint.startswith("/v1/images"): + return _image_error_response(exc) + return _protocol_error_response(exc, 502, sse) + if not has_first: + self.log("流式调用结束") + return StreamingResponse(sender(()), media_type="text/event-stream") + return StreamingResponse(sender(self.stream(itertools.chain([first], result))), media_type="text/event-stream") + + def stream(self, items): + urls: list[str] = [] + account_emails: list[str] = [] + failed = False + try: + for item in items: + urls.extend(_collect_urls(item)) + account_emails.extend(_collect_account_emails(item)) + yield _strip_internal_response_fields(item) + except Exception as exc: + failed = True + self.log( + "流式调用失败", + status="failed", + error=str(exc), + urls=urls, + account_email=(account_emails[0] if account_emails else getattr(exc, "account_email", "")), + ) + if self.endpoint.startswith("/v1/images") and not hasattr(exc, "to_openai_error"): + from services.protocol.conversation import ImageGenerationError, public_image_error_message + + raise ImageGenerationError(public_image_error_message(str(exc))) from exc + raise + finally: + if not failed: + self.log("流式调用结束", urls=urls, account_email=account_emails[0] if account_emails else "") + + def log(self, suffix: str, result: object = None, status: str = "success", error: str = "", + urls: list[str] | None = None, account_email: str = "") -> None: + detail = { + "key_id": self.identity.get("id"), + "key_name": self.identity.get("name"), + "role": self.identity.get("role"), + "endpoint": self.endpoint, + "model": self.model, + "started_at": datetime.fromtimestamp(self.started).strftime("%Y-%m-%d %H:%M:%S"), + "ended_at": datetime.now().strftime("%Y-%m-%d %H:%M:%S"), + "duration_ms": int((time.time() - self.started) * 1000), + "status": status, + } + request_excerpt = _request_excerpt(self.request_text) + if request_excerpt: + detail["request_text"] = request_excerpt + if error: + detail["error"] = error + email = str(account_email or "").strip() + if not email: + emails = _collect_account_emails(result) + email = emails[0] if emails else "" + if email: + detail["account_email"] = email + collected_urls = [*(urls or []), *_collect_urls(result)] + if collected_urls: + detail["urls"] = list(dict.fromkeys(collected_urls)) + log_service.add(LOG_TYPE_CALL, f"{self.summary}{suffix}", detail) diff --git a/services/oauth_login_service.py b/services/oauth_login_service.py new file mode 100644 index 0000000000000000000000000000000000000000..0e13aca37a436a78d7a5e90ff3db0699d7a13a35 --- /dev/null +++ b/services/oauth_login_service.py @@ -0,0 +1,272 @@ +"""手动 OAuth 桥服务 + +让用户用自己浏览器走一遍 OpenAI 的标准 OAuth + PKCE 授权码流程: + 1. 后端生成 code_verifier / code_challenge / state,构造 authorize URL。 + 2. 用户在浏览器登录,浏览器最终被 OpenAI 重定向到 platform.openai.com 的 + callback 地址;用户从地址栏或 devtools 抓出 code,回填到前端。 + 3. 后端拿之前存好的 code_verifier + 回填的 code 调用 /api/accounts/oauth/token + 得到 {access_token, refresh_token, id_token}。 + +得到的 refresh_token 跟 account_service 自动刷新机制用的 client_id 是同一个 +(app_2SKx67EdpoN0G6j64rFvigXD),所以落盘后能直接进入 keepalive 周期。 +""" +from __future__ import annotations + +import base64 +import hashlib +import secrets +import threading +import time +import uuid +from typing import Any +from urllib.parse import parse_qs, urlencode, urlparse + +from curl_cffi import requests + +from services.proxy_service import proxy_settings +from services.register.openai_register import ( + auth_base, + common_headers, + platform_auth0_client, + platform_base, + platform_oauth_audience, + platform_oauth_client_id, + platform_oauth_redirect_uri, + sec_ch_ua, + user_agent, +) + + +class OAuthLoginError(Exception): + """OAuth 桥流程中的可预期错误,会被 API 层翻译成 400。""" + + +class OAuthLoginService: + """维护 PKCE 临时会话,并完成 code → token 的兑换。""" + + _SESSION_TTL_SECONDS = 10 * 60 # 用户点开浏览器 + 拿 code 给的时间上限 + _MAX_SESSIONS = 64 # 防止异常累积;超过容量时清理最老的 + + def __init__(self) -> None: + self._lock = threading.Lock() + self._sessions: dict[str, dict[str, Any]] = {} + + @staticmethod + def _generate_pkce() -> tuple[str, str]: + """生成 PKCE code_verifier 与对应的 code_challenge(S256)。""" + verifier = base64.urlsafe_b64encode(secrets.token_bytes(64)).rstrip(b"=").decode("ascii") + challenge = base64.urlsafe_b64encode( + hashlib.sha256(verifier.encode("ascii")).digest() + ).rstrip(b"=").decode("ascii") + return verifier, challenge + + def _purge_expired_locked(self) -> None: + """清理过期或溢出容量的会话,必须在持锁状态下调用。""" + now = time.time() + expired = [sid for sid, item in self._sessions.items() if now - item["created_at"] > self._SESSION_TTL_SECONDS] + for sid in expired: + self._sessions.pop(sid, None) + if len(self._sessions) > self._MAX_SESSIONS: + ordered = sorted(self._sessions.items(), key=lambda kv: kv[1]["created_at"]) + for sid, _ in ordered[: len(self._sessions) - self._MAX_SESSIONS]: + self._sessions.pop(sid, None) + + def start(self, email_hint: str = "") -> dict[str, str]: + """登记一个新的 PKCE 会话,返回 session_id 与可让用户打开的 authorize_url。 + + state 形如 ".",让 callback URL 自带 session_id, + finish 时即便前端 React 状态被覆盖也能从 URL 恢复正确的 verifier。 + """ + verifier, challenge = self._generate_pkce() + nonce = secrets.token_urlsafe(32) + device_id = str(uuid.uuid4()) + session_id = uuid.uuid4().hex + state = f"{session_id}.{secrets.token_urlsafe(16)}" + + params = { + "issuer": auth_base, + "client_id": platform_oauth_client_id, + "audience": platform_oauth_audience, + "redirect_uri": platform_oauth_redirect_uri, + "device_id": device_id, + "screen_hint": "login_or_signup", + "max_age": "0", + "scope": "openid profile email offline_access", + "response_type": "code", + "response_mode": "query", + "state": state, + "nonce": nonce, + "code_challenge": challenge, + "code_challenge_method": "S256", + "auth0Client": platform_auth0_client, + } + email_hint = str(email_hint or "").strip() + if email_hint: + params["login_hint"] = email_hint + + authorize_url = f"{auth_base}/api/accounts/authorize?{urlencode(params)}" + + with self._lock: + self._purge_expired_locked() + self._sessions[session_id] = { + "code_verifier": verifier, + "state": state, + "created_at": time.time(), + "redirect_uri": platform_oauth_redirect_uri, + } + + return { + "session_id": session_id, + "authorize_url": authorize_url, + "expires_in": str(self._SESSION_TTL_SECONDS), + "redirect_uri_prefix": platform_oauth_redirect_uri, + } + + @staticmethod + def _extract_code_from_callback(value: str) -> tuple[str, str]: + """从 callback URL 或 raw code 中提取 (code, state)。 + + 既允许用户粘贴整段 platform.openai.com/auth/callback?code=...&state=... 的 URL, + 也允许只粘 code 本身。 + """ + raw = str(value or "").strip() + if not raw: + return "", "" + if raw.startswith("http://") or raw.startswith("https://"): + try: + parsed = parse_qs(urlparse(raw).query) + except Exception as exc: + raise OAuthLoginError(f"无法解析 callback URL: {exc}") from exc + code = str((parsed.get("code") or [""])[0]).strip() + state = str((parsed.get("state") or [""])[0]).strip() + if not code: + err = str((parsed.get("error_description") or parsed.get("error") or [""])[0]).strip() + raise OAuthLoginError(err or "callback URL 中没有 code 参数") + return code, state + # 用户可能直接粘了 code 字符串 + return raw, "" + + def finish(self, session_id: str, callback: str) -> dict[str, str]: + """用 session_id 配对的 code_verifier 把 callback 里的 code 换成 token 三件套。 + + - 优先用 callback URL 自带 state 里的 session_id(更可靠), + 找不到才用前端传来的 session_id; + - 失败时不立刻销毁 session(OAuth code 错配换 token 失败通常不会消耗 code), + 只有成功兑换才 pop,便于用户用同一 verifier 重试。 + """ + body_sid = str(session_id or "").strip() + code, state = self._extract_code_from_callback(callback) + if not code: + raise OAuthLoginError("缺少 code 或 callback URL") + + # state 里嵌的 session_id 优先级最高 + state_sid = state.split(".", 1)[0] if state else "" + candidate_sids = [sid for sid in (state_sid, body_sid) if sid] + if not candidate_sids: + raise OAuthLoginError("既未提供 session_id,callback URL 中也未携带 state") + + with self._lock: + self._purge_expired_locked() + session = None + picked_sid = "" + for sid in candidate_sids: + cur = self._sessions.get(sid) + if cur is not None: + session = cur + picked_sid = sid + break + if session is None: + raise OAuthLoginError( + "OAuth 会话已过期或不存在,请回到导入对话框点\"重新生成\"再走一次" + ) + + if state and session.get("state") and state != session["state"]: + raise OAuthLoginError( + "state 不匹配。常见原因:你点过两次\"打开授权页面\",但浏览器里登录的还是前一次的窗口。请点\"重新生成\"重来。" + ) + + tokens = self._exchange_code( + code, + session["code_verifier"], + session.get("redirect_uri") or platform_oauth_redirect_uri, + ) + # 仅在成功兑换之后才消耗 session + with self._lock: + self._sessions.pop(picked_sid, None) + return tokens + + @staticmethod + def _exchange_code(code: str, code_verifier: str, redirect_uri: str) -> dict[str, str]: + """调用 /api/accounts/oauth/token 用 code+verifier 换 token 三件套。""" + kwargs = proxy_settings.build_session_kwargs(impersonate="chrome", verify=False) + session = requests.Session(**kwargs) + try: + response = session.post( + f"{auth_base}/api/accounts/oauth/token", + headers={ + **common_headers, + "referer": f"{platform_base}/", + "origin": platform_base, + "auth0-client": platform_auth0_client, + "sec-ch-ua": sec_ch_ua, + "user-agent": user_agent, + }, + json={ + "client_id": platform_oauth_client_id, + "code_verifier": code_verifier, + "grant_type": "authorization_code", + "code": code, + "redirect_uri": redirect_uri, + }, + timeout=60, + ) + except Exception as exc: + raise OAuthLoginError(f"换 token 网络异常: {exc}") from exc + finally: + session.close() + + try: + data = response.json() if response.text else {} + except Exception: + data = {} + + if response.status_code != 200 or not isinstance(data, dict) or not data.get("access_token"): + detail = "" + if isinstance(data, dict): + detail = str(data.get("error_description") or data.get("error") or data.get("message") or "") + if not detail: + try: + detail = str(response.text or "")[:300] + except Exception: + detail = "" + # 打到 docker logs 方便排错——OAuth 换 token 的失败原因往往只有这里能看到 + print( + f"[oauth-login] /api/accounts/oauth/token rejected: " + f"status={response.status_code} detail={detail!r} " + f"raw_body={(getattr(response, 'text', '') or '')[:500]!r}", + flush=True, + ) + raise OAuthLoginError( + f"OpenAI 拒绝换 token (HTTP {response.status_code}){': ' + detail if detail else ''}" + ) + + access_token = str(data.get("access_token") or "").strip() + refresh_token = str(data.get("refresh_token") or "").strip() + id_token = str(data.get("id_token") or "").strip() + + if not access_token: + raise OAuthLoginError("OpenAI 返回的 access_token 为空") + if not refresh_token: + # scope 含 offline_access 时正常会下发 refresh_token;这里给出明确提示 + raise OAuthLoginError( + "OpenAI 没有返回 refresh_token(可能 scope 未包含 offline_access 或 code 已使用过)" + ) + + return { + "access_token": access_token, + "refresh_token": refresh_token, + "id_token": id_token, + } + + +oauth_login_service = OAuthLoginService() diff --git a/services/openai_backend_api.py b/services/openai_backend_api.py new file mode 100644 index 0000000000000000000000000000000000000000..641893a395e5c6afc0ef3b48cdb3a7dc179bca33 --- /dev/null +++ b/services/openai_backend_api.py @@ -0,0 +1,1264 @@ +import base64 +import json +import os +import random +import re +import time +import urllib.error +import urllib.request +from concurrent.futures import ThreadPoolExecutor +from dataclasses import dataclass +from io import BytesIO +from pathlib import Path +from typing import Any, Dict, Iterator, Optional + +from curl_cffi import requests +from PIL import Image + +from services.account_service import account_service +from services.config import config +from services.proxy_service import proxy_settings +from utils.helper import UpstreamHTTPError, ensure_ok, iter_sse_payloads, new_uuid, split_image_model +from utils.log import logger +from utils.pow import build_legacy_requirements_token, build_proof_token, parse_pow_resources +from utils.turnstile import solve_turnstile_token + + +class InvalidAccessTokenError(RuntimeError): + pass + + +class ImagePollTimeoutError(RuntimeError): + pass + + +@dataclass +class ChatRequirements: + """保存一次对话请求所需的 sentinel token。""" + token: str + proof_token: str = "" + turnstile_token: str = "" + so_token: str = "" + raw_finalize: Optional[Dict[str, Any]] = None + + +DEFAULT_CLIENT_VERSION = "prod-be885abbfcfe7b1f511e88b3003d9ee44757fbad" +DEFAULT_CLIENT_BUILD_NUMBER = "5955942" +DEFAULT_POW_SCRIPT = "https://chatgpt.com/backend-api/sentinel/sdk.js" +CODEX_IMAGE_MODEL = "codex-gpt-image-2" +CODEX_RESPONSES_MODEL = "gpt-5.5" +CODEX_RESPONSES_INSTRUCTIONS = ( + "Use the image_generation tool to create exactly one image for the user's request. " + "Return the generated image result." +) + + +class OpenAIBackendAPI: + """ChatGPT Web 后端封装。 + + 说明: + - 传入 `access_token` 时,聊天和模型列表都会走已登录链路 + 例如 `/backend-api/sentinel/chat-requirements`、`/backend-api/conversation` + - 不传 `access_token` 时,会走未登录链路 + 例如 `/backend-anon/sentinel/chat-requirements`、`/backend-anon/conversation` + - `stream_conversation()` 是底层统一流式入口 + - 协议兼容转换放在 `services.protocol` + """ + + def __init__(self, access_token: str = "") -> None: + """初始化后端客户端。 + + 参数: + - `access_token`:可选。传入后表示使用已登录链路;不传则使用未登录链路。 + """ + self.base_url = "https://chatgpt.com" + self.client_version = DEFAULT_CLIENT_VERSION + self.client_build_number = DEFAULT_CLIENT_BUILD_NUMBER + self.access_token = access_token + self.account = account_service.get_account(self.access_token) if self.access_token else {} + self.account = self.account if isinstance(self.account, dict) else {} + self.fp = self._build_fp() + self.user_agent = self.fp["user-agent"] + self.device_id = self.fp["oai-device-id"] + self.session_id = self.fp["oai-session-id"] + self.pow_script_sources: list[str] = [] + self.pow_data_build = "" + self.session = requests.Session(**proxy_settings.build_session_kwargs( + account=self.account, + impersonate=self.fp["impersonate"], + verify=True, + )) + self.session.headers.update({ + "User-Agent": self.user_agent, + "Origin": self.base_url, + "Referer": self.base_url + "/", + "Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8,en-US;q=0.7", + "Cache-Control": "no-cache", + "Pragma": "no-cache", + "Priority": "u=1, i", + "Sec-Ch-Ua": self.fp["sec-ch-ua"], + "Sec-Ch-Ua-Arch": '"x86"', + "Sec-Ch-Ua-Bitness": '"64"', + "Sec-Ch-Ua-Full-Version": '"143.0.3650.96"', + "Sec-Ch-Ua-Full-Version-List": '"Microsoft Edge";v="143.0.3650.96", "Chromium";v="143.0.7499.147", "Not A(Brand";v="24.0.0.0"', + "Sec-Ch-Ua-Mobile": self.fp["sec-ch-ua-mobile"], + "Sec-Ch-Ua-Model": '""', + "Sec-Ch-Ua-Platform": self.fp["sec-ch-ua-platform"], + "Sec-Ch-Ua-Platform-Version": '"19.0.0"', + "Sec-Fetch-Dest": "empty", + "Sec-Fetch-Mode": "cors", + "Sec-Fetch-Site": "same-origin", + "OAI-Device-Id": self.device_id, + "OAI-Session-Id": self.session_id, + "OAI-Language": "zh-CN", + "OAI-Client-Version": self.client_version, + "OAI-Client-Build-Number": self.client_build_number, + }) + if self.access_token: + self.session.headers["Authorization"] = f"Bearer {self.access_token}" + + def _build_fp(self) -> Dict[str, str]: + account = self.account + raw_fp = account.get("fp") + fp = {str(k).lower(): str(v) for k, v in raw_fp.items()} if isinstance(raw_fp, dict) else {} + for key in ( + "user-agent", + "impersonate", + "oai-device-id", + "oai-session-id", + "sec-ch-ua", + "sec-ch-ua-mobile", + "sec-ch-ua-platform", + ): + value = str(account.get(key) or "").strip() + if value: + fp[key] = value + fp.setdefault( + "user-agent", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 " + "(KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36 Edg/143.0.0.0", + ) + fp.setdefault("impersonate", "edge101") + fp.setdefault("oai-device-id", new_uuid()) + fp.setdefault("oai-session-id", new_uuid()) + fp.setdefault("sec-ch-ua", '"Microsoft Edge";v="143", "Chromium";v="143", "Not A(Brand";v="24"') + fp.setdefault("sec-ch-ua-mobile", "?0") + fp.setdefault("sec-ch-ua-platform", '"Windows"') + return fp + + def _headers(self, path: str, extra: Optional[Dict[str, str]] = None) -> Dict[str, str]: + """构造请求头,并补上 web 端要求的 target path/route。""" + headers = dict(self.session.headers) + headers["X-OpenAI-Target-Path"] = path + headers["X-OpenAI-Target-Route"] = path + if extra: + headers.update(extra) + return headers + + @staticmethod + def _extract_quota_and_restore_at(limits_progress: list[Any]) -> tuple[int, str | None, bool]: + for item in limits_progress: + if isinstance(item, dict) and item.get("feature_name") == "image_gen": + return int(item.get("remaining") or 0), str(item.get("reset_after") or "") or None, False + return 0, None, True + + def _get_me(self) -> Dict[str, Any]: + path = "/backend-api/me" + response = self.session.get(self.base_url + path, headers=self._headers(path), timeout=20) + if response.status_code != 200: + if response.status_code == 401: + raise InvalidAccessTokenError(f"{path} failed: HTTP {response.status_code}") + raise RuntimeError(f"{path} failed: HTTP {response.status_code}") + return response.json() + + def _get_conversation_init(self) -> Dict[str, Any]: + path = "/backend-api/conversation/init" + response = self.session.post( + self.base_url + path, + headers=self._headers(path, {"Content-Type": "application/json"}), + json={ + "gizmo_id": None, + "requested_default_model": None, + "conversation_id": None, + "timezone_offset_min": -480, + }, + timeout=20, + ) + if response.status_code != 200: + if response.status_code == 401: + raise InvalidAccessTokenError(f"{path} failed: HTTP {response.status_code}") + raise RuntimeError(f"{path} failed: HTTP {response.status_code}") + return response.json() + + def _get_default_account(self) -> Dict[str, Any]: + route = "/backend-api/accounts/check/v4-2023-04-27" + response = self.session.get(self.base_url + route + "?timezone_offset_min=-480", headers=self._headers(route), + timeout=20) + if response.status_code != 200: + if response.status_code == 401: + raise InvalidAccessTokenError(f"{route} failed: HTTP {response.status_code}") + raise RuntimeError(f"/backend-api/accounts/check failed: HTTP {response.status_code}") + payload = response.json() + logger.debug({"event": "backend_user_info_account_payload", "account_payload": payload}) + return ((payload.get("accounts") or {}).get("default") or {}).get("account") or {} + + def get_user_info(self) -> Dict[str, Any]: + """获取当前 token 的账号信息。""" + if not self.access_token: + raise RuntimeError("access_token is required") + logger.debug({"event": "backend_user_info_start"}) + with ThreadPoolExecutor(max_workers=3) as executor: + me_future = executor.submit(self._get_me) + init_future = executor.submit(self._get_conversation_init) + account_future = executor.submit(self._get_default_account) + me_payload, init_payload, default_account = me_future.result(), init_future.result(), account_future.result() + + plan_type = str(default_account.get("plan_type") or "free") + + limits_progress = init_payload.get("limits_progress") + limits_progress = limits_progress if isinstance(limits_progress, list) else [] + quota, restore_at, image_quota_unknown = self._extract_quota_and_restore_at(limits_progress) + result = { + "email": me_payload.get("email"), + "user_id": me_payload.get("id"), + "type": plan_type, + "quota": quota, + "image_quota_unknown": image_quota_unknown, + "limits_progress": limits_progress, + "default_model_slug": init_payload.get("default_model_slug"), + "restore_at": restore_at, + "status": "正常" if image_quota_unknown and plan_type.lower() != "free" else ("限流" if quota == 0 else "正常"), + } + logger.debug({ + "event": "backend_user_info_result", + "email": result.get("email"), + "user_id": result.get("user_id"), + "type": result.get("type"), + "quota": result.get("quota"), + "image_quota_unknown": result.get("image_quota_unknown"), + "default_model_slug": result.get("default_model_slug"), + "restore_at": result.get("restore_at"), + "status": result.get("status"), + }) + return result + + def _bootstrap_headers(self) -> Dict[str, str]: + """构造首页预热请求头。""" + return { + "User-Agent": self.user_agent, + "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8", + "Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8", + "Sec-Ch-Ua": self.session.headers["Sec-Ch-Ua"], + "Sec-Ch-Ua-Mobile": self.session.headers["Sec-Ch-Ua-Mobile"], + "Sec-Ch-Ua-Platform": self.session.headers["Sec-Ch-Ua-Platform"], + "Sec-Fetch-Dest": "document", + "Sec-Fetch-Mode": "navigate", + "Sec-Fetch-Site": "none", + "Sec-Fetch-User": "?1", + "Upgrade-Insecure-Requests": "1", + } + + def _build_requirements(self, data: Dict[str, Any], source_p: str = "") -> ChatRequirements: + """把 sentinel 响应整理成后续对话需要的 token 集合。""" + if (data.get("arkose") or {}).get("required"): + raise RuntimeError("chat requirements requires arkose token, which is not implemented") + + proof_token = "" + proof_info = data.get("proofofwork") or {} + if proof_info.get("required"): + proof_token = build_proof_token( + proof_info.get("seed", ""), + proof_info.get("difficulty", ""), + self.user_agent, + script_sources=self.pow_script_sources, + data_build=self.pow_data_build, + ) + + turnstile_token = "" + turnstile_info = data.get("turnstile") or {} + if turnstile_info.get("required") and turnstile_info.get("dx"): + turnstile_token = solve_turnstile_token(turnstile_info["dx"], source_p) or "" + + return ChatRequirements( + token=data.get("token", ""), + proof_token=proof_token, + turnstile_token=turnstile_token, + so_token=data.get("so_token", ""), + raw_finalize=data, + ) + + def _conversation_headers(self, path: str, requirements: ChatRequirements) -> Dict[str, str]: + """根据当前 requirements 构造对话 SSE 请求头。""" + headers = { + "Accept": "text/event-stream", + "Content-Type": "application/json", + "OpenAI-Sentinel-Chat-Requirements-Token": requirements.token, + } + if requirements.proof_token: + headers["OpenAI-Sentinel-Proof-Token"] = requirements.proof_token + if requirements.turnstile_token: + headers["OpenAI-Sentinel-Turnstile-Token"] = requirements.turnstile_token + if requirements.so_token: + headers["OpenAI-Sentinel-SO-Token"] = requirements.so_token + return self._headers(path, headers) + + def _api_messages_to_conversation_messages(self, messages: list[Dict[str, Any]]) -> list[Dict[str, Any]]: + """把标准 chat messages 转成 web conversation 所需的 messages。""" + conversation_messages = [] + for item in messages: + role = item.get("role", "user") + content = item.get("content", "") + if isinstance(content, str): + conversation_messages.append({ + "id": new_uuid(), + "author": {"role": role}, + "content": {"content_type": "text", "parts": [content]}, + }) + continue + if not isinstance(content, list): + raise RuntimeError("only string or list message content is supported") + text_parts: list[str] = [] + image_inputs: list[tuple[bytes, str]] = [] + for part in content: + if not isinstance(part, dict): + continue + part_type = str(part.get("type") or "") + if part_type == "text": + text_parts.append(str(part.get("text") or "")) + elif part_type == "image": + data = part.get("data") + mime = str(part.get("mime") or "image/png") + if isinstance(data, (bytes, bytearray)): + image_inputs.append((bytes(data), mime)) + if not image_inputs: + conversation_messages.append({ + "id": new_uuid(), + "author": {"role": role}, + "content": {"content_type": "text", "parts": ["".join(text_parts)]}, + }) + continue + if not self.access_token: + raise RuntimeError("authenticated upstream account required for image input") + uploaded: list[Dict[str, Any]] = [] + for idx, (data, mime) in enumerate(image_inputs, start=1): + ext_part = mime.split("/", 1)[1].split("+")[0] if "/" in mime else "png" + extension = "jpg" if ext_part == "jpeg" else (ext_part or "png") + b64 = base64.b64encode(data).decode("ascii") + uploaded.append(self._upload_image(f"data:{mime};base64,{b64}", f"image_{idx}.{extension}")) + parts: list[Any] = [] + for ref in uploaded: + parts.append({ + "content_type": "image_asset_pointer", + "asset_pointer": f"file-service://{ref['file_id']}", + "width": ref["width"], + "height": ref["height"], + "size_bytes": ref["file_size"], + }) + text = "".join(text_parts) + if text: + parts.append(text) + conversation_messages.append({ + "id": new_uuid(), + "author": {"role": role}, + "content": {"content_type": "multimodal_text", "parts": parts}, + "metadata": { + "attachments": [{ + "id": ref["file_id"], + "mimeType": ref["mime_type"], + "name": ref["file_name"], + "size": ref["file_size"], + "width": ref["width"], + "height": ref["height"], + } for ref in uploaded], + }, + }) + return conversation_messages + + def _conversation_payload(self, messages: list[Dict[str, Any]], model: str, timezone: str) -> Dict[str, Any]: + """把标准 messages 构造成 web 对话请求体。""" + return { + "action": "next", + "messages": self._api_messages_to_conversation_messages(messages), + "model": model, + "parent_message_id": new_uuid(), + "conversation_mode": {"kind": "primary_assistant"}, + "conversation_origin": None, + "force_paragen": False, + "force_paragen_model_slug": "", + "force_rate_limit": False, + "force_use_sse": True, + "history_and_training_disabled": True, + "reset_rate_limits": False, + "suggestions": [], + "supported_encodings": [], + "system_hints": [], + "timezone": timezone, + "timezone_offset_min": -480, + "variant_purpose": "comparison_implicit", + "websocket_request_id": new_uuid(), + "client_contextual_info": { + "is_dark_mode": False, + "time_since_loaded": 120, + "page_height": 900, + "page_width": 1400, + "pixel_ratio": 2, + "screen_height": 1440, + "screen_width": 2560, + }, + } + + def _image_model_slug(self, model: str) -> str: + """把标准图片模型名映射到底层 model slug。""" + _, base_model = split_image_model(model) + if not base_model: + return "auto" + if base_model == "gpt-image-2": + return "gpt-5-3" + if base_model == CODEX_IMAGE_MODEL: + return base_model + return "auto" + + def _image_headers(self, path: str, requirements: ChatRequirements, conduit_token: str = "", accept: str = "*/*") -> \ + Dict[str, str]: + """构造图片链路请求头。""" + headers = { + "Content-Type": "application/json", + "Accept": accept, + "OpenAI-Sentinel-Chat-Requirements-Token": requirements.token, + } + if requirements.proof_token: + headers["OpenAI-Sentinel-Proof-Token"] = requirements.proof_token + if conduit_token: + headers["X-Conduit-Token"] = conduit_token + if accept == "text/event-stream": + headers["X-Oai-Turn-Trace-Id"] = new_uuid() + return self._headers(path, headers) + + def _codex_responses_headers(self) -> Dict[str, str]: + return { + "Authorization": f"Bearer {self.access_token}", + "Content-Type": "application/json", + } + + def _ensure_codex_source_account(self) -> None: + account = account_service.get_account(self.access_token) + source_type = str((account or {}).get("source_type") or "web").strip().lower() + if source_type != "codex": + raise RuntimeError("codex responses endpoint requires a codex source account") + + @staticmethod + def _codex_image_input(prompt: str, images: list[str]) -> list[Dict[str, Any]]: + content: list[Dict[str, Any]] = [{"type": "input_text", "text": prompt}] + for image in images: + payload = image if image.startswith("data:image/") else f"data:image/png;base64,{image}" + content.append({"type": "input_image", "image_url": payload}) + return [{"role": "user", "content": content}] + + @staticmethod + def _codex_body_preview(body: Any, limit: int = 4000) -> str: + if isinstance(body, (dict, list)): + try: + text = json.dumps(body, ensure_ascii=False) + except Exception: + text = repr(body) + else: + text = str(body or "") + return text if len(text) <= limit else text[:limit] + "...[truncated]" + + @staticmethod + def _codex_event_image_result_lengths(value: Any) -> list[int]: + if isinstance(value, dict): + lengths: list[int] = [] + if value.get("type") == "image_generation_call" and isinstance(value.get("result"), str): + lengths.append(len(value["result"])) + for item in value.values(): + lengths.extend(OpenAIBackendAPI._codex_event_image_result_lengths(item)) + return lengths + if isinstance(value, list): + lengths: list[int] = [] + for item in value: + lengths.extend(OpenAIBackendAPI._codex_event_image_result_lengths(item)) + return lengths + return [] + + @staticmethod + def _codex_event_summary(event: Dict[str, Any]) -> Dict[str, Any]: + summary: Dict[str, Any] = { + "type": str(event.get("type") or ""), + "keys": list(event.keys())[:30], + } + for key in ("id", "status", "sequence_number", "response_id", "item_id", "output_index", "content_index"): + value = event.get(key) + if isinstance(value, (str, int, float, bool)) or value is None: + summary[key] = value + for key in ("response", "item", "output"): + value = event.get(key) + if isinstance(value, dict): + summary[f"{key}_type"] = value.get("type") + summary[f"{key}_status"] = value.get("status") + summary[f"{key}_keys"] = list(value.keys())[:30] + elif isinstance(value, list): + summary[f"{key}_len"] = len(value) + summary[f"{key}_types"] = [ + item.get("type") for item in value[:10] if isinstance(item, dict) + ] + error = event.get("error") + if isinstance(error, dict): + summary["error"] = { + key: error.get(key) + for key in ("type", "code", "message") + if error.get(key) is not None + } + delta = event.get("delta") + if isinstance(delta, str): + summary["delta_len"] = len(delta) + summary["delta_preview"] = delta[:200] + result_lengths = OpenAIBackendAPI._codex_event_image_result_lengths(event) + if result_lengths: + summary["image_result_lengths"] = result_lengths[:10] + return summary + + def _log_codex_response_failure( + self, + path: str, + status_code: int, + headers: Any, + payload: Dict[str, Any], + body: Any, + ) -> None: + request_headers = self._codex_responses_headers() + safe_request_headers = { + key: value for key, value in request_headers.items() if key.lower() != "authorization" + } + response_headers = dict(headers.items()) if hasattr(headers, "items") else dict(headers or {}) + tool = ((payload.get("tools") or [{}])[0]) if isinstance(payload.get("tools"), list) else {} + logger.warning({ + "event": "codex_responses_http_error", + "path": path, + "status_code": status_code, + "request": { + "model": payload.get("model"), + "tool_model": tool.get("model"), + "tool_action": tool.get("action"), + "size": tool.get("size"), + "quality": tool.get("quality"), + "image_input_count": max(len((payload.get("input") or [{}])[0].get("content") or []) - 1, 0), + "prompt_preview": self._codex_body_preview( + (((payload.get("input") or [{}])[0].get("content") or [{}])[0].get("text") or ""), + 500, + ), + "headers": safe_request_headers, + }, + "response": { + "headers": response_headers, + "body_preview": self._codex_body_preview(body), + }, + }) + + @staticmethod + def _iter_codex_response_events(raw: Any) -> Iterator[Dict[str, Any]]: + content_type = str(raw.headers.get("content-type") or "").lower() + text = raw.read().decode("utf-8", "replace") + status_code = getattr(raw, "status", None) + parse_errors: list[str] = [] + events: list[Dict[str, Any]] = [] + if "application/json" in content_type: + try: + data = json.loads(text) + if isinstance(data, dict): + events.append(data) + except Exception as exc: + parse_errors.append(str(exc)) + else: + lines: list[str] = [] + for line in text.splitlines() + [""]: + if not line: + if lines: + payload_text = "\n".join(lines).strip() + if payload_text and payload_text != "[DONE]": + try: + data = json.loads(payload_text) + except Exception as exc: + parse_errors.append(str(exc)) + data = None + if isinstance(data, dict): + events.append(data) + lines = [] + elif line.startswith("data:"): + lines.append(line[5:].lstrip()) + + event_types: Dict[str, int] = {} + image_result_lengths: list[int] = [] + for event in events: + event_type = str(event.get("type") or "") + event_types[event_type] = event_types.get(event_type, 0) + 1 + image_result_lengths.extend(OpenAIBackendAPI._codex_event_image_result_lengths(event)) + logger.info({ + "event": "codex_responses_response_debug", + "status_code": status_code, + "content_type": content_type, + "response_text_len": len(text), + "event_count": len(events), + "event_types": event_types, + "image_result_lengths": image_result_lengths[:10], + "parse_error_count": len(parse_errors), + "parse_errors": parse_errors[:5], + "event_summaries": [OpenAIBackendAPI._codex_event_summary(event) for event in events[:30]], + "event_previews": [ + OpenAIBackendAPI._codex_body_preview(event, 1500) + for event in events[:10] + ] if not image_result_lengths else [], + "body_preview": text[:1000] if not events else "", + }) + for event in events: + yield event + + def iter_codex_image_response_events( + self, + prompt: str, + images: list[str] | None = None, + size: str | None = None, + quality: str = "auto", + ) -> Iterator[Dict[str, Any]]: + if not self.access_token: + raise RuntimeError("access_token is required for codex image endpoints") + self._ensure_codex_source_account() + path = "/backend-api/codex/responses" + payload = { + "model": CODEX_RESPONSES_MODEL, + "instructions": CODEX_RESPONSES_INSTRUCTIONS, + "store": False, + "input": self._codex_image_input(prompt, images or []), + "tools": [{ + "type": "image_generation", + "model": "gpt-image-2", + "action": "edit" if images else "generate", + "size": str(size or "1024x1024"), + "quality": str(quality or "auto"), + "output_format": "png", + }], + "tool_choice": {"type": "image_generation"}, + "stream": True, + } + request = urllib.request.Request( + self.base_url + path, + json.dumps(payload).encode(), + self._codex_responses_headers(), + method="POST", + ) + account = account_service.get_account(self.access_token) or {} + token_payload = account_service._decode_jwt_payload(self.access_token) + auth_claim = token_payload.get("https://api.openai.com/auth") + auth_claim = auth_claim if isinstance(auth_claim, dict) else {} + tool = payload["tools"][0] + logger.info({ + "event": "codex_responses_request_debug", + "url": self.base_url + path, + "transport": "urllib.request", + "timeout_secs": 1200, + "account_email": str(account.get("email") or "").strip(), + "source_type": str(account.get("source_type") or "").strip(), + "account_type": str(account.get("type") or "").strip(), + "token_claims": { + "jti": token_payload.get("jti"), + "iat": token_payload.get("iat"), + "exp": token_payload.get("exp"), + "client_id": token_payload.get("client_id"), + "chatgpt_account_id": auth_claim.get("chatgpt_account_id"), + "chatgpt_plan_type": auth_claim.get("chatgpt_plan_type"), + "localhost": auth_claim.get("localhost"), + }, + "request": { + "model": payload.get("model"), + "tool_model": tool.get("model"), + "tool_action": tool.get("action"), + "size": tool.get("size"), + "quality": tool.get("quality"), + "output_format": tool.get("output_format"), + "stream": payload.get("stream"), + "image_input_count": max(len((payload.get("input") or [{}])[0].get("content") or []) - 1, 0), + "prompt_preview": self._codex_body_preview( + (((payload.get("input") or [{}])[0].get("content") or [{}])[0].get("text") or ""), + 500, + ), + }, + "headers": { + key: value for key, value in self._codex_responses_headers().items() + if key.lower() != "authorization" + }, + }) + try: + with urllib.request.urlopen(request, timeout=1200) as raw: + yield from self._iter_codex_response_events(raw) + except urllib.error.HTTPError as error: + body_text = error.read().decode("utf-8", "replace") + body: Any = body_text + try: + body = json.loads(body_text) + except Exception: + pass + self._log_codex_response_failure(path, error.code, error.headers, payload, body) + retry_after_header = error.headers.get("Retry-After") if error.headers else None + retry_after = int(retry_after_header) if str(retry_after_header or "").isdigit() else None + raise UpstreamHTTPError(path, error.code, body, retry_after=retry_after) from error + + def _prepare_image_conversation(self, prompt: str, requirements: ChatRequirements, model: str) -> str: + """为图片生成准备 conduit token。""" + path = "/backend-api/f/conversation/prepare" + payload = { + "action": "next", + "fork_from_shared_post": False, + "parent_message_id": new_uuid(), + "model": self._image_model_slug(model), + "client_prepare_state": "success", + "timezone_offset_min": -480, + "timezone": "Asia/Shanghai", + "conversation_mode": {"kind": "primary_assistant"}, + "system_hints": ["picture_v2"], + "partial_query": { + "id": new_uuid(), + "author": {"role": "user"}, + "content": {"content_type": "text", "parts": [prompt]}, + }, + "supports_buffering": True, + "supported_encodings": ["v1"], + "client_contextual_info": {"app_name": "chatgpt.com"}, + } + response = self.session.post( + self.base_url + path, + headers=self._image_headers(path, requirements), + json=payload, + timeout=60, + ) + ensure_ok(response, path) + return response.json().get("conduit_token", "") + + def _decode_image_base64(self, image: str) -> bytes: + """把 base64 图片字符串或本地路径解码成二进制。""" + if ( + image + and len(image) < 512 + and not image.startswith("data:") + and "\n" not in image + and "\r" not in image + ): + file_path = Path(os.path.expanduser(image)) + if file_path.exists() and file_path.is_file(): + return file_path.read_bytes() + payload = image.split(",", 1)[1] if image.startswith("data:") and "," in image else image + return base64.b64decode(payload) + + def _upload_image(self, image: str, file_name: str = "image.png") -> Dict[str, Any]: + """上传一张 base64 图片,返回底层文件元数据。""" + data = self._decode_image_base64(image) + if ( + image + and len(image) < 512 + and not image.startswith("data:") + and "\n" not in image + and "\r" not in image + ): + candidate_path = Path(os.path.expanduser(image)) + if candidate_path.exists() and candidate_path.is_file(): + file_name = candidate_path.name + image = Image.open(BytesIO(data)) + width, height = image.size + mime_type = Image.MIME.get(image.format, "image/png") + path = "/backend-api/files" + response = self.session.post( + self.base_url + path, + headers=self._headers(path, {"Content-Type": "application/json", "Accept": "application/json"}), + json={"file_name": file_name, "file_size": len(data), "use_case": "multimodal", "width": width, + "height": height}, + timeout=60, + ) + ensure_ok(response, path) + upload_meta = response.json() + time.sleep(0.5) + response = self.session.put( + upload_meta["upload_url"], + headers={ + "Content-Type": mime_type, + "x-ms-blob-type": "BlockBlob", + "x-ms-version": "2020-04-08", + "Origin": self.base_url, + "Referer": self.base_url + "/", + "User-Agent": self.user_agent, + "Accept": "application/json, text/plain, */*", + "Accept-Language": "en-US,en;q=0.8", + }, + data=data, + timeout=120, + ) + ensure_ok(response, "image_upload") + path = f"/backend-api/files/{upload_meta['file_id']}/uploaded" + response = self.session.post( + self.base_url + path, + headers=self._headers(path, {"Content-Type": "application/json", "Accept": "application/json"}), + data="{}", + timeout=60, + ) + ensure_ok(response, path) + return { + "file_id": upload_meta["file_id"], + "file_name": file_name, + "file_size": len(data), + "mime_type": mime_type, + "width": width, + "height": height, + } + + def _start_image_generation(self, prompt: str, requirements: ChatRequirements, conduit_token: str, model: str, + references: Optional[list[Dict[str, Any]]] = None) -> requests.Response: + """启动图片生成或编辑的 SSE 请求。""" + references = references or [] + parts = [{ + "content_type": "image_asset_pointer", + "asset_pointer": f"file-service://{item['file_id']}", + "width": item["width"], + "height": item["height"], + "size_bytes": item["file_size"], + } for item in references] + parts.append(prompt) + content = {"content_type": "multimodal_text", "parts": parts} if references else {"content_type": "text", + "parts": [prompt]} + metadata = { + "developer_mode_connector_ids": [], + "selected_github_repos": [], + "selected_all_github_repos": False, + "system_hints": ["picture_v2"], + "serialization_metadata": {"custom_symbol_offsets": []}, + } + if references: + metadata["attachments"] = [{ + "id": item["file_id"], + "mimeType": item["mime_type"], + "name": item["file_name"], + "size": item["file_size"], + "width": item["width"], + "height": item["height"], + } for item in references] + payload = { + "action": "next", + "messages": [{ + "id": new_uuid(), + "author": {"role": "user"}, + "create_time": time.time(), + "content": content, + "metadata": metadata, + }], + "parent_message_id": new_uuid(), + "model": self._image_model_slug(model), + "client_prepare_state": "sent", + "timezone_offset_min": -480, + "timezone": "Asia/Shanghai", + "conversation_mode": {"kind": "primary_assistant"}, + "enable_message_followups": True, + "system_hints": ["picture_v2"], + "supports_buffering": True, + "supported_encodings": ["v1"], + "client_contextual_info": { + "is_dark_mode": False, + "time_since_loaded": 1200, + "page_height": 1072, + "page_width": 1724, + "pixel_ratio": 1.2, + "screen_height": 1440, + "screen_width": 2560, + "app_name": "chatgpt.com", + }, + "paragen_cot_summary_display_override": "allow", + "force_parallel_switch": "auto", + } + path = "/backend-api/f/conversation" + response = self.session.post( + self.base_url + path, + headers=self._image_headers(path, requirements, conduit_token, "text/event-stream"), + json=payload, + timeout=300, + stream=True, + ) + ensure_ok(response, path) + return response + + def _get_conversation(self, conversation_id: str) -> Dict[str, Any]: + """获取完整 conversation 详情。""" + path = f"/backend-api/conversation/{conversation_id}" + response = self.session.get(self.base_url + path, headers=self._headers(path, {"Accept": "application/json"}), + timeout=60) + ensure_ok(response, path) + return response.json() + + def _extract_image_tool_records(self, data: Dict[str, Any]) -> list[Dict[str, Any]]: + """从 conversation 明细里提取图片工具输出记录。""" + mapping = data.get("mapping") or {} + file_pat = re.compile(r"file-service://([A-Za-z0-9_-]+)") + sed_pat = re.compile(r"sediment://([A-Za-z0-9_-]+)") + records = [] + for message_id, node in mapping.items(): + message = (node or {}).get("message") or {} + author = message.get("author") or {} + metadata = message.get("metadata") or {} + content = message.get("content") or {} + if author.get("role") != "tool": + continue + if content.get("content_type") != "multimodal_text": + continue + file_ids, sediment_ids = [], [] + for part in content.get("parts") or []: + text = (part.get("asset_pointer") or "") if isinstance(part, dict) else ( + part if isinstance(part, str) else "") + for hit in file_pat.findall(text): + if hit not in file_ids: + file_ids.append(hit) + for hit in sed_pat.findall(text): + if hit not in sediment_ids: + sediment_ids.append(hit) + if metadata.get("async_task_type") != "image_gen" and not file_ids and not sediment_ids: + continue + records.append( + {"message_id": message_id, "create_time": message.get("create_time") or 0, "file_ids": file_ids, + "sediment_ids": sediment_ids}) + return sorted(records, key=lambda item: item["create_time"]) + + def _poll_image_results(self, conversation_id: str, timeout_secs: float = 120.0) -> tuple[list[str], list[str]]: + """Poll the conversation document until image file ids appear or budget runs out. + + - Sleeps image_poll_initial_wait_secs first (default 10s, +jitter). ChatGPT + image generation takes ~30s; polling immediately wastes requests and trips + a transient 429 the upstream returns within ~200ms of the SSE stream + closing (the conversation document is not yet committed). + - Subsequent polls are image_poll_interval_secs apart (default 10s). + - On upstream 429 / 5xx or network errors, backs off exponentially + (capped at 16s, +jitter) honoring Retry-After when present. + - All sleeps stay within timeout_secs; on exhaustion raises ImagePollTimeoutError. + """ + start = time.time() + attempt = 0 + interval = float(config.image_poll_interval_secs) + initial_wait = float(config.image_poll_initial_wait_secs) + logger.info({ + "event": "image_poll_start", + "conversation_id": conversation_id, + "timeout_secs": timeout_secs, + "initial_wait_secs": initial_wait, + "interval_secs": interval, + }) + + def _remaining() -> float: + return timeout_secs - (time.time() - start) + + if initial_wait > 0: + jitter = random.uniform(0, min(2.0, initial_wait * 0.2)) + sleep_for = min(initial_wait + jitter, max(0.0, _remaining())) + if sleep_for > 0: + time.sleep(sleep_for) + + def _retry_sleep(reason: str, status_code: int | None, error: str | None, retry_after: int | None) -> bool: + # retry_after=0 means "retry immediately" — must not be coerced via falsy check. + base = retry_after if retry_after is not None else min(2 ** min(attempt, 4), 16) + backoff = base + random.uniform(0, 0.5) + remaining = _remaining() + if remaining <= 0: + return False + sleep_for = min(backoff, remaining) + log_payload: Dict[str, Any] = { + "event": "image_poll_retry", + "conversation_id": conversation_id, + "attempt": attempt, + "reason": reason, + "sleep_secs": round(sleep_for, 2), + } + if status_code is not None: + log_payload["status_code"] = status_code + if error is not None: + log_payload["error"] = error + logger.warning(log_payload) + time.sleep(sleep_for) + return True + + while _remaining() > 0: + attempt += 1 + try: + conversation = self._get_conversation(conversation_id) + except UpstreamHTTPError as exc: + if exc.status_code in (429, 500, 502, 503, 504): + if _retry_sleep("upstream_status", exc.status_code, None, exc.retry_after): + continue + break + raise + except requests.exceptions.RequestException as exc: + if _retry_sleep("network", None, str(exc), None): + continue + break + + file_ids, sediment_ids = [], [] + for record in self._extract_image_tool_records(conversation): + for file_id in record["file_ids"]: + if file_id not in file_ids: + file_ids.append(file_id) + for sediment_id in record["sediment_ids"]: + if sediment_id not in sediment_ids: + sediment_ids.append(sediment_id) + logger.debug({"event": "image_poll_check", "conversation_id": conversation_id, "attempt": attempt, + "file_ids": file_ids, "sediment_ids": sediment_ids}) + if file_ids: + logger.info({"event": "image_poll_hit", "conversation_id": conversation_id, "file_ids": file_ids, + "sediment_ids": sediment_ids}) + return file_ids, sediment_ids + if sediment_ids: + logger.info({"event": "image_poll_hit", "conversation_id": conversation_id, "file_ids": [], + "sediment_ids": sediment_ids}) + return [], sediment_ids + logger.debug({"event": "image_poll_wait", "conversation_id": conversation_id, + "elapsed_secs": round(time.time() - start, 1)}) + wait = min(interval, max(0.0, _remaining())) + if wait > 0: + time.sleep(wait) + logger.info({ + "event": "image_poll_timeout", + "conversation_id": conversation_id, + "timeout_secs": timeout_secs, + "attempts_made": attempt, + # attempts_made == 0 means the initial_wait consumed the entire budget — no HTTP attempted. + "initial_wait_exhausted_budget": attempt == 0, + }) + raise ImagePollTimeoutError( + f"ChatGPT 生图超时(已等待 {timeout_secs} 秒)。" + f"当前超时阈值可在 config.json 中调大 image_poll_timeout_secs," + f"也可能是账号被限流或生图队列拥堵导致。" + ) + + def _get_file_download_url(self, file_id: str) -> str: + """获取文件下载地址。""" + path = f"/backend-api/files/{file_id}/download" + response = self.session.get(self.base_url + path, headers=self._headers(path, {"Accept": "application/json"}), + timeout=60) + ensure_ok(response, path) + data = response.json() + return data.get("download_url") or data.get("url") or "" + + def _get_attachment_download_url(self, conversation_id: str, attachment_id: str) -> str: + """通过 conversation 附件接口获取下载地址。""" + path = f"/backend-api/conversation/{conversation_id}/attachment/{attachment_id}/download" + response = self.session.get(self.base_url + path, headers=self._headers(path, {"Accept": "application/json"}), + timeout=60) + ensure_ok(response, path) + data = response.json() + return data.get("download_url") or data.get("url") or "" + + def _resolve_image_urls(self, conversation_id: str, file_ids: list[str], sediment_ids: list[str]) -> list[str]: + """把图片结果 id 解析成可下载 URL。""" + urls = [] + skip_patterns = {"file_upload"} + for file_id in file_ids: + if file_id in skip_patterns: + logger.debug({ + "event": "image_file_id_skipped", + "source": "file", + "conversation_id": conversation_id, + "id": file_id, + }) + continue + try: + url = self._get_file_download_url(file_id) + except Exception as exc: + logger.debug({ + "event": "image_download_url_failed", + "source": "file", + "conversation_id": conversation_id, + "id": file_id, + "error": repr(exc), + }) + continue + if url: + urls.append(url) + else: + logger.debug({ + "event": "image_download_url_empty", + "source": "file", + "conversation_id": conversation_id, + "id": file_id, + }) + if urls or not conversation_id: + logger.debug({ + "event": "image_urls_resolved", + "conversation_id": conversation_id, + "file_ids": file_ids, + "sediment_ids": sediment_ids, + "urls": urls, + }) + return urls + for sediment_id in sediment_ids: + try: + url = self._get_attachment_download_url(conversation_id, sediment_id) + except Exception as exc: + logger.debug({ + "event": "image_download_url_failed", + "source": "sediment", + "conversation_id": conversation_id, + "id": sediment_id, + "error": repr(exc), + }) + continue + if url: + urls.append(url) + else: + logger.debug({ + "event": "image_download_url_empty", + "source": "sediment", + "conversation_id": conversation_id, + "id": sediment_id, + }) + logger.debug({ + "event": "image_urls_resolved", + "conversation_id": conversation_id, + "file_ids": file_ids, + "sediment_ids": sediment_ids, + "urls": urls, + }) + return urls + + def resolve_conversation_image_urls( + self, + conversation_id: str, + file_ids: list[str], + sediment_ids: list[str], + poll: bool = True, + ) -> list[str]: + file_ids = [item for item in file_ids if item != "file_upload"] + sediment_ids = list(sediment_ids) + if poll and conversation_id and not file_ids and not sediment_ids: + logger.info({"event": "image_resolve_poll_needed", "conversation_id": conversation_id}) + polled_file_ids, polled_sediment_ids = self._poll_image_results(conversation_id, + config.image_poll_timeout_secs) + file_ids.extend(item for item in polled_file_ids if item and item not in file_ids) + sediment_ids.extend(item for item in polled_sediment_ids if item and item not in sediment_ids) + return self._resolve_image_urls(conversation_id, file_ids, sediment_ids) + + def download_image_bytes(self, urls: list[str]) -> list[bytes]: + images = [] + for url in urls: + response = self.session.get(url, timeout=120) + ensure_ok(response, "image_download") + images.append(response.content) + return images + + def stream_conversation( + self, + messages: Optional[list[Dict[str, Any]]] = None, + model: str = "auto", + prompt: str = "", + images: Optional[list[str]] = None, + system_hints: Optional[list[str]] = None, + ) -> Iterator[str]: + system_hints = system_hints or [] + if "picture_v2" in system_hints: + yield from self._stream_picture_conversation(prompt, model, images or []) + return + + normalized = messages or [{"role": "user", "content": prompt}] + self._bootstrap() + requirements = self._get_chat_requirements() + path, timezone = self._chat_target() + payload = self._conversation_payload(normalized, model, timezone) + response = self.session.post( + self.base_url + path, + headers=self._conversation_headers(path, requirements), + json=payload, + timeout=300, + stream=True, + ) + ensure_ok(response, path) + try: + yield from iter_sse_payloads(response) + finally: + response.close() + + def _stream_picture_conversation( + self, + prompt: str, + model: str, + images: list[str], + ) -> Iterator[str]: + if not self.access_token: + raise RuntimeError("access_token is required for image endpoints") + references = [self._upload_image(image, f"image_{idx}.png") for idx, image in enumerate(images, start=1)] + self._bootstrap() + requirements = self._get_chat_requirements() + conduit_token = self._prepare_image_conversation(prompt, requirements, model) + response = self._start_image_generation(prompt, requirements, conduit_token, model, references) + try: + yield from iter_sse_payloads(response) + finally: + response.close() + + def _bootstrap(self) -> None: + """预热首页,并提取 PoW 相关脚本引用。""" + response = self.session.get( + self.base_url + "/", + headers=self._bootstrap_headers(), + timeout=30, + ) + ensure_ok(response, "bootstrap") + self.pow_script_sources, self.pow_data_build = parse_pow_resources(response.text) + if not self.pow_script_sources: + self.pow_script_sources = [DEFAULT_POW_SCRIPT] + + def _get_chat_requirements(self) -> ChatRequirements: + """获取当前模式对话所需的 sentinel token。""" + path = "/backend-api/sentinel/chat-requirements" if self.access_token else "/backend-anon/sentinel/chat-requirements" + context = "auth_chat_requirements" if self.access_token else "noauth_chat_requirements" + body = {"p": build_legacy_requirements_token(self.user_agent, self.pow_script_sources, self.pow_data_build)} + response = self.session.post( + self.base_url + path, + headers=self._headers(path, {"Content-Type": "application/json"}), + json=body, + timeout=30, + ) + ensure_ok(response, context) + requirements = self._build_requirements(response.json(), "" if self.access_token else body["p"]) + if not requirements.token: + message = "missing auth chat requirements token" if self.access_token else "missing chat requirements token" + raise RuntimeError(f"{message}: {requirements.raw_finalize}") + return requirements + + def _chat_target(self) -> tuple[str, str]: + if self.access_token: + return "/backend-api/conversation", "Asia/Shanghai" + return "/backend-anon/conversation", "America/Los_Angeles" + + def list_models(self) -> Dict[str, Any]: + """返回当前模式下可用模型,格式对齐 OpenAI `/v1/models`。""" + self._bootstrap() + path = "/backend-api/models?history_and_training_disabled=false" if self.access_token else ( + "/backend-anon/models?iim=false&is_gizmo=false" + ) + route = "/backend-api/models" if self.access_token else "/backend-anon/models" + context = "auth_models" if self.access_token else "anon_models" + response = self.session.get( + self.base_url + path, + headers=self._headers(route), + timeout=30, + ) + ensure_ok(response, context) + data = [] + seen = set() + for item in response.json().get("models", []): + if not isinstance(item, dict): + continue + slug = str(item.get("slug", "")).strip() + if not slug or slug in seen: + continue + seen.add(slug) + data.append({ + "id": slug, + "object": "model", + "created": int(item.get("created") or 0), + "owned_by": str(item.get("owned_by") or "chatgpt"), + "permission": [], + "root": slug, + "parent": None, + }) + data.sort(key=lambda item: item["id"]) + return {"object": "list", "data": data} diff --git a/services/protocol/__init__.py b/services/protocol/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..1b57d842a9b9267064a65e9216aa3a073beef0b6 --- /dev/null +++ b/services/protocol/__init__.py @@ -0,0 +1,2 @@ +"""Protocol converters for OpenAI-compatible endpoints.""" + diff --git a/services/protocol/anthropic_v1_messages.py b/services/protocol/anthropic_v1_messages.py new file mode 100644 index 0000000000000000000000000000000000000000..a5113b0575eccb446d00b119067a90e271db67b5 --- /dev/null +++ b/services/protocol/anthropic_v1_messages.py @@ -0,0 +1,306 @@ +from __future__ import annotations + +import html +import json +import re +import time +import uuid +from collections.abc import Callable, Iterable, Iterator +from dataclasses import dataclass +from typing import Any + +from services.account_service import account_service +from services.openai_backend_api import OpenAIBackendAPI +from services.protocol.conversation import count_message_tokens, count_text_tokens, normalize_messages +from services.protocol.openai_v1_chat_complete import collect_chat_content, stream_text_chat_completion + +XML_TOOL_RULE = """Tool output adapter: when calling tools, output ONLY this XML and no prose/markdown: +TOOL_NAME""" + + +@dataclass +class MessageRequest: + backend: OpenAIBackendAPI + messages: list[dict[str, Any]] + model: str + tools: Any = None + + +def _tool_meta(tool: dict[str, object]) -> tuple[str, str, object]: + fn = tool.get("function") if isinstance(tool.get("function"), dict) else {} + name = str(tool.get("name") or fn.get("name") or "").strip() + desc = str(tool.get("description") or fn.get("description") or "").strip() + schema = tool.get("input_schema") or tool.get("parameters") or fn.get("input_schema") or fn.get("parameters") or {} + return name, desc, schema + + +def build_tool_prompt(tools: object) -> str: + if not isinstance(tools, list): + return "" + blocks = [] + for tool in tools: + if not isinstance(tool, dict): + continue + name, desc, schema = _tool_meta(tool) + if name: + blocks.append(f"Tool: {name}\nDescription: {desc}\nParameters: {json.dumps(schema, ensure_ascii=False)}") + if not blocks: + return "" + return "Available tools:\n" + "\n".join(blocks) + """ + +Tool use rules: +- If the user asks to list/read/search files, inspect project state, run a command, or answer from local code, you MUST call a suitable tool first. Do not say you cannot access files. +- To call tools, output ONLY XML and no prose/markdown: +TOOL_NAME +- Put parameters under using the exact schema names. +""".strip() + + +def merge_system(system: object, extra: str) -> object: + system = compact_system(system) + if _has_claude_code_system(system): + extra = XML_TOOL_RULE + if not extra: + return system + if isinstance(system, str) and system.strip(): + return f"{system.strip()}\n\n{extra}" + if isinstance(system, list): + return [*system, {"type": "text", "text": extra}] + return extra + + +def _has_claude_code_system(system: object) -> bool: + if isinstance(system, str): + return "You are Claude Code" in system + if isinstance(system, list): + return any(isinstance(item, dict) and "You are Claude Code" in str(item.get("text") or "") for item in system) + return False + + +def compact_system(system: object) -> object: + if isinstance(system, str): + return _compact_system_text(system) + if isinstance(system, list): + result = [] + for item in system: + if isinstance(item, dict) and str(item.get("type") or "") == "text": + copied = dict(item) + copied["text"] = _compact_system_text(str(item.get("text") or "")) + result.append(copied) + else: + result.append(item) + return result + return system + + +def _compact_system_text(text: str) -> str: + return text or "" + + +def _compact_message_text(text: str) -> str: + return text or "" + + +def preprocess_payload(payload: dict[str, object], text_mapper: Callable[[str], str] | None = None) -> dict[str, object]: + payload["messages"] = preprocess_messages(payload.get("messages"), text_mapper) + payload["system"] = merge_system(payload.get("system"), build_tool_prompt(payload.get("tools"))) + return payload + + +def message_request(body: dict[str, Any]) -> MessageRequest: + payload = preprocess_payload(dict(body)) + return MessageRequest( + backend=OpenAIBackendAPI(access_token=account_service.get_text_access_token()), + messages=normalize_messages(payload.get("messages"), payload.get("system")), + model=str(payload.get("model") or "auto").strip() or "auto", + tools=payload.get("tools"), + ) + + +def preprocess_messages(messages: object, text_mapper: Callable[[str], str] | None = None) -> object: + if not isinstance(messages, list): + return messages + mapper = text_mapper or (lambda text: text) + result = [] + for message in messages: + if not isinstance(message, dict): + continue + item = dict(message) + content = item.get("content") + if isinstance(content, str): + item["content"] = _compact_message_text(mapper(content)) + elif isinstance(content, list): + item["content"] = [_preprocess_block(block, mapper) for block in content] + result.append(item) + return result + + +def _preprocess_block(block: object, text_mapper: Callable[[str], str]) -> object: + if not isinstance(block, dict): + return block + block_type = str(block.get("type") or "") + if block_type == "text": + item = dict(block) + item["text"] = _compact_message_text(text_mapper(str(block.get("text") or ""))) + return item + if block_type == "tool_use": + return {"type": "text", "text": f"{block.get('name') or ''}{json.dumps(block.get('input') or {}, ensure_ascii=False)}"} + if block_type == "tool_result": + return {"type": "text", "text": f"Tool result {block.get('tool_use_id') or ''}: {block.get('content') or ''}"} + return block + + +def message_response(model: str, text: str, input_tokens: int, output_tokens: int, tools: object = None) -> dict[str, object]: + content, stop_reason = content_blocks(text, tools) + return { + "id": f"msg_{uuid.uuid4()}", + "type": "message", + "role": "assistant", + "model": model, + "content": content, + "stop_reason": stop_reason, + "stop_sequence": None, + "usage": {"input_tokens": input_tokens, "output_tokens": output_tokens}, + } + + +def content_blocks(text: str, tools: object = None) -> tuple[list[dict[str, object]], str]: + calls = parse_tool_calls(text) if isinstance(tools, list) and tools else [] + text = strip_tool_markup(text) + if calls: + content = ([{"type": "text", "text": text}] if text else []) + [{"type": "tool_use", "id": f"toolu_{uuid.uuid4()}", "name": name, "input": args} for name, args in calls] + return content, "tool_use" + return [{"type": "text", "text": text}], "end_turn" + + +def strip_tool_markup(text: str) -> str: + return re.sub(r"(?is)]*>.*?|]*>.*?|]*>.*?|]*>.*?", "", text or "").strip() + + +def streamable_text(text: str) -> str: + text = text or "" + match = re.search(r"(?is) list[tuple[str, dict[str, object]]]: + text = re.sub(r"(?is)```.*?```", "", text or "").strip() + blocks = re.findall(r"(?is)]*>(.*?)|]*>(.*?)|]*>(.*?)", text) + result = [] + for block in (next((part for part in match if part), "") for match in blocks): + name = xml_value(block, "tool_name") or xml_value(block, "name") or xml_value(block, "function") + params = xml_value(block, "parameters") or xml_value(block, "input") or xml_value(block, "arguments") or "{}" + if name: + result.append((name, parse_tool_params(params))) + return result + + +def xml_value(text: str, tag: str) -> str: + match = re.search(rf"(?is)<{tag}\b[^>]*>(.*?)", text) + if not match: + return "" + value = match.group(1).strip() + cdata = re.fullmatch(r"(?is)", value) + return html.unescape(cdata.group(1) if cdata else value).strip() + + +def parse_tool_params(raw: str) -> dict[str, object]: + raw = raw.strip() + try: + parsed = json.loads(raw) + return parsed if isinstance(parsed, dict) else {} + except Exception: + return {m.group(1): parse_tool_value(m.group(2)) for m in re.finditer(r"(?is)<([\w.-]+)\b[^>]*>(.*?)", raw)} + + +def parse_tool_value(raw: str) -> object: + value = xml_value(f"{raw}", "x") + try: + return json.loads(value) + except Exception: + return value + + +def stream_events(chunks: Iterable[dict[str, object]], model: str, input_tokens: int, output_tokens: Callable[[str], int], tools: object = None) -> Iterator[dict[str, object]]: + message_id = f"msg_{uuid.uuid4()}" + created = int(time.time()) + current_text = "" + streamed_text = "" + tool_mode = isinstance(tools, list) and bool(tools) + tool_started = False + text_open = False + yield {"type": "message_start", "message": {"id": message_id, "type": "message", "role": "assistant", "model": model, "content": [], "stop_reason": None, "stop_sequence": None, "usage": {"input_tokens": input_tokens, "output_tokens": 0}}} + if not tool_mode: + text_open = True + yield {"type": "content_block_start", "index": 0, "content_block": {"type": "text", "text": ""}} + for chunk in chunks: + choice = (chunk.get("choices") or [{}])[0] + delta = choice.get("delta") or {} + text_delta = delta.get("content", "") if isinstance(delta, dict) else "" + if text_delta: + current_text += text_delta + if not tool_started: + visible_text = current_text if not tool_mode else streamable_text(current_text) + if visible_text.startswith(streamed_text): + text_delta = visible_text[len(streamed_text):] + if text_delta: + if not text_open: + text_open = True + yield {"type": "content_block_start", "index": 0, "content_block": {"type": "text", "text": ""}} + streamed_text = visible_text + yield {"type": "content_block_delta", "index": 0, "delta": {"type": "text_delta", "text": text_delta}} + tool_started = tool_mode and visible_text != current_text + if choice.get("finish_reason"): + content, stop_reason = content_blocks(current_text, tools) + if text_open: + yield {"type": "content_block_stop", "index": 0} + if stop_reason == "tool_use": + start_index = 1 if text_open else 0 + if content and content[0]["type"] == "text": + remaining = str(content[0].get("text") or "")[len(streamed_text):] + if remaining: + if not text_open: + yield {"type": "content_block_start", "index": 0, "content_block": {"type": "text", "text": ""}} + yield {"type": "content_block_delta", "index": 0, "delta": {"type": "text_delta", "text": remaining}} + if not text_open: + yield {"type": "content_block_stop", "index": 0} + start_index = 1 + content = content[1:] + yield from _stream_buffered_blocks(content, start_index) + yield {"type": "message_delta", "delta": {"stop_reason": stop_reason, "stop_sequence": None}, "usage": {"output_tokens": output_tokens(current_text)}} + break + yield {"type": "message_stop", "created": created} + + +def _stream_buffered_blocks(content: list[dict[str, object]], start_index: int = 0) -> Iterator[dict[str, object]]: + for offset, block in enumerate(content): + index = start_index + offset + if block["type"] == "tool_use": + start = {"type": "tool_use", "id": block["id"], "name": block["name"], "input": {}} + delta = {"type": "input_json_delta", "partial_json": json.dumps(block.get("input") or {}, ensure_ascii=False)} + else: + start = {"type": "text", "text": ""} + delta = {"type": "text_delta", "text": block.get("text") or ""} + yield {"type": "content_block_start", "index": index, "content_block": start} + yield {"type": "content_block_delta", "index": index, "delta": delta} + yield {"type": "content_block_stop", "index": index} + + +def handle(body: dict[str, Any]) -> dict[str, Any] | Iterator[dict[str, Any]]: + request = message_request(body) + if body.get("stream"): + return stream_events( + stream_text_chat_completion(request.backend, request.messages, request.model), + request.model, + count_message_tokens(request.messages, request.model), + lambda text: count_text_tokens(text, request.model), + request.tools, + ) + text = collect_chat_content(stream_text_chat_completion(request.backend, request.messages, request.model)) + return message_response( + request.model, + text, + count_message_tokens(request.messages, request.model), + count_text_tokens(text, request.model), + request.tools, + ) diff --git a/services/protocol/chat_completion_cache.py b/services/protocol/chat_completion_cache.py new file mode 100644 index 0000000000000000000000000000000000000000..f28cc9840b0a86ebbb5b751fadd937c28590eaa6 --- /dev/null +++ b/services/protocol/chat_completion_cache.py @@ -0,0 +1,236 @@ +from __future__ import annotations + +import copy +import hashlib +import json +import threading +import time +from dataclasses import dataclass, field +from typing import Any, Callable, Iterable, Iterator + +from services.config import config + +CACHEABLE_TEXT_KEYS = { + "frequency_penalty", + "max_completion_tokens", + "max_tokens", + "metadata", + "model", + "presence_penalty", + "reasoning_effort", + "response_format", + "seed", + "stop", + "temperature", + "tool_choice", + "tools", + "top_p", + "user", +} + + +@dataclass +class CacheEntry: + expires_at: float + value: Any + + +@dataclass +class InflightCall: + condition: threading.Condition = field(default_factory=lambda: threading.Condition(threading.RLock())) + done: bool = False + value: Any = None + error: BaseException | None = None + + +def _json_safe(value: Any) -> Any: + if isinstance(value, bytes): + return {"__bytes_sha256__": hashlib.sha256(value).hexdigest(), "length": len(value)} + if isinstance(value, bytearray): + data = bytes(value) + return {"__bytes_sha256__": hashlib.sha256(data).hexdigest(), "length": len(data)} + if isinstance(value, dict): + return {str(key): _json_safe(item) for key, item in value.items()} + if isinstance(value, (list, tuple)): + return [_json_safe(item) for item in value] + return value + + +def canonical_body(body: dict[str, Any], messages: list[dict[str, Any]], *, stream: bool) -> dict[str, Any]: + payload = {key: body.get(key) for key in CACHEABLE_TEXT_KEYS if key in body} + payload["messages"] = messages + payload["stream"] = bool(stream) + return payload + + +def cache_key(body: dict[str, Any], messages: list[dict[str, Any]], *, stream: bool) -> str: + encoded = json.dumps( + _json_safe(canonical_body(body, messages, stream=stream)), + ensure_ascii=False, + sort_keys=True, + separators=(",", ":"), + ).encode("utf-8") + return hashlib.sha256(encoded).hexdigest() + + +def _message_signature(message: dict[str, Any]) -> str: + return json.dumps(_json_safe(message), ensure_ascii=False, sort_keys=True, separators=(",", ":")) + + +def normalize_text_messages(messages: list[dict[str, Any]]) -> list[dict[str, Any]]: + settings = config.get_chat_completion_cache_settings() + if not settings.get("normalize_messages"): + return messages + + normalized: list[dict[str, Any]] = [] + previous_signature = "" + for message in messages: + if settings.get("drop_assistant_history") and str(message.get("role") or "") == "assistant": + continue + signature = _message_signature(message) + if settings.get("drop_adjacent_duplicates") and signature == previous_signature: + continue + normalized.append(message) + previous_signature = signature + return normalized + + +class ChatCompletionCache: + def __init__(self) -> None: + self._lock = threading.RLock() + self._entries: dict[str, CacheEntry] = {} + self._inflight: dict[str, InflightCall] = {} + + def clear(self) -> None: + with self._lock: + self._entries.clear() + self._inflight.clear() + + def _settings(self) -> dict[str, object]: + return config.get_chat_completion_cache_settings() + + def _prune_locked(self, now: float, max_entries: int) -> None: + expired = [key for key, item in self._entries.items() if item.expires_at <= now] + for key in expired: + self._entries.pop(key, None) + while len(self._entries) > max_entries: + oldest_key = min(self._entries, key=lambda key: self._entries[key].expires_at) + self._entries.pop(oldest_key, None) + + @staticmethod + def _copy(value: Any) -> Any: + return copy.deepcopy(value) + + def get_or_compute_response(self, key: str, compute: Callable[[], dict[str, Any]]) -> dict[str, Any]: + settings = self._settings() + if not settings.get("enabled") or int(settings.get("ttl_seconds") or 0) <= 0: + return compute() + + now = time.time() + max_entries = int(settings.get("max_entries") or 1) + with self._lock: + self._prune_locked(now, max_entries) + entry = self._entries.get(key) + if entry and entry.expires_at > now: + return self._copy(entry.value) + inflight = self._inflight.get(key) if settings.get("dedupe_inflight") else None + if inflight is None: + inflight = InflightCall() + if settings.get("dedupe_inflight"): + self._inflight[key] = inflight + owner = True + else: + owner = False + + if not owner: + with inflight.condition: + while not inflight.done: + inflight.condition.wait() + if inflight.error: + raise inflight.error + return self._copy(inflight.value) + + try: + value = compute() + except BaseException as exc: + with self._lock: + self._inflight.pop(key, None) + with inflight.condition: + inflight.error = exc + inflight.done = True + inflight.condition.notify_all() + raise + + expires_at = time.time() + int(settings.get("ttl_seconds") or 0) + with self._lock: + self._entries[key] = CacheEntry(expires_at=expires_at, value=self._copy(value)) + self._prune_locked(time.time(), max_entries) + self._inflight.pop(key, None) + with inflight.condition: + inflight.value = self._copy(value) + inflight.done = True + inflight.condition.notify_all() + return value + + def get_or_compute_stream(self, key: str, compute: Callable[[], Iterable[dict[str, Any]]]) -> Iterator[dict[str, Any]]: + settings = self._settings() + if ( + not settings.get("enabled") + or not settings.get("stream_cache") + or int(settings.get("ttl_seconds") or 0) <= 0 + ): + yield from compute() + return + + now = time.time() + max_entries = int(settings.get("max_entries") or 1) + with self._lock: + self._prune_locked(now, max_entries) + entry = self._entries.get(key) + if entry and entry.expires_at > now: + yield from self._copy(entry.value) + return + inflight = self._inflight.get(key) if settings.get("dedupe_inflight") else None + if inflight is None: + inflight = InflightCall() + if settings.get("dedupe_inflight"): + self._inflight[key] = inflight + owner = True + else: + owner = False + + if not owner: + with inflight.condition: + while not inflight.done: + inflight.condition.wait() + if inflight.error: + raise inflight.error + yield from self._copy(inflight.value) + return + + chunks: list[dict[str, Any]] = [] + try: + for chunk in compute(): + chunks.append(self._copy(chunk)) + yield chunk + except BaseException as exc: + with self._lock: + self._inflight.pop(key, None) + with inflight.condition: + inflight.error = exc + inflight.done = True + inflight.condition.notify_all() + raise + + expires_at = time.time() + int(settings.get("ttl_seconds") or 0) + with self._lock: + self._entries[key] = CacheEntry(expires_at=expires_at, value=self._copy(chunks)) + self._prune_locked(time.time(), max_entries) + self._inflight.pop(key, None) + with inflight.condition: + inflight.value = self._copy(chunks) + inflight.done = True + inflight.condition.notify_all() + + +chat_completion_cache = ChatCompletionCache() diff --git a/services/protocol/conversation.py b/services/protocol/conversation.py new file mode 100644 index 0000000000000000000000000000000000000000..dd39c81441359c997eeba746e43eae67fe27f35b --- /dev/null +++ b/services/protocol/conversation.py @@ -0,0 +1,835 @@ +from __future__ import annotations + +import base64 +import json +import re +import time +from dataclasses import dataclass, field +from typing import Any, Iterable, Iterator + +import tiktoken + +from services.account_service import account_service +from services.config import config +from services.image_storage_service import image_storage_service +from services.openai_backend_api import ImagePollTimeoutError, OpenAIBackendAPI +from utils.helper import ( + IMAGE_MODELS, + extract_image_from_message_content, + is_codex_image_model, + is_supported_image_model, + split_image_model, +) +from utils.image_tokens import count_image_content_tokens +from utils.log import logger + + +class ImageGenerationError(Exception): + def __init__( + self, + message: str, + status_code: int = 502, + error_type: str = "server_error", + code: str | None = "upstream_error", + param: str | None = None, + account_email: str = "", + ) -> None: + super().__init__(message) + self.status_code = status_code + self.error_type = error_type + self.code = code + self.param = param + self.account_email = account_email + + def to_openai_error(self) -> dict[str, Any]: + return { + "error": { + "message": public_image_error_message(str(self)), + "type": self.error_type, + "param": self.param, + "code": self.code, + } + } + + +def public_image_error_message(message: str) -> str: + text = str(message or "").strip() + lower = text.lower() + if any(item in lower for item in ("backend-api/", "status=", "body=", "chatgpt.com", "upstreamhttperror")): + return "The image generation request failed. Please try again later." + return text or "The image generation request failed. Please try again later." + + +def is_token_invalid_error(message: str) -> bool: + text = str(message or "").lower() + return ( + "token_invalidated" in text + or "token_revoked" in text + or "authentication token has been invalidated" in text + or "invalidated oauth token" in text + ) + + +def image_stream_error_message(message: str) -> str: + text = str(message or "") + lower = text.lower() + if is_token_invalid_error(text): + return "image generation failed" + if "curl: (35)" in lower or "tls connect error" in lower or "openssl_internal" in lower: + return "upstream image connection failed, please retry later" + return text or "image generation failed" + + +def encode_images(images: Iterable[tuple[bytes, str, str]]) -> list[str]: + return [base64.b64encode(data).decode("ascii") for data, _, _ in images if data] + + +def save_image_bytes(image_data: bytes, base_url: str | None = None) -> str: + return image_storage_service.save(image_data, base_url).url + + +def message_text(content: Any) -> str: + if isinstance(content, str): + return content + if isinstance(content, list): + parts = [] + for item in content: + if isinstance(item, str): + parts.append(item) + elif isinstance(item, dict) and str(item.get("type") or "") in {"text", "input_text", "output_text"}: + parts.append(str(item.get("text") or "")) + return "".join(parts) + return "" + + +def normalize_messages(messages: object, system: Any = None) -> list[dict[str, Any]]: + normalized = [] + if config.global_system_prompt: + normalized.append({"role": "system", "content": config.global_system_prompt}) + system_text = message_text(system) + if system_text: + normalized.append({"role": "system", "content": system_text}) + if isinstance(messages, list): + for message in messages: + if not isinstance(message, dict): + continue + role = message.get("role", "user") + content = message.get("content", "") + text = message_text(content) + images: list[tuple[bytes, str]] = [] + if role == "user": + images.extend(extract_image_from_message_content(content)) + if isinstance(content, list): + for part in content: + if not isinstance(part, dict) or part.get("type") != "image": + continue + data = part.get("data") + if isinstance(data, (bytes, bytearray)): + images.append((bytes(data), str(part.get("mime") or "image/png"))) + if images: + parts: list[Any] = [] + if text: + parts.append({"type": "text", "text": text}) + for data, mime in images: + parts.append({"type": "image", "data": data, "mime": mime}) + normalized.append({"role": role, "content": parts}) + else: + normalized.append({"role": role, "content": text}) + return normalized + + +def prompt_with_global_system(prompt: str) -> str: + return f"{config.global_system_prompt}\n\n{prompt}" if config.global_system_prompt else prompt + + +def assistant_history_text(messages: list[dict[str, Any]]) -> str: + return "".join(str(item.get("content") or "") for item in messages if item.get("role") == "assistant") + + +def assistant_history_messages(messages: list[dict[str, Any]]) -> list[str]: + return [str(item.get("content") or "") for item in messages if item.get("role") == "assistant" and item.get("content")] + + +def build_image_prompt(prompt: str, size: str | None, quality: str = "auto") -> str: + hints = [] + if size: + hints.append(f"输出图片尺寸为 {size}。") + if quality: + hints.append(f"输出图片质量为 {quality}。") + return f"{prompt.strip()}\n\n{''.join(hints)}" if hints else prompt + + +def encoding_for_model(model: str): + try: + return tiktoken.encoding_for_model(model) + except KeyError: + try: + return tiktoken.get_encoding("o200k_base") + except KeyError: + return tiktoken.get_encoding("cl100k_base") + + +def count_message_image_tokens(messages: list[dict[str, Any]], model: str) -> int: + return sum(count_image_content_tokens(message.get("content"), model) for message in messages) + + +def count_message_text_tokens(messages: list[dict[str, Any]], model: str) -> int: + encoding = encoding_for_model(model) + total = 0 + for message in messages: + total += 3 + for key, value in message.items(): + if key == "content" and isinstance(value, list): + total += len(encoding.encode(message_text(value))) + elif isinstance(value, str): + total += len(encoding.encode(value)) + else: + continue + if key == "name": + total += 1 + return total + 3 + + +def count_message_tokens(messages: list[dict[str, Any]], model: str) -> int: + return count_message_text_tokens(messages, model) + count_message_image_tokens(messages, model) + + +def count_text_tokens(text: str, model: str) -> int: + return len(encoding_for_model(model).encode(text)) + + +def format_image_result( + items: list[dict[str, Any]], + prompt: str, + response_format: str, + base_url: str | None = None, + created: int | None = None, + message: str = "", +) -> dict[str, Any]: + data: list[dict[str, Any]] = [] + for item in items: + b64_json = str(item.get("b64_json") or "").strip() + if not b64_json: + continue + revised_prompt = str(item.get("revised_prompt") or prompt).strip() or prompt + if response_format == "b64_json": + data.append({ + "b64_json": b64_json, + "url": save_image_bytes(base64.b64decode(b64_json), base_url), + "revised_prompt": revised_prompt, + }) + else: + data.append({ + "url": save_image_bytes(base64.b64decode(b64_json), base_url), + "revised_prompt": revised_prompt, + }) + result: dict[str, Any] = {"created": created or int(time.time()), "data": data} + if message and not data: + result["message"] = message + return result + + +@dataclass +class ConversationRequest: + model: str = "auto" + prompt: str = "" + messages: list[dict[str, Any]] | None = None + images: list[str] | None = None + n: int = 1 + size: str | None = None + quality: str = "auto" + response_format: str = "b64_json" + base_url: str | None = None + message_as_error: bool = False + + +@dataclass +class ConversationState: + text: str = "" + raw_text: str = "" + conversation_id: str = "" + file_ids: list[str] = field(default_factory=list) + sediment_ids: list[str] = field(default_factory=list) + blocked: bool = False + tool_invoked: bool | None = None + turn_use_case: str = "" + + +@dataclass +class ImageOutput: + kind: str + model: str + index: int + total: int + created: int = field(default_factory=lambda: int(time.time())) + text: str = "" + upstream_event_type: str = "" + data: list[dict[str, Any]] = field(default_factory=list) + account_email: str = "" + + def to_chunk(self) -> dict[str, Any]: + chunk: dict[str, Any] = { + "object": "image.generation.chunk", + "created": self.created, + "model": self.model, + "index": self.index, + "total": self.total, + "progress_text": self.text, + "upstream_event_type": self.upstream_event_type, + "data": [], + } + if self.account_email: + chunk["_account_email"] = self.account_email + if self.kind == "message": + chunk.update({ + "object": "image.generation.message", + "message": self.text, + }) + chunk.pop("progress_text", None) + chunk.pop("upstream_event_type", None) + elif self.kind == "result": + chunk.update({ + "object": "image.generation.result", + "data": self.data, + }) + chunk.pop("progress_text", None) + chunk.pop("upstream_event_type", None) + return chunk + + +def assistant_message_text(message: dict[str, Any]) -> str: + content = message.get("content") or {} + parts = content.get("parts") or [] + if not isinstance(parts, list): + return "" + return "".join(part for part in parts if isinstance(part, str)) + + +def strip_history(text: str, history_text: str = "") -> str: + text = str(text or "") + history_text = str(history_text or "") + while history_text and text.startswith(history_text): + text = text[len(history_text):] + return text + + +def sanitize_output_text(text: str) -> str: + text = str(text or "") + + def replace_url(match: re.Match[str]) -> str: + label = match.group(1).strip() + url = match.group(2).strip() + if label and url.startswith(("http://", "https://")): + return f"{label} ({url})" + return label or url + + # ChatGPT web sometimes returns rich annotation markers using private-use + # characters. API clients cannot render those, so convert links and drop + # citations before emitting OpenAI-compatible text. + text = re.sub(r"\ue200url\ue202([^\ue202\ue201]*)\ue202([^\ue201]*)\ue201", replace_url, text) + text = re.sub(r"\ue200cite\ue202[^\ue201]*\ue201", "", text) + text = re.sub(r"\ue200[^\ue201]*\ue201", "", text) + text = re.sub(r"\ue200[^\ue201]*$", "", text) + return text + + +def assistant_raw_text(event: dict[str, Any], current_text: str = "", history_text: str = "") -> str: + for candidate in (event, event.get("v")): + if not isinstance(candidate, dict): + continue + message = candidate.get("message") + if not isinstance(message, dict): + continue + role = str((message.get("author") or {}).get("role") or "").strip().lower() + if role != "assistant": + continue + text = assistant_message_text(message) + if text: + return strip_history(text, history_text) + return apply_text_patch(event, current_text, history_text) + + +def assistant_text(event: dict[str, Any], current_text: str = "", history_text: str = "") -> str: + return sanitize_output_text(assistant_raw_text(event, current_text, history_text)) + + +def event_assistant_text(event: dict[str, Any], history_text: str = "") -> str: + for candidate in (event, event.get("v")): + if not isinstance(candidate, dict): + continue + message = candidate.get("message") + if isinstance(message, dict) and (message.get("author") or {}).get("role") == "assistant": + return strip_history(assistant_message_text(message), history_text) + return "" + + +def apply_text_patch(event: dict[str, Any], current_text: str = "", history_text: str = "") -> str: + if event.get("p") == "/message/content/parts/0": + return apply_patch_op(event, current_text, history_text) + + operations = event.get("v") + if isinstance(operations, str) and current_text and not event.get("p") and not event.get("o"): + return current_text + operations + + if event.get("o") == "patch" and isinstance(operations, list): + text = current_text + for item in operations: + if isinstance(item, dict): + text = apply_text_patch(item, text, history_text) + return text + + if not isinstance(operations, list): + return current_text + + text = current_text + for item in operations: + if isinstance(item, dict): + text = apply_text_patch(item, text, history_text) + return text + + +def apply_patch_op(operation: dict[str, Any], current_text: str, history_text: str = "") -> str: + op = operation.get("o") + value = str(operation.get("v") or "") + if op == "append": + return current_text + value + if op == "replace": + return strip_history(value, history_text) + return current_text + + +def add_unique(values: list[str], candidates: list[str]) -> None: + for candidate in candidates: + if candidate and candidate not in values: + values.append(candidate) + + +def extract_conversation_ids(payload: str) -> tuple[str, list[str], list[str]]: + conversation_match = re.search(r'"conversation_id"\s*:\s*"([^"]+)"', payload) + conversation_id = conversation_match.group(1) if conversation_match else "" + # Negative lookahead excludes "file-service" (URI prefix, not a real id). + file_ids = re.findall(r"(file[-_](?!service\b)[A-Za-z0-9]+)", payload) + sediment_ids = re.findall(r"sediment://([A-Za-z0-9_-]+)", payload) + return conversation_id, file_ids, sediment_ids + + +def is_image_tool_event(event: dict[str, Any]) -> bool: + value = event.get("v") + message = event.get("message") or (value.get("message") if isinstance(value, dict) else None) + if not isinstance(message, dict): + return False + metadata = message.get("metadata") or {} + author = message.get("author") or {} + content = message.get("content") or {} + if author.get("role") != "tool": + return False + if metadata.get("async_task_type") == "image_gen": + return True + if content.get("content_type") != "multimodal_text": + return False + return any( + isinstance(part, dict) and ( + part.get("content_type") == "image_asset_pointer" + or str(part.get("asset_pointer") or "").startswith(("file-service://", "sediment://")) + ) + for part in content.get("parts") or [] + ) + + +def update_conversation_state(state: ConversationState, payload: str, event: dict[str, Any] | None = None) -> None: + conversation_id, file_ids, sediment_ids = extract_conversation_ids(payload) + if conversation_id and not state.conversation_id: + state.conversation_id = conversation_id + # Accept file_id / sediment_id when any of: + # 1) event is a complete image_gen tool message + # 2) prior server_ste_metadata already flipped tool_invoked True (in an image_gen turn) + # 3) patch event whose payload references asset_pointer / file-service:// + # User messages (type=conversation.message) never satisfy these, so attacker-controlled + # substrings in user input cannot inject file ids into state. + is_patch_event = isinstance(event, dict) and event.get("o") == "patch" + image_context = ( + (isinstance(event, dict) and is_image_tool_event(event)) + or state.tool_invoked is True + or (is_patch_event and ("asset_pointer" in payload or "file-service://" in payload)) + ) + if image_context: + add_unique(state.file_ids, file_ids) + add_unique(state.sediment_ids, sediment_ids) + if not isinstance(event, dict): + return + state.conversation_id = str(event.get("conversation_id") or state.conversation_id) + value = event.get("v") + if isinstance(value, dict): + state.conversation_id = str(value.get("conversation_id") or state.conversation_id) + if event.get("type") == "moderation": + moderation = event.get("moderation_response") + if isinstance(moderation, dict) and moderation.get("blocked") is True: + state.blocked = True + if event.get("type") == "server_ste_metadata": + metadata = event.get("metadata") + if isinstance(metadata, dict): + if isinstance(metadata.get("tool_invoked"), bool): + state.tool_invoked = metadata["tool_invoked"] + state.turn_use_case = str(metadata.get("turn_use_case") or state.turn_use_case) + + +def conversation_base_event(event_type: str, state: ConversationState, **extra: Any) -> dict[str, Any]: + return { + "type": event_type, + "text": state.text, + "conversation_id": state.conversation_id, + "file_ids": list(state.file_ids), + "sediment_ids": list(state.sediment_ids), + "blocked": state.blocked, + "tool_invoked": state.tool_invoked, + "turn_use_case": state.turn_use_case, + **extra, + } + + +def iter_conversation_payloads(payloads: Iterator[str], history_text: str = "", + history_messages: list[str] | None = None) -> Iterator[dict[str, Any]]: + state = ConversationState() + history_messages = history_messages or [] + history_index = 0 + for payload in payloads: + # print(f"[upstream_sse] {payload}", flush=True) + if not payload: + continue + if payload == "[DONE]": + yield conversation_base_event("conversation.done", state, done=True) + break + try: + event = json.loads(payload) + except json.JSONDecodeError: + update_conversation_state(state, payload) + yield conversation_base_event("conversation.raw", state, payload=payload) + continue + if not isinstance(event, dict): + yield conversation_base_event("conversation.event", state, raw=event) + continue + update_conversation_state(state, payload, event) + if history_index < len(history_messages) and event_assistant_text(event, history_text) == history_messages[history_index]: + history_index += 1 + state.raw_text = "" + state.text = "" + continue + next_raw_text = assistant_raw_text(event, state.raw_text, history_text) + next_text = sanitize_output_text(next_raw_text) + state.raw_text = next_raw_text + if next_text != state.text: + delta = next_text[len(state.text):] if next_text.startswith(state.text) else next_text + state.text = next_text + yield conversation_base_event("conversation.delta", state, raw=event, delta=delta) + continue + yield conversation_base_event("conversation.event", state, raw=event) + + +def conversation_events( + backend: OpenAIBackendAPI, + messages: list[dict[str, Any]] | None = None, + model: str = "auto", + prompt: str = "", + images: list[str] | None = None, + size: str | None = None, + quality: str = "auto", +) -> Iterator[dict[str, Any]]: + normalized = normalize_messages(messages or ([{"role": "user", "content": prompt}] if prompt else [])) + image_model = is_supported_image_model(model) + history_text = "" if image_model else assistant_history_text(normalized) + history_messages = [] if image_model else assistant_history_messages(normalized) + final_prompt = prompt_with_global_system(build_image_prompt(prompt, size, quality)) if image_model else prompt + payloads = backend.stream_conversation( + messages=normalized, + model=model, + prompt=final_prompt, + images=images if image_model else None, + system_hints=["picture_v2"] if image_model else None, + ) + yield from iter_conversation_payloads(payloads, history_text, history_messages) + + +def text_backend() -> OpenAIBackendAPI: + return OpenAIBackendAPI(access_token=account_service.get_text_access_token()) + + +def stream_text_deltas(backend: OpenAIBackendAPI, request: ConversationRequest) -> Iterator[str]: + attempted_tokens: set[str] = set() + token = getattr(backend, "access_token", "") + emitted = False + while True: + if token and token in attempted_tokens: + raise RuntimeError("no available text account") + if token: + attempted_tokens.add(token) + try: + active_backend = OpenAIBackendAPI(access_token=token) + for event in conversation_events(active_backend, messages=request.messages, model=request.model, prompt=request.prompt): + if event.get("type") != "conversation.delta": + continue + delta = str(event.get("delta") or "") + if delta: + emitted = True + yield delta + account_service.mark_text_used(token) + return + except Exception as exc: + error_message = str(exc) + if token and not emitted and is_token_invalid_error(error_message): + refreshed_token = account_service.refresh_access_token(token, force=True, event="text_stream") + if refreshed_token and refreshed_token != token and refreshed_token not in attempted_tokens: + token = refreshed_token + else: + account_service.remove_invalid_token(token, "text_stream") + token = account_service.get_text_access_token(attempted_tokens) + if token: + continue + raise + + +def collect_text(backend: OpenAIBackendAPI, request: ConversationRequest) -> str: + return "".join(stream_text_deltas(backend, request)) + + +def stream_image_outputs( + backend: OpenAIBackendAPI, + request: ConversationRequest, + index: int = 1, + total: int = 1, +) -> Iterator[ImageOutput]: + last: dict[str, Any] = {} + for event in conversation_events( + backend, + prompt=request.prompt, + model=request.model, + images=request.images or [], + size=request.size, + quality=request.quality, + ): + last = event + if event.get("type") == "conversation.delta": + yield ImageOutput( + kind="progress", + model=request.model, + index=index, + total=total, + text=str(event.get("delta") or ""), + upstream_event_type="conversation.delta", + ) + continue + if event.get("type") == "conversation.event": + raw = event.get("raw") + raw_type = str(raw.get("type") or "") if isinstance(raw, dict) else "" + yield ImageOutput( + kind="progress", + model=request.model, + index=index, + total=total, + upstream_event_type=raw_type, + ) + + conversation_id = str(last.get("conversation_id") or "") + file_ids = [str(item) for item in last.get("file_ids") or []] + sediment_ids = [str(item) for item in last.get("sediment_ids") or []] + message = str(last.get("text") or "").strip() + logger.info({ + "event": "image_stream_resolve_start", + "conversation_id": conversation_id, + "file_ids": file_ids, + "sediment_ids": sediment_ids, + "tool_invoked": last.get("tool_invoked"), + "turn_use_case": last.get("turn_use_case"), + }) + if message and not file_ids and not sediment_ids and last.get("blocked"): + yield ImageOutput(kind="message", model=request.model, index=index, total=total, text=message) + return + should_poll_for_image = bool(request.images) or last.get("turn_use_case") == "image gen" + if message and not file_ids and not sediment_ids and not should_poll_for_image: + yield ImageOutput(kind="message", model=request.model, index=index, total=total, text=message) + return + + image_urls = backend.resolve_conversation_image_urls(conversation_id, file_ids, sediment_ids) + if image_urls: + image_items = [ + {"b64_json": base64.b64encode(image_data).decode("ascii")} + for image_data in backend.download_image_bytes(image_urls) + ] + data = format_image_result( + image_items, + request.prompt, + request.response_format, + request.base_url, + int(time.time()), + )["data"] + if data: + yield ImageOutput(kind="result", model=request.model, index=index, total=total, data=data) + return + + if message: + yield ImageOutput(kind="message", model=request.model, index=index, total=total, text=message) + + +def _codex_response_images(value: Any) -> list[str]: + if isinstance(value, dict): + if value.get("type") == "image_generation_call" and isinstance(value.get("result"), str): + result = value["result"].strip() + if result: + return [result.split(",", 1)[1] if result.startswith("data:image/") else result] + images: list[str] = [] + for item in value.values(): + images.extend(_codex_response_images(item)) + return images + if isinstance(value, list): + images: list[str] = [] + for item in value: + images.extend(_codex_response_images(item)) + return images + return [] + + +def stream_codex_image_outputs( + backend: OpenAIBackendAPI, + request: ConversationRequest, + index: int = 1, + total: int = 1, +) -> Iterator[ImageOutput]: + images = _codex_response_images(list(backend.iter_codex_image_response_events( + prompt=request.prompt, + images=request.images or [], + size=request.size, + quality=request.quality, + ))) + if not images: + raise ImageGenerationError("No image result found in response") + data = format_image_result( + [{"b64_json": item, "revised_prompt": request.prompt} for item in images], + request.prompt, + request.response_format, + request.base_url, + int(time.time()), + )["data"] + if data: + yield ImageOutput(kind="result", model=request.model, index=index, total=total, data=data) + return + raise ImageGenerationError("No image result found in response") + + +def stream_image_outputs_with_pool(request: ConversationRequest) -> Iterator[ImageOutput]: + if not is_supported_image_model(request.model): + raise ImageGenerationError("unsupported image model,supported models: " + ", ".join(sorted(IMAGE_MODELS))) + + emitted = False + last_error = "" + for index in range(1, request.n + 1): + while True: + try: + plan_type, _ = split_image_model(request.model) + codex_model = is_codex_image_model(request.model) + token = account_service.get_available_access_token( + plan_type=plan_type, + source_type="codex" if codex_model else None, + plan_types=("plus", "team", "pro") if codex_model and not plan_type else None, + ) + except RuntimeError as exc: + if emitted: + return + raise ImageGenerationError(str(exc) or "image generation failed") from exc + + emitted_for_token = False + returned_message = False + returned_result = False + account = account_service.get_account(token) or {} + account_email = str(account.get("email") or "").strip() + try: + backend = OpenAIBackendAPI(access_token=token) + stream_fn = stream_codex_image_outputs if is_codex_image_model(request.model) else stream_image_outputs + for output in stream_fn(backend, request, index, request.n): + if account_email and not output.account_email: + output.account_email = account_email + if output.kind == "message" and request.message_as_error: + raise ImageGenerationError( + output.text or "Image generation was rejected by upstream policy.", + status_code=400, + error_type="invalid_request_error", + code="content_policy_violation", + account_email=account_email, + ) + emitted = True + emitted_for_token = True + returned_message = output.kind == "message" + returned_result = returned_result or output.kind == "result" + yield output + if returned_message or not returned_result: + account_service.mark_image_result(token, False) + return + account_service.mark_image_result(token, True) + break + except ImagePollTimeoutError as exc: + if account_email and not getattr(exc, "account_email", ""): + exc.account_email = account_email + raise + except ImageGenerationError as exc: + account_service.mark_image_result(token, False) + if account_email and not getattr(exc, "account_email", ""): + exc.account_email = account_email + logger.warning({ + "event": "image_stream_generation_error", + "request_token": token, + "account_email": account_email, + "error": str(exc), + }) + raise + except Exception as exc: + account_service.mark_image_result(token, False) + last_error = str(exc) + logger.warning({ + "event": "image_stream_fail", + "request_token": token, + "account_email": account_email, + "error": last_error, + }) + if not emitted_for_token and is_token_invalid_error(last_error): + refreshed_token = account_service.refresh_access_token(token, force=True, event="image_stream") + if refreshed_token and refreshed_token != token: + token = refreshed_token + continue + account_service.remove_invalid_token(token, "image_stream") + continue + raise ImageGenerationError(image_stream_error_message(last_error), account_email=account_email) from exc + + if not emitted: + if not last_error: + last_error = "no account in the pool could generate images — check account quota and rate-limit status" + raise ImageGenerationError(image_stream_error_message(last_error)) + + +def stream_image_chunks(outputs: Iterable[ImageOutput]) -> Iterator[dict[str, Any]]: + for output in outputs: + yield output.to_chunk() + + +def collect_image_outputs(outputs: Iterable[ImageOutput]) -> dict[str, Any]: + created = None + data: list[dict[str, Any]] = [] + message = "" + progress_parts: list[str] = [] + account_email = "" + for output in outputs: + created = created or output.created + if output.account_email and not account_email: + account_email = output.account_email + if output.kind == "progress" and output.text: + progress_parts.append(output.text) + elif output.kind == "message": + message = output.text + elif output.kind == "result": + data.extend(output.data) + + result: dict[str, Any] = {"created": created or int(time.time()), "data": data} + if not data: + text = message or "".join(progress_parts).strip() + if text: + result["message"] = text + if account_email: + result["_account_email"] = account_email + return result diff --git a/services/protocol/error_response.py b/services/protocol/error_response.py new file mode 100644 index 0000000000000000000000000000000000000000..53fde95356c4e95172225c71e368d31105447c48 --- /dev/null +++ b/services/protocol/error_response.py @@ -0,0 +1,124 @@ +from __future__ import annotations + +from typing import Any + +from fastapi.responses import JSONResponse + + +def _message_from_value(value: object) -> str: + if isinstance(value, str): + return value + if not isinstance(value, dict): + return "" + message = value.get("message") + if isinstance(message, str) and message: + return message + return _message_from_value(value.get("error")) + + +def error_message_from_detail(detail: object) -> str: + if isinstance(detail, list): + messages = [] + for item in detail: + if not isinstance(item, dict): + continue + location = ".".join(str(part) for part in item.get("loc", []) if part != "body") + message = str(item.get("msg") or "").strip() + if location and message: + messages.append(f"{location}: {message}") + elif message: + messages.append(message) + return "; ".join(messages) + if isinstance(detail, dict): + message = _message_from_value(detail.get("error")) or _message_from_value(detail) + if message: + return message + return str(detail or "").strip() + + +def _default_error_type(status_code: int) -> str: + if status_code == 401: + return "authentication_error" + if status_code == 403: + return "permission_error" + if status_code == 429: + return "rate_limit_error" + if 400 <= status_code < 500: + return "invalid_request_error" + return "server_error" + + +def _default_error_code(status_code: int) -> str: + if status_code == 401: + return "invalid_api_key" + if status_code == 403: + return "permission_denied" + if status_code == 429: + return "rate_limit_exceeded" + if 400 <= status_code < 500: + return "bad_request" + return "upstream_error" + + +def openai_error_payload( + detail: object, + status_code: int, + *, + error_type: str | None = None, + code: object | None = None, + param: object | None = None, +) -> dict[str, Any]: + error_detail = detail.get("error") if isinstance(detail, dict) else None + if isinstance(error_detail, dict): + return { + "error": { + "message": error_message_from_detail(error_detail) or "request failed", + "type": str(error_detail.get("type") or error_type or _default_error_type(status_code)), + "param": error_detail.get("param", param), + "code": error_detail.get("code", code if code is not None else _default_error_code(status_code)), + } + } + return { + "error": { + "message": error_message_from_detail(detail) or "request failed", + "type": error_type or _default_error_type(status_code), + "param": param, + "code": code if code is not None else _default_error_code(status_code), + } + } + + +def openai_error_response( + detail: object, + status_code: int, + *, + headers: dict[str, str] | None = None, + error_type: str | None = None, + code: object | None = None, + param: object | None = None, +) -> JSONResponse: + return JSONResponse( + status_code=status_code, + content=openai_error_payload(detail, status_code, error_type=error_type, code=code, param=param), + headers=headers, + ) + + +def anthropic_error_response( + detail: object, + status_code: int, + *, + headers: dict[str, str] | None = None, +) -> JSONResponse: + error_type = "api_error" if status_code >= 500 else _default_error_type(status_code) + return JSONResponse( + status_code=status_code, + content={ + "type": "error", + "error": { + "type": error_type, + "message": error_message_from_detail(detail) or "request failed", + }, + }, + headers=headers, + ) diff --git a/services/protocol/openai_v1_chat_complete.py b/services/protocol/openai_v1_chat_complete.py new file mode 100644 index 0000000000000000000000000000000000000000..f7ca73f0228e089f874be27930d18af59b094b14 --- /dev/null +++ b/services/protocol/openai_v1_chat_complete.py @@ -0,0 +1,231 @@ +from __future__ import annotations + +import time +import uuid +from typing import Any, Iterable, Iterator + +from fastapi import HTTPException + +from services.protocol.chat_completion_cache import cache_key, chat_completion_cache, normalize_text_messages +from services.protocol.conversation import ( + ConversationRequest, + ImageOutput, + collect_image_outputs, + collect_text, + count_message_image_tokens, + count_message_text_tokens, + count_text_tokens, + encode_images, + normalize_messages, + stream_image_outputs_with_pool, + stream_text_deltas, + text_backend, +) +from utils.helper import build_chat_image_markdown_content, extract_chat_image, extract_chat_prompt, is_image_chat_request, parse_image_count +from utils.image_tokens import ( + chat_usage_from_image_usage, + count_image_inputs_tokens, + count_image_output_items_tokens, + image_usage, +) + +TOOL_UNAVAILABLE_SYSTEM_MESSAGE = ( + "This compatibility backend cannot execute local tools, shell commands, web searches, " + "or file operations. Do not claim to have run tools or inspected external resources. " + "If a user asks you to use a tool, say that tool execution is unavailable through this backend." +) + + +def completion_chunk(model: str, delta: dict[str, Any], finish_reason: str | None = None, completion_id: str = "", created: int | None = None) -> dict[str, Any]: + return { + "id": completion_id or f"chatcmpl-{uuid.uuid4().hex}", + "object": "chat.completion.chunk", + "created": created or int(time.time()), + "model": model, + "choices": [{"index": 0, "delta": delta, "finish_reason": finish_reason}], + } + + +def completion_response( + model: str, + content: str, + created: int | None = None, + messages: list[dict[str, Any]] | None = None, +) -> dict[str, Any]: + prompt_text_tokens = count_message_text_tokens(messages, model) if messages else 0 + prompt_image_tokens = count_message_image_tokens(messages, model) if messages else 0 + prompt_tokens = prompt_text_tokens + prompt_image_tokens + completion_tokens = count_text_tokens(content, model) if messages else 0 + return { + "id": f"chatcmpl-{uuid.uuid4().hex}", + "object": "chat.completion", + "created": created or int(time.time()), + "model": model, + "choices": [{ + "index": 0, + "message": {"role": "assistant", "content": content}, + "finish_reason": "stop", + }], + "usage": { + "prompt_tokens": prompt_tokens, + "completion_tokens": completion_tokens, + "total_tokens": prompt_tokens + completion_tokens, + "prompt_tokens_details": { + "text_tokens": prompt_text_tokens, + "image_tokens": prompt_image_tokens, + "cached_tokens": 0, + }, + "completion_tokens_details": { + "text_tokens": completion_tokens, + "image_tokens": 0, + "reasoning_tokens": 0, + }, + }, + } + + +def stream_text_chat_completion(backend, messages: list[dict[str, Any]], model: str) -> Iterator[dict[str, Any]]: + completion_id = f"chatcmpl-{uuid.uuid4().hex}" + created = int(time.time()) + sent_role = False + request = ConversationRequest(model=model, messages=messages) + for delta_text in stream_text_deltas(backend, request): + if not sent_role: + sent_role = True + yield completion_chunk(model, {"role": "assistant", "content": delta_text}, None, completion_id, created) + else: + yield completion_chunk(model, {"content": delta_text}, None, completion_id, created) + if not sent_role: + yield completion_chunk(model, {"role": "assistant", "content": ""}, None, completion_id, created) + yield completion_chunk(model, {}, "stop", completion_id, created) + + +def collect_chat_content(chunks: Iterable[dict[str, Any]]) -> str: + parts: list[str] = [] + for chunk in chunks: + choices = chunk.get("choices") + first = choices[0] if isinstance(choices, list) and choices and isinstance(choices[0], dict) else {} + delta = first.get("delta") if isinstance(first.get("delta"), dict) else {} + content = str(delta.get("content") or "") + if content: + parts.append(content) + return "".join(parts) + + +def chat_messages_from_body(body: dict[str, Any]) -> list[dict[str, Any]]: + messages = body.get("messages") + if isinstance(messages, list) and messages: + return [message for message in messages if isinstance(message, dict)] + prompt = str(body.get("prompt") or "").strip() + if prompt: + return [{"role": "user", "content": prompt}] + raise HTTPException(status_code=400, detail={"error": "messages or prompt is required"}) + + +def chat_image_args(body: dict[str, Any]) -> tuple[str, str, int, list[tuple[bytes, str, str]]]: + model = str(body.get("model") or "gpt-image-2").strip() or "gpt-image-2" + prompt = extract_chat_prompt(body) + if not prompt: + raise HTTPException(status_code=400, detail={"error": "prompt is required"}) + images = [ + (data, f"image_{idx}.png", mime) + for idx, (data, mime) in enumerate(extract_chat_image(body), start=1) + ] + return model, prompt, parse_image_count(body.get("n")), images + + +def text_chat_parts(body: dict[str, Any]) -> tuple[str, list[dict[str, Any]]]: + model = str(body.get("model") or "auto").strip() or "auto" + messages = normalize_text_messages(normalize_messages(chat_messages_from_body(body))) + tools = body.get("tools") + if isinstance(tools, list) and tools: + messages.insert(0, {"role": "system", "content": TOOL_UNAVAILABLE_SYSTEM_MESSAGE}) + return model, messages + + +def image_result_content(result: dict[str, Any]) -> str: + data = result.get("data") + if isinstance(data, list) and data: + return build_chat_image_markdown_content(result) + return str(result.get("message") or "Image generation completed.") + + +def image_chat_response(body: dict[str, Any]) -> dict[str, Any]: + model, prompt, n, images = chat_image_args(body) + result = collect_image_outputs(stream_image_outputs_with_pool(ConversationRequest( + prompt=prompt, + model=model, + n=n, + response_format="b64_json", + images=encode_images(images) or None, + ))) + response = completion_response(model, image_result_content(result), int(result.get("created") or 0) or None) + usage = image_usage( + input_text_tokens=count_text_tokens(prompt, model), + input_image_tokens=count_image_inputs_tokens(images, model), + output_tokens=count_image_output_items_tokens(result.get("data")), + ) + response["usage"] = chat_usage_from_image_usage(usage) + return response + + +def image_chat_events(body: dict[str, Any]) -> Iterator[dict[str, Any]]: + model, prompt, n, images = chat_image_args(body) + image_outputs = stream_image_outputs_with_pool(ConversationRequest( + prompt=prompt, + model=model, + n=n, + response_format="b64_json", + images=encode_images(images) or None, + )) + yield from stream_image_chat_completion(image_outputs, model) + + +def stream_image_chat_completion(image_outputs: Iterable[ImageOutput], model: str) -> Iterator[dict[str, Any]]: + completion_id = f"chatcmpl-{uuid.uuid4().hex}" + created = int(time.time()) + sent_role = False + sent_text = "" + for output in image_outputs: + content = "" + if output.kind == "progress": + content = output.text + sent_text += content + elif output.kind == "result": + content = build_chat_image_markdown_content({"data": output.data}) + elif output.kind == "message": + content = output.text[len(sent_text):] if output.text.startswith(sent_text) else output.text + if not content: + continue + if not sent_role: + sent_role = True + yield completion_chunk(model, {"role": "assistant", "content": content}, None, completion_id, created) + else: + yield completion_chunk(model, {"content": content}, None, completion_id, created) + if not sent_role: + yield completion_chunk(model, {"role": "assistant", "content": ""}, None, completion_id, created) + yield completion_chunk(model, {}, "stop", completion_id, created) + + +def handle(body: dict[str, Any]) -> dict[str, Any] | Iterator[dict[str, Any]]: + if body.get("stream"): + if is_image_chat_request(body): + return image_chat_events(body) + model, messages = text_chat_parts(body) + key = cache_key(body, messages, stream=True) + return chat_completion_cache.get_or_compute_stream( + key, + lambda: stream_text_chat_completion(text_backend(), messages, model), + ) + if is_image_chat_request(body): + return image_chat_response(body) + model, messages = text_chat_parts(body) + key = cache_key(body, messages, stream=False) + return chat_completion_cache.get_or_compute_response( + key, + lambda: completion_response( + model, + collect_text(text_backend(), ConversationRequest(model=model, messages=messages)), + messages=messages, + ), + ) diff --git a/services/protocol/openai_v1_image_edit.py b/services/protocol/openai_v1_image_edit.py new file mode 100644 index 0000000000000000000000000000000000000000..eff5782359efb7bc5d0693fb525534cbffcd106f --- /dev/null +++ b/services/protocol/openai_v1_image_edit.py @@ -0,0 +1,48 @@ +from __future__ import annotations + +from typing import Any, Iterator + +from services.protocol.conversation import ( + ConversationRequest, + ImageGenerationError, + collect_image_outputs, + count_text_tokens, + encode_images, + stream_image_chunks, + stream_image_outputs_with_pool, +) +from utils.image_tokens import count_image_inputs_tokens, count_image_output_items_tokens, image_usage + + +def handle(body: dict[str, Any]) -> dict[str, Any] | Iterator[dict[str, Any]]: + prompt = str(body.get("prompt") or "") + images = body.get("images") or [] + model = str(body.get("model") or "gpt-image-2") + n = int(body.get("n") or 1) + size = body.get("size") + quality = str(body.get("quality") or "auto") + response_format = str(body.get("response_format") or "b64_json") + base_url = str(body.get("base_url") or "") or None + encoded_images = encode_images(images) + if not encoded_images: + raise ImageGenerationError("image is required") + outputs = stream_image_outputs_with_pool(ConversationRequest( + prompt=prompt, + model=model, + n=n, + size=size, + quality=quality, + response_format=response_format, + base_url=base_url, + images=encoded_images, + message_as_error=True, + )) + if body.get("stream"): + return stream_image_chunks(outputs) + result = collect_image_outputs(outputs) + result["usage"] = image_usage( + input_text_tokens=count_text_tokens(prompt, model), + input_image_tokens=count_image_inputs_tokens(images, model), + output_tokens=count_image_output_items_tokens(result.get("data"), size, quality), + ) + return result diff --git a/services/protocol/openai_v1_image_generations.py b/services/protocol/openai_v1_image_generations.py new file mode 100644 index 0000000000000000000000000000000000000000..32846784941956540600c5d680433845dfabcc64 --- /dev/null +++ b/services/protocol/openai_v1_image_generations.py @@ -0,0 +1,40 @@ +from __future__ import annotations + +from typing import Any, Iterator + +from services.protocol.conversation import ( + ConversationRequest, + collect_image_outputs, + count_text_tokens, + stream_image_chunks, + stream_image_outputs_with_pool, +) +from utils.image_tokens import count_image_output_items_tokens, image_usage + + +def handle(body: dict[str, Any]) -> dict[str, Any] | Iterator[dict[str, Any]]: + prompt = str(body.get("prompt") or "") + model = str(body.get("model") or "gpt-image-2") + n = int(body.get("n") or 1) + size = body.get("size") + quality = str(body.get("quality") or "auto") + response_format = str(body.get("response_format") or "b64_json") + base_url = str(body.get("base_url") or "") or None + outputs = stream_image_outputs_with_pool(ConversationRequest( + prompt=prompt, + model=model, + n=n, + size=size, + quality=quality, + response_format=response_format, + base_url=base_url, + message_as_error=True, + )) + if body.get("stream"): + return stream_image_chunks(outputs) + result = collect_image_outputs(outputs) + result["usage"] = image_usage( + input_text_tokens=count_text_tokens(prompt, model), + output_tokens=count_image_output_items_tokens(result.get("data"), size, quality), + ) + return result diff --git a/services/protocol/openai_v1_models.py b/services/protocol/openai_v1_models.py new file mode 100644 index 0000000000000000000000000000000000000000..07fadab500a189fae5e1b3e4be5c727cddafb7ca --- /dev/null +++ b/services/protocol/openai_v1_models.py @@ -0,0 +1,53 @@ +from __future__ import annotations + +from typing import Any + +from services.account_service import account_service +from services.openai_backend_api import OpenAIBackendAPI +from utils.helper import CODEX_IMAGE_MODEL + + +def list_models() -> dict[str, Any]: + result = OpenAIBackendAPI().list_models() + data = result.get("data") + if not isinstance(data, list): + return result + seen = {str(item.get("id") or "").strip() for item in data if isinstance(item, dict)} + dynamic_models: set[str] = set() + accounts = account_service.list_accounts() + web_image_accounts = [ + account + for account in accounts + if isinstance(account, dict) + ] + codex_types = { + normalized + for account in accounts + if isinstance(account, dict) + and account_service._normalize_source_type(account.get("source_type")) == "codex" + and (normalized := account_service._normalize_account_type(account.get("type"))) + } + + if web_image_accounts: + dynamic_models.add("gpt-image-2") + if codex_types & {"Plus", "Team", "Pro"}: + dynamic_models.add(CODEX_IMAGE_MODEL) + if "Plus" in codex_types: + dynamic_models.add(f"plus-{CODEX_IMAGE_MODEL}") + if "Team" in codex_types: + dynamic_models.add(f"team-{CODEX_IMAGE_MODEL}") + if "Pro" in codex_types: + dynamic_models.add(f"pro-{CODEX_IMAGE_MODEL}") + + for model in sorted(dynamic_models): + if model not in seen: + data.append({ + "id": model, + "object": "model", + "created": 0, + "owned_by": "chatgpt2api", + "permission": [], + "root": model, + "parent": None, + }) + return result diff --git a/services/protocol/openai_v1_response.py b/services/protocol/openai_v1_response.py new file mode 100644 index 0000000000000000000000000000000000000000..bf510dec6ca938be267118f994c1ad4ac78572eb --- /dev/null +++ b/services/protocol/openai_v1_response.py @@ -0,0 +1,313 @@ +from __future__ import annotations + +import base64 +import time +import uuid +from typing import Any, Iterable, Iterator + +from fastapi import HTTPException + +from services.protocol.chat_completion_cache import cache_key, chat_completion_cache, normalize_text_messages +from services.protocol.conversation import ( + ConversationRequest, + ImageOutput, + count_message_text_tokens, + count_text_tokens, + encode_images, + stream_image_outputs_with_pool, + stream_text_deltas, + text_backend, +) +from utils.helper import extract_image_from_message_content, extract_response_prompt, has_response_image_generation_tool +from utils.image_tokens import ( + count_image_content_tokens, + count_image_output_items_tokens, + image_usage, + token_usage, +) + +TOOL_UNAVAILABLE_SYSTEM_MESSAGE = ( + "This compatibility backend cannot execute local tools, shell commands, web searches, " + "or file operations. Do not claim to have run tools or inspected external resources. " + "If a user asks you to use a tool, say that tool execution is unavailable through this backend." +) + + +def is_text_response_request(body: dict[str, Any]) -> bool: + return not has_response_image_generation_tool(body) + + +def has_non_image_tools(body: dict[str, Any]) -> bool: + tools = body.get("tools") + if not isinstance(tools, list): + return False + return any( + isinstance(tool, dict) and str(tool.get("type") or "").strip() != "image_generation" + for tool in tools + ) + + +def response_image_tool(body: dict[str, Any]) -> dict[str, object]: + for tool in body.get("tools") or []: + if isinstance(tool, dict) and tool.get("type") == "image_generation": + return tool + return {} + + +def extract_response_image(input_value: object) -> tuple[bytes, str] | None: + if isinstance(input_value, dict): + images = extract_image_from_message_content(input_value.get("content")) + return images[0] if images else None + if not isinstance(input_value, list): + return None + for item in reversed(input_value): + if isinstance(item, dict) and str(item.get("type") or "").strip() == "input_image": + image_url = str(item.get("image_url") or "") + if image_url.startswith("data:"): + header, _, data = image_url.partition(",") + mime = header.split(";")[0].removeprefix("data:") + return base64.b64decode(data), mime or "image/png" + if isinstance(item, dict): + images = extract_image_from_message_content(item.get("content")) + if images: + return images[0] + return None + + +def _input_image_parts(input_value: object) -> list[dict[str, Any]]: + parts: list[dict[str, Any]] = [] + if isinstance(input_value, dict): + content = input_value.get("content") + if isinstance(content, list): + parts.extend(item for item in content if isinstance(item, dict)) + return parts + if not isinstance(input_value, list): + return parts + if all(isinstance(item, dict) and item.get("type") for item in input_value): + return [item for item in input_value if isinstance(item, dict)] + for item in input_value: + if isinstance(item, dict): + content = item.get("content") + if isinstance(content, list): + parts.extend(part for part in content if isinstance(part, dict)) + return parts + + +def messages_from_input(input_value: object, instructions: object = None) -> list[dict[str, Any]]: + messages: list[dict[str, Any]] = [] + system_text = str(instructions or "").strip() + if system_text: + messages.append({"role": "system", "content": system_text}) + if isinstance(input_value, str): + if input_value.strip(): + messages.append({"role": "user", "content": input_value.strip()}) + return messages + if isinstance(input_value, dict): + messages.append({ + "role": str(input_value.get("role") or "user"), + "content": extract_response_prompt([input_value]) or input_value.get("content") or "", + }) + return messages + if isinstance(input_value, list): + if all(isinstance(item, dict) and item.get("type") for item in input_value): + text = extract_response_prompt(input_value) + if text: + messages.append({"role": "user", "content": text}) + return messages + for item in input_value: + if isinstance(item, dict): + messages.append({ + "role": str(item.get("role") or "user"), + "content": extract_response_prompt([item]) or item.get("content") or "", + }) + return messages + + +def text_output_item(text: str, item_id: str | None = None, status: str = "completed") -> dict[str, Any]: + return { + "id": item_id or f"msg_{uuid.uuid4().hex}", + "type": "message", + "status": status, + "role": "assistant", + "content": [{"type": "output_text", "text": text, "annotations": []}], + } + + +def image_output_items(prompt: str, data: list[dict[str, Any]], item_id: str | None = None) -> list[dict[str, Any]]: + output = [] + for item in data: + b64_json = str(item.get("b64_json") or "").strip() + if b64_json: + output.append({ + "id": item_id or f"ig_{len(output) + 1}", + "type": "image_generation_call", + "status": "completed", + "result": b64_json, + "revised_prompt": str(item.get("revised_prompt") or prompt).strip() or prompt, + }) + return output + + +def response_created(response_id: str, model: str, created: int) -> dict[str, Any]: + return { + "type": "response.created", + "response": { + "id": response_id, + "object": "response", + "created_at": created, + "status": "in_progress", + "error": None, + "incomplete_details": None, + "model": model, + "output": [], + "parallel_tool_calls": False, + }, + } + + +def response_completed( + response_id: str, + model: str, + created: int, + output: list[dict[str, Any]], + usage: dict[str, Any] | None = None, +) -> dict[str, Any]: + response = { + "type": "response.completed", + "response": { + "id": response_id, + "object": "response", + "created_at": created, + "status": "completed", + "error": None, + "incomplete_details": None, + "model": model, + "output": output, + "parallel_tool_calls": False, + }, + } + if usage: + response["response"]["usage"] = usage + return response + + +def text_response_parts(body: dict[str, Any]) -> tuple[str, list[dict[str, Any]]]: + model = str(body.get("model") or "auto").strip() or "auto" + messages = normalize_text_messages(messages_from_input(body.get("input"), body.get("instructions"))) + if has_non_image_tools(body): + messages.insert(0, {"role": "system", "content": TOOL_UNAVAILABLE_SYSTEM_MESSAGE}) + return model, messages + + +def stream_text_response(backend, body: dict[str, Any], messages: list[dict[str, Any]] | None = None) -> Iterator[dict[str, Any]]: + model = str(body.get("model") or "auto").strip() or "auto" + messages = messages if messages is not None else messages_from_input(body.get("input"), body.get("instructions")) + response_id = f"resp_{uuid.uuid4().hex}" + item_id = f"msg_{uuid.uuid4().hex}" + created = int(time.time()) + full_text = "" + yield response_created(response_id, model, created) + yield {"type": "response.output_item.added", "output_index": 0, "item": text_output_item("", item_id, "in_progress")} + request = ConversationRequest(model=model, messages=messages) + for delta in stream_text_deltas(backend, request): + full_text += delta + yield {"type": "response.output_text.delta", "item_id": item_id, "output_index": 0, "content_index": 0, "delta": delta} + yield {"type": "response.output_text.done", "item_id": item_id, "output_index": 0, "content_index": 0, "text": full_text} + item = text_output_item(full_text, item_id, "completed") + yield {"type": "response.output_item.done", "output_index": 0, "item": item} + usage = token_usage( + input_text_tokens=count_message_text_tokens(messages, model), + output_text_tokens=count_text_tokens(full_text, model), + ) + yield response_completed(response_id, model, created, [item], usage) + + +def stream_image_response( + image_outputs: Iterable[ImageOutput], + prompt: str, + model: str, + input_image_tokens: int = 0, + size: object = None, + quality: str = "auto", +) -> Iterator[dict[str, Any]]: + response_id = f"resp_{uuid.uuid4().hex}" + created = int(time.time()) + yield response_created(response_id, model, created) + for output in image_outputs: + if output.kind == "message": + text = output.text + item = text_output_item(text) + usage = token_usage( + input_text_tokens=count_text_tokens(prompt, model), + input_image_tokens=input_image_tokens, + output_text_tokens=count_text_tokens(text, model), + ) + yield {"type": "response.output_text.delta", "item_id": item["id"], "output_index": 0, "content_index": 0, "delta": text} + yield {"type": "response.output_text.done", "item_id": item["id"], "output_index": 0, "content_index": 0, "text": text} + yield {"type": "response.output_item.done", "output_index": 0, "item": item} + yield response_completed(response_id, model, created, [item], usage) + return + if output.kind != "result": + continue + items = image_output_items(prompt, output.data) + if items: + item = items[0] + usage = image_usage( + input_text_tokens=count_text_tokens(prompt, model), + input_image_tokens=input_image_tokens, + output_tokens=count_image_output_items_tokens(output.data, size, quality), + ) + yield {"type": "response.output_item.done", "output_index": 0, "item": item} + yield response_completed(response_id, model, created, [item], usage) + return + raise RuntimeError("image generation failed") + + +def collect_response(events: Iterable[dict[str, Any]]) -> dict[str, Any]: + completed = {} + for event in events: + if event.get("type") == "response.completed": + completed = event.get("response") if isinstance(event.get("response"), dict) else {} + if not completed: + raise RuntimeError("response generation failed") + return completed + + +def response_events(body: dict[str, Any]) -> Iterator[dict[str, Any]]: + if is_text_response_request(body): + model, messages = text_response_parts(body) + key = cache_key(body, messages, stream=bool(body.get("stream"))) + yield from chat_completion_cache.get_or_compute_stream( + key, + lambda: stream_text_response(text_backend(), body, messages), + ) + return + + prompt = extract_response_prompt(body.get("input")) + if not prompt: + raise HTTPException(status_code=400, detail={"error": "input text is required"}) + model = str(body.get("model") or "gpt-image-2").strip() or "gpt-image-2" + image_info = extract_response_image(body.get("input")) + if image_info: + image_data, mime_type = image_info + images = encode_images([(image_data, "image.png", mime_type)]) + else: + images = None + input_image_tokens = count_image_content_tokens(_input_image_parts(body.get("input")), model) + tool = response_image_tool(body) + image_outputs = stream_image_outputs_with_pool(ConversationRequest( + prompt=prompt, + model=model, + size=tool.get("size"), + quality=str(tool.get("quality") or "auto"), + response_format="b64_json", + images=images, + )) + yield from stream_image_response(image_outputs, prompt, model, input_image_tokens, tool.get("size"), str(tool.get("quality") or "auto")) + + +def handle(body: dict[str, Any]) -> dict[str, Any] | Iterator[dict[str, Any]]: + events = response_events(body) + if body.get("stream"): + return events + return collect_response(events) diff --git a/services/proxy_service.py b/services/proxy_service.py new file mode 100644 index 0000000000000000000000000000000000000000..b851c66f4abffc54976bc4a48b64a159f98d47a2 --- /dev/null +++ b/services/proxy_service.py @@ -0,0 +1,64 @@ +"""Global outbound proxy helpers for upstream ChatGPT and CPA requests.""" + +from __future__ import annotations + +import time +from urllib.parse import urlparse + +from curl_cffi.requests import Session + +from services.config import config + + +class ProxySettingsStore: + def build_session_kwargs(self, account: dict | None = None, proxy: str = "", **session_kwargs) -> dict[str, object]: + account_proxy = str((account or {}).get("proxy") or "").strip() + proxy = str(proxy or account_proxy or config.get_proxy_settings()).strip() + if proxy: + session_kwargs["proxy"] = proxy + return session_kwargs + + +def _clean(value: object) -> str: + return str(value or "").strip() + + +def _is_valid_proxy_url(url: str) -> bool: + parsed = urlparse(url) + return parsed.scheme in {"http", "https", "socks5", "socks5h"} and bool(parsed.netloc) + + +def test_proxy(url: str, *, timeout: float = 15.0) -> dict: + candidate = _clean(url) + if not candidate: + return {"ok": False, "status": 0, "latency_ms": 0, "error": "proxy url is required"} + if not _is_valid_proxy_url(candidate): + return {"ok": False, "status": 0, "latency_ms": 0, "error": "invalid proxy url"} + session = Session(impersonate="edge101", verify=True, proxy=candidate) + started = time.perf_counter() + try: + response = session.get( + "https://chatgpt.com/api/auth/csrf", + headers={"user-agent": "Mozilla/5.0 (chatgpt2api proxy test)"}, + timeout=timeout, + ) + latency_ms = int((time.perf_counter() - started) * 1000) + return { + "ok": response.status_code < 500, + "status": int(response.status_code), + "latency_ms": latency_ms, + "error": None if response.status_code < 500 else f"HTTP {response.status_code}", + } + except Exception as exc: + latency_ms = int((time.perf_counter() - started) * 1000) + return { + "ok": False, + "status": 0, + "latency_ms": latency_ms, + "error": str(exc) or exc.__class__.__name__, + } + finally: + session.close() + +proxy_settings = ProxySettingsStore() + diff --git a/services/register/__init__.py b/services/register/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/services/register/mail_provider.py b/services/register/mail_provider.py new file mode 100644 index 0000000000000000000000000000000000000000..8b2e0f7f8e80cba7ff38832e75dc2ba3be9ba432 --- /dev/null +++ b/services/register/mail_provider.py @@ -0,0 +1,990 @@ +from __future__ import annotations + +import hashlib +import json +import random +import re +import string +import time +from datetime import datetime, timezone +from email import message_from_string, policy +from email.utils import parsedate_to_datetime +from threading import Lock +from typing import Any, Callable, TypeVar + +from curl_cffi import requests + + +from services.config import DATA_DIR + +DDG_ALIASES_FILE = DATA_DIR / "ddg_aliases.json" +_ddg_aliases_lock = Lock() + + +def _load_ddg_aliases() -> set[str]: + try: + if DDG_ALIASES_FILE.exists(): + data = json.loads(DDG_ALIASES_FILE.read_text(encoding="utf-8")) + if isinstance(data, list): + return {str(item).strip().lower() for item in data if str(item).strip()} + except Exception: + pass + return set() + + +def _save_ddg_aliases(aliases: set[str]) -> None: + DDG_ALIASES_FILE.parent.mkdir(parents=True, exist_ok=True) + DDG_ALIASES_FILE.write_text(json.dumps(sorted(aliases), ensure_ascii=False, indent=2) + "\n", encoding="utf-8") + + +def _is_ddg_alias_duplicate(address: str) -> bool: + target = str(address or "").strip().lower() + if not target: + return False + with _ddg_aliases_lock: + used = _load_ddg_aliases() + return target in used + + +def _record_ddg_alias(address: str) -> None: + target = str(address or "").strip().lower() + if not target: + return + with _ddg_aliases_lock: + used = _load_ddg_aliases() + used.add(target) + _save_ddg_aliases(used) + + +ResultT = TypeVar("ResultT") +domain_lock = Lock() +provider_lock = Lock() +domain_index = 0 +provider_index = 0 +cloudmail_token_lock = Lock() +cloudmail_token_cache: dict[str, tuple[str, float]] = {} + + +def _config(mail_config: dict) -> dict: + return { + "request_timeout": float(mail_config.get("request_timeout") or 30), + "wait_timeout": float(mail_config.get("wait_timeout") or 30), + "wait_interval": float(mail_config.get("wait_interval") or 2), + "user_agent": str(mail_config.get("user_agent") or "Mozilla/5.0"), + "proxy": str(mail_config.get("proxy") or "").strip(), + } + + +def _random_mailbox_name() -> str: + return f"{''.join(random.choices(string.ascii_lowercase, k=5))}{''.join(random.choices(string.digits, k=random.randint(1, 3)))}{''.join(random.choices(string.ascii_lowercase, k=random.randint(1, 3)))}" + + +def _random_subdomain_label() -> str: + return "".join(random.choices(string.ascii_lowercase + string.digits, k=random.randint(4, 10))) + + +def _next_domain(domains: list[str]) -> str: + global domain_index + domains = [str(item).strip() for item in domains if str(item).strip()] + if not domains: + raise RuntimeError("mail.domain 不能为空") + if len(domains) == 1: + return domains[0] + with domain_lock: + value = domains[domain_index % len(domains)] + domain_index = (domain_index + 1) % len(domains) + return value + + +def _normalize_string_list(value: Any) -> list[str]: + if isinstance(value, list): + return [str(item).strip() for item in value if str(item).strip()] + text = str(value or "").strip() + return [text] if text else [] + + +def _create_session(conf: dict): + proxy = str(conf.get("proxy") or "").strip() + kwargs = {"impersonate": "chrome", "verify": False} + if proxy: + kwargs["proxy"] = proxy + return requests.Session(**kwargs) + + +def _parse_received_at(value: Any) -> datetime | None: + if isinstance(value, (int, float)): + try: + return datetime.fromtimestamp(float(value), tz=timezone.utc) + except Exception: + return None + text = str(value or "").strip() + if not text: + return None + try: + date = datetime.fromisoformat(text[:-1] + "+00:00" if text.endswith("Z") else text) + return date if date.tzinfo else date.replace(tzinfo=timezone.utc) + except Exception: + pass + try: + date = parsedate_to_datetime(text) + return date if date.tzinfo else date.replace(tzinfo=timezone.utc) + except Exception: + return None + + +def _extract_content(data: dict[str, Any]) -> tuple[str, str]: + text_content = str(data.get("text_content") or data.get("text") or data.get("body") or data.get("content") or "") + html_content = str(data.get("html_content") or data.get("html") or data.get("html_body") or data.get("body_html") or "") + if text_content or html_content: + return text_content, html_content + raw = data.get("raw") + if not isinstance(raw, str) or not raw.strip(): + return "", "" + try: + parsed = message_from_string(raw, policy=policy.default) + except Exception: + return raw, "" + plain: list[str] = [] + html: list[str] = [] + for part in parsed.walk() if parsed.is_multipart() else [parsed]: + if part.get_content_maintype() == "multipart": + continue + try: + payload = part.get_content() + except Exception: + payload = "" + if not payload: + continue + if part.get_content_type() == "text/html": + html.append(str(payload)) + else: + plain.append(str(payload)) + return "\n".join(plain).strip(), "\n".join(html).strip() + + +def _extract_text_candidates(value: Any) -> list[str]: + if isinstance(value, str): + return [value] + if isinstance(value, dict): + out: list[str] = [] + for key in ("address", "email", "name", "value"): + if value.get(key): + out.extend(_extract_text_candidates(value.get(key))) + return out + if isinstance(value, list): + out: list[str] = [] + for item in value: + out.extend(_extract_text_candidates(item)) + return out + return [] + + +def _message_matches_email(data: dict[str, Any], email: str) -> bool: + target = str(email or "").strip().lower() + candidates: list[str] = [] + for key in ("to", "mailTo", "receiver", "receivers", "address", "email", "envelope_to"): + if key in data: + candidates.extend(_extract_text_candidates(data.get(key))) + return not target or not candidates or any(target in str(item).strip().lower() for item in candidates if str(item).strip()) + + +def _extract_code(message: dict[str, Any]) -> str | None: + content = f"{message.get('subject', '')}\n{message.get('text_content', '')}\n{message.get('html_content', '')}".strip() + if not content: + return None + match = re.search(r"background-color:\s*#F3F3F3[^>]*>[\s\S]*?(\d{6})[\s\S]*?

", content, re.I) + if match: + return match.group(1) + match = re.search(r"(?:Verification code|code is|代码为|验证码)[:\s]*(\d{6})", content, re.I) + if match and match.group(1) != "177010": + return match.group(1) + for code in re.findall(r">\s*(\d{6})\s*<|(? str: + provider = str(message.get("provider") or "").strip() + mailbox = str(message.get("mailbox") or "").strip() + message_id = str(message.get("message_id") or "").strip() + if message_id: + return f"id:{provider}:{mailbox}:{message_id}" + received_at = message.get("received_at") + received_value = received_at.isoformat() if isinstance(received_at, datetime) else str(received_at or "") + content = "\n".join(str(message.get(key) or "") for key in ("subject", "sender", "text_content", "html_content")) + digest = hashlib.sha256(content.encode("utf-8", errors="replace")).hexdigest() + return f"content:{provider}:{mailbox}:{received_value}:{digest}" + + +class BaseMailProvider: + name = "unknown" + + def __init__(self, conf: dict, provider_ref: str = ""): + self.conf = conf + self.provider_ref = provider_ref + + def wait_for(self, mailbox: dict[str, Any], on_message: Callable[[dict[str, Any]], ResultT | None]) -> ResultT | None: + deadline = time.monotonic() + self.conf["wait_timeout"] + while time.monotonic() < deadline: + message = self.fetch_latest_message(mailbox) + if message: + result = on_message(message) + if result is not None: + return result + time.sleep(max(0.2, self.conf["wait_interval"])) + return None + + def wait_for_code(self, mailbox: dict[str, Any]) -> str | None: + seen_value = mailbox.setdefault("_seen_code_message_refs", []) + if not isinstance(seen_value, list): + seen_value = [] + mailbox["_seen_code_message_refs"] = seen_value + seen_refs = {str(item) for item in seen_value} + + def extract_unseen_code(message: dict[str, Any]) -> str | None: + ref = _message_tracking_ref(message) + if ref in seen_refs: + return None + code = _extract_code(message) + if code: + seen_value.append(ref) + seen_refs.add(ref) + return code + + return self.wait_for(mailbox, extract_unseen_code) + + def close(self) -> None: + pass + + +class CloudflareTempMailProvider(BaseMailProvider): + name = "cloudflare_temp_email" + + def __init__(self, entry: dict, conf: dict): + super().__init__(conf, str(entry.get("provider_ref") or "")) + self.api_base = str(entry["api_base"]).rstrip("/") + self.admin_password = str(entry["admin_password"]).strip() + self.domain = entry.get("domain") or [] + self.session = _create_session(conf) + + def _request(self, method: str, path: str, headers: dict | None = None, params: dict | None = None, payload: dict | None = None, expected: tuple[int, ...] = (200,)): + resp = self.session.request(method.upper(), f"{self.api_base}{path}", headers={"Content-Type": "application/json", "User-Agent": self.conf["user_agent"], **(headers or {})}, params=params, json=payload, timeout=self.conf["request_timeout"], verify=False) + if resp.status_code not in expected: + raise RuntimeError(f"CloudflareTempMail 请求失败: {method} {path}, HTTP {resp.status_code}, body={resp.text[:300]}") + return {} if resp.status_code == 204 else resp.json() + + def create_mailbox(self, username: str | None = None) -> dict[str, Any]: + data = self._request("POST", "/admin/new_address", headers={"x-admin-auth": self.admin_password}, payload={"enablePrefix": True, "name": username or _random_mailbox_name(), "domain": _next_domain(self.domain)}) + address = str(data.get("address") or "").strip() + token = str(data.get("jwt") or "").strip() + if not address or not token: + raise RuntimeError("CloudflareTempMail 缺少 address 或 jwt") + return {"provider": self.name, "provider_ref": self.provider_ref, "address": address, "token": token} + + def fetch_latest_message(self, mailbox: dict[str, Any]) -> dict[str, Any] | None: + data = self._request("GET", "/api/mails", headers={"Authorization": f"Bearer {mailbox['token']}"}, params={"limit": 10, "offset": 0}) + raw = list(data.get("results") or []) if isinstance(data, dict) else data if isinstance(data, list) else [] + messages = [item for item in raw if isinstance(item, dict) and _message_matches_email(item, str(mailbox.get("address") or ""))] + if not messages: + return None + item = messages[0] + text_content, html_content = _extract_content(item) + sender = item.get("from") or item.get("sender") or "" + if isinstance(sender, dict): + sender = sender.get("address") or sender.get("email") or sender.get("name") or "" + return {"provider": self.name, "mailbox": mailbox["address"], "message_id": str(item.get("id") or item.get("_id") or ""), "subject": str(item.get("subject") or ""), "sender": str(sender), "text_content": text_content, "html_content": html_content, "received_at": _parse_received_at(item.get("createdAt") or item.get("created_at") or item.get("receivedAt") or item.get("date") or item.get("timestamp")), "raw": item} + + def close(self) -> None: + self.session.close() + + +class DDGMailProvider(BaseMailProvider): + name = "ddg_mail" + + def __init__(self, entry: dict, conf: dict): + super().__init__(conf, str(entry.get("provider_ref") or "")) + self.label = str(entry.get("label") or self.provider_ref) + self.ddg_token = str(entry["ddg_token"]).strip() + self.cf_api_base = str(entry.get("api_base") or entry.get("cf_api_base") or "").rstrip("/") + self.cf_inbox_jwt = str(entry.get("cf_inbox_jwt") or "").strip() + self.cf_admin_password = str(entry.get("admin_password") or "").strip() + self.cf_api_key = str(entry.get("cf_api_key") or "").strip() + self.cf_auth_mode = str(entry.get("cf_auth_mode") or "none").strip().lower() + self.cf_domain = entry.get("cf_domain") or [] + self.cf_create_path = str(entry.get("cf_create_path") or "/api/new_address").strip() + self.cf_messages_path = str(entry.get("cf_messages_path") or "/api/mails").strip() + self.session = _create_session(conf) + + def _cf_build_headers(self, content_type: bool = False) -> dict: + headers = {"Content-Type": "application/json"} if content_type else {} + if self.cf_api_key: + if self.cf_auth_mode == "x-api-key": + headers["X-API-Key"] = self.cf_api_key + elif self.cf_auth_mode != "none": + headers["Authorization"] = f"Bearer {self.cf_api_key}" + return headers + + def _cf_request(self, method: str, path: str, headers: dict | None = None, params: dict | None = None, payload: dict | None = None, expected: tuple[int, ...] = (200,)) -> dict: + merged_headers = {**self._cf_build_headers(True), **(headers or {}), "User-Agent": self.conf["user_agent"]} + if self.cf_admin_password and method.upper() in ("POST",): + merged_headers["x-admin-auth"] = self.cf_admin_password + if self.cf_api_key and self.cf_auth_mode == "query-key": + params = {**(params or {}), "key": self.cf_api_key} + resp = self.session.request(method.upper(), f"{self.cf_api_base}{path}", headers=merged_headers, params=params, json=payload, timeout=self.conf["request_timeout"], verify=False) + if resp.status_code not in expected: + raise RuntimeError(f"DDGMail CF请求失败: {method} {path}, HTTP {resp.status_code}, body={resp.text[:300]}") + return {} if resp.status_code == 204 else resp.json() + + def _ddg_request(self, method: str, path: str, payload: dict | None = None) -> dict: + resp = self.session.request(method.upper(), f"https://quack.duckduckgo.com{path}", headers={"Authorization": f"Bearer {self.ddg_token}", "Content-Type": "application/json", "User-Agent": self.conf["user_agent"]}, json=payload, timeout=self.conf["request_timeout"], verify=False) + if resp.status_code not in (200, 201): + raise RuntimeError(f"DDG API请求失败: {method} {path}, HTTP {resp.status_code}, body={resp.text[:300]}") + return resp.json() + + def _cf_list_payload(self, data: Any) -> list: + if isinstance(data, list): + return data + if isinstance(data, dict): + for key in ("results", "hydra:member", "data", "messages"): + value = data.get(key) + if isinstance(value, list): + return value + if isinstance(value, dict) and isinstance(value.get("messages"), list): + return value["messages"] + return [] + + def create_mailbox(self, username: str | None = None) -> dict[str, Any]: + ddg_data = self._ddg_request("POST", "/api/email/addresses", payload={}) + ddg_address_part = str(ddg_data.get("address") or "").strip() + if not ddg_address_part: + raise RuntimeError("DDG API 返回无 address 字段") + ddg_address = f"{ddg_address_part}@duck.com" + + if _is_ddg_alias_duplicate(ddg_address): + raise RuntimeError(f"[{self.label}] DDG日上限已达,别名 {ddg_address} 已存在,自动切换邮箱提供商") + + _record_ddg_alias(ddg_address) + + if not self.cf_inbox_jwt: + raise RuntimeError("DDGMail 需要 cf_inbox_jwt(DDG 转发目标的固定收件箱 JWT),请在邮箱配置中填写 CF Inbox JWT") + + return {"provider": self.name, "provider_ref": self.provider_ref, "address": ddg_address, "token": self.cf_inbox_jwt, "label": self.label} + + def _parse_raw_recipient(self, raw_text: str) -> str: + if not raw_text: + return "" + match = re.search(r"^To:\s*(.+?)$", raw_text, re.MULTILINE | re.IGNORECASE) + if match: + addr = match.group(1).strip() + addr = re.sub(r"\s*<[^>]*>", "", addr) + return addr.strip().lower() + try: + parsed = message_from_string(raw_text, policy=policy.default) + return str(parsed.get("To") or "").strip().lower() + except Exception: + return "" + + def fetch_latest_message(self, mailbox: dict[str, Any]) -> dict[str, Any] | None: + target_address = str(mailbox.get("address") or "").strip().lower() + data = self._cf_request("GET", self.cf_messages_path, headers={"Authorization": f"Bearer {mailbox['token']}"}, params={"limit": 30, "offset": 0}) + raw_list = self._cf_list_payload(data) + messages = [item for item in raw_list if isinstance(item, dict)] + if not messages: + return None + + for item in messages: + message_id = str(item.get("id") or item.get("msgid") or item.get("_id") or "") + raw_text = str(item.get("raw") or "") + raw_recipient = self._parse_raw_recipient(raw_text) + if target_address and raw_recipient and target_address not in raw_recipient: + continue + text_content, html_content = _extract_content(item) + subject = str(item.get("subject") or "") + sender = item.get("from") or item.get("sender") or item.get("source") or "" + if isinstance(sender, dict): + sender = sender.get("address") or sender.get("email") or sender.get("name") or "" + if raw_text and (not subject or not sender or subject == sender == ""): + try: + parsed = message_from_string(raw_text, policy=policy.default) + if not subject: + subject = str(parsed.get("Subject") or "") + if not sender: + sender = str(parsed.get("From") or "") + except Exception: + pass + return {"provider": self.name, "mailbox": mailbox["address"], "message_id": message_id, "subject": subject, "sender": str(sender), "text_content": text_content, "html_content": html_content, "received_at": _parse_received_at(item.get("createdAt") or item.get("created_at") or item.get("receivedAt") or item.get("date") or item.get("timestamp")), "raw": item} + + return None + + def close(self) -> None: + self.session.close() + + +class CloudMailGenProvider(BaseMailProvider): + name = "cloudmail_gen" + + def __init__(self, entry: dict, conf: dict): + super().__init__(conf, str(entry.get("provider_ref") or "")) + self.api_base = str(entry["api_base"]).rstrip("/") + self.admin_email = str(entry.get("admin_email") or "").strip() + self.admin_password = str(entry.get("admin_password") or "").strip() + self.domain = _normalize_string_list(entry.get("domain")) + self.subdomain = _normalize_string_list(entry.get("subdomain")) + self.email_prefix = str(entry.get("email_prefix") or "").strip() + self.session = _create_session(conf) + + def _request( + self, + method: str, + path: str, + headers: dict | None = None, + params: dict | None = None, + payload: dict | None = None, + expected: tuple[int, ...] = (200,), + ): + resp = self.session.request( + method.upper(), + f"{self.api_base}{path}", + headers={ + "Content-Type": "application/json", + "User-Agent": self.conf["user_agent"], + **(headers or {}), + }, + params=params, + json=payload, + timeout=self.conf["request_timeout"], + verify=False, + ) + if resp.status_code not in expected: + raise RuntimeError(f"CloudMailGen 请求失败: {method} {path}, HTTP {resp.status_code}, body={resp.text[:300]}") + return {} if resp.status_code == 204 else resp.json() + + def _cache_key(self) -> str: + return f"{self.api_base}|{self.admin_email}" + + def _get_token(self) -> str: + if not self.admin_email or not self.admin_password: + raise RuntimeError("CloudMailGen 缺少 admin_email 或 admin_password") + cache_key = self._cache_key() + now = time.time() + with cloudmail_token_lock: + cached = cloudmail_token_cache.get(cache_key) + if cached and now < cached[1] - 300: + return cached[0] + data = self._request( + "POST", + "/api/public/genToken", + payload={"email": self.admin_email, "password": self.admin_password}, + ) + token = "" + if isinstance(data, dict) and data.get("code") == 200: + token = str((data.get("data") or {}).get("token") or "").strip() + if not token: + raise RuntimeError(f"CloudMailGen genToken 返回异常: {data}") + with cloudmail_token_lock: + cloudmail_token_cache[cache_key] = (token, now + 24 * 3600) + return token + + def _resolve_address(self, username: str | None = None) -> str: + domain = _next_domain(self.domain) + if self.subdomain: + domain = f"{random.choice(self.subdomain)}.{domain}" + if username: + local_part = username + elif self.email_prefix: + local_part = f"{self.email_prefix}_{''.join(random.choices(string.ascii_lowercase + string.digits, k=6))}" + else: + local_part = _random_mailbox_name() + return f"{local_part}@{domain}" + + def create_mailbox(self, username: str | None = None) -> dict[str, Any]: + if not self.domain: + raise RuntimeError("CloudMailGen 需要至少配置一个 domain") + address = self._resolve_address(username) + return {"provider": self.name, "provider_ref": self.provider_ref, "address": address} + + def fetch_latest_message(self, mailbox: dict[str, Any]) -> dict[str, Any] | None: + address = str(mailbox.get("address") or "").strip() + if not address: + raise RuntimeError("CloudMailGen 缺少 address") + token = self._get_token() + data = self._request( + "POST", + "/api/public/emailList", + headers={"Authorization": token}, + payload={"toEmail": address, "size": 20, "timeSort": "desc"}, + ) + items = (data.get("data") or []) if isinstance(data, dict) and data.get("code") == 200 else [] + messages = [item for item in items if isinstance(item, dict) and _message_matches_email(item, address)] + if not messages: + return None + item = messages[0] + text_content, html_content = _extract_content(item) + return { + "provider": self.name, + "mailbox": address, + "message_id": str(item.get("id") or item.get("_id") or item.get("messageId") or ""), + "subject": str(item.get("subject") or ""), + "sender": str(item.get("from") or item.get("sender") or ""), + "text_content": text_content, + "html_content": html_content, + "received_at": _parse_received_at( + item.get("createdAt") or item.get("created_at") or item.get("receivedAt") or item.get("date") or item.get("timestamp") + ), + "to": item.get("to") or item.get("toEmail") or item.get("mailTo"), + "raw": item, + } + + def close(self) -> None: + self.session.close() + + +class TempMailLolProvider(BaseMailProvider): + name = "tempmail_lol" + + def __init__(self, entry: dict, conf: dict): + super().__init__(conf, str(entry.get("provider_ref") or "")) + self.api_key = str(entry.get("api_key") or "").strip() + self.domain = [str(item).strip() for item in (entry.get("domain") or []) if str(item).strip()] + self.session = _create_session(conf) + self.session.headers.update({"User-Agent": conf["user_agent"], "Accept": "application/json", "Content-Type": "application/json"}) + if self.api_key: + self.session.headers["Authorization"] = f"Bearer {self.api_key}" + + @staticmethod + def _resolve_domain(domain: str) -> tuple[str, bool]: + text = str(domain or "").strip().lower() + if text.startswith("*.") and len(text) > 2: + return f"{_random_subdomain_label()}.{text[2:]}", True + return text, False + + def _request(self, method: str, path: str, params: dict | None = None, payload: dict | None = None, expected: tuple[int, ...] = (200,)): + resp = self.session.request(method.upper(), f"https://api.tempmail.lol/v2{path}", params=params, json=payload, timeout=self.conf["request_timeout"], verify=False) + if resp.status_code not in expected: + raise RuntimeError(f"TempMail.lol 请求失败: {method} {path}, HTTP {resp.status_code}, body={resp.text[:300]}") + data = resp.json() + if not isinstance(data, dict): + raise RuntimeError(f"TempMail.lol {method} {path} 返回结构不是对象") + return data + + def create_mailbox(self, username: str | None = None) -> dict[str, Any]: + payload: dict[str, Any] = {} + if self.domain: + domain, force_random_prefix = self._resolve_domain(random.choice(self.domain)) + payload["domain"] = domain + if force_random_prefix: + payload["prefix"] = _random_mailbox_name() + if username and "prefix" not in payload: + payload["prefix"] = username + data = self._request("POST", "/inbox/create", payload=payload, expected=(200, 201)) + address = str(data.get("address") or "").strip() + token = str(data.get("token") or "").strip() + if not address or not token: + raise RuntimeError("TempMail.lol 缺少 address 或 token") + return {"provider": self.name, "provider_ref": self.provider_ref, "address": address, "token": token} + + def fetch_latest_message(self, mailbox: dict[str, Any]) -> dict[str, Any] | None: + data = self._request("GET", "/inbox", params={"token": mailbox["token"]}) + items = data.get("emails") or data.get("messages") or [] + messages = [item for item in items if isinstance(item, dict)] if isinstance(items, list) else [] + if not messages: + return None + item = max(messages, key=lambda value: ((_parse_received_at(value.get("created_at") or value.get("createdAt") or value.get("date") or value.get("received_at") or value.get("timestamp")) or datetime.fromtimestamp(0, tz=timezone.utc)).timestamp(), str(value.get("id") or value.get("token") or ""))) + text_content, html_content = _extract_content(item) + return {"provider": self.name, "mailbox": mailbox["address"], "message_id": str(item.get("id") or item.get("token") or ""), "subject": str(item.get("subject") or ""), "sender": str(item.get("from") or item.get("from_address") or ""), "text_content": text_content, "html_content": html_content, "received_at": _parse_received_at(item.get("created_at") or item.get("createdAt") or item.get("date") or item.get("received_at") or item.get("timestamp")), "raw": item} + + def close(self) -> None: + self.session.close() + + +class DuckMailProvider(BaseMailProvider): + name = "duckmail" + + def __init__(self, entry: dict, conf: dict): + super().__init__(conf, str(entry.get("provider_ref") or "")) + self.api_key = str(entry["api_key"]).strip() + self.default_domain = str(entry.get("default_domain") or "duckmail.sbs").strip() or "duckmail.sbs" + self.session = _create_session(conf) + self.session.headers.update({"User-Agent": conf["user_agent"], "Accept": "application/json", "Content-Type": "application/json"}) + + def _request(self, method: str, path: str, token: str = "", use_api_key: bool = False, params: dict | None = None, payload: dict | None = None, expected: tuple[int, ...] = (200, 201, 204)): + headers = {"Authorization": f"Bearer {self.api_key if use_api_key else token}"} if use_api_key or token else {} + resp = self.session.request(method.upper(), f"https://api.duckmail.sbs{path}", headers=headers, params=params, json=payload, timeout=self.conf["request_timeout"], verify=False) + if resp.status_code not in expected: + raise RuntimeError(f"DuckMail 请求失败: {method} {path}, HTTP {resp.status_code}, body={resp.text[:300]}") + return {} if resp.status_code == 204 else resp.json() + + @staticmethod + def _items(data): + return data if isinstance(data, list) else data.get("hydra:member") or data.get("member") or data.get("data") or [] + + def create_mailbox(self, username: str | None = None) -> dict[str, Any]: + password = "".join(random.choices(string.ascii_letters + string.digits, k=12)) + address = f"{username or _random_mailbox_name()}@{self.default_domain}" + payload = {"address": address, "password": password} + account = self._request("POST", "/accounts", use_api_key=True, payload=payload) + token_data = self._request("POST", "/token", use_api_key=True, payload=payload) + return {"provider": self.name, "provider_ref": self.provider_ref, "address": address, "token": str(token_data.get("token") or ""), "password": password, "account_id": str(account.get("id") or "")} + + def fetch_latest_message(self, mailbox: dict[str, Any]) -> dict[str, Any] | None: + data = self._request("GET", "/messages", token=str(mailbox.get("token") or ""), params={"page": 1}) + items = self._items(data) + if not items: + return None + item = items[0] + message_id = str(item.get("id") or item.get("@id") or "").replace("/messages/", "") + if message_id: + item = self._request("GET", f"/messages/{message_id}", token=str(mailbox.get("token") or "")) + sender = item.get("from") or "" + if isinstance(sender, dict): + sender = sender.get("address") or sender.get("name") or "" + html_content = item.get("html") or "" + if isinstance(html_content, list): + html_content = "".join(str(value) for value in html_content) + return {"provider": self.name, "mailbox": mailbox["address"], "message_id": message_id, "subject": str(item.get("subject") or ""), "sender": str(sender), "text_content": str(item.get("text") or item.get("text_content") or ""), "html_content": str(html_content), "received_at": _parse_received_at(item.get("createdAt") or item.get("created_at") or item.get("receivedAt") or item.get("date")), "raw": item} + + def close(self) -> None: + self.session.close() + + +class GptMailProvider(BaseMailProvider): + name = "gptmail" + + def __init__(self, entry: dict, conf: dict): + super().__init__(conf, str(entry.get("provider_ref") or "")) + self.api_key = str(entry["api_key"]).strip() + self.default_domain = str(entry.get("default_domain") or "").strip() + self.session = _create_session(conf) + self.session.headers.update({"User-Agent": conf["user_agent"], "Accept": "application/json", "Content-Type": "application/json", "X-API-Key": self.api_key}) + + def _request(self, method: str, path: str, params: dict | None = None, payload: dict | None = None): + query = dict(params or {}) + resp = self.session.request(method.upper(), f"https://mail.chatgpt.org.uk{path}", params=query, json=payload, timeout=self.conf["request_timeout"], verify=False) + if resp.status_code != 200: + raise RuntimeError(f"GPTMail 请求失败: {method} {path}, HTTP {resp.status_code}, body={resp.text[:300]}") + data = resp.json() + return data["data"] if isinstance(data, dict) and "data" in data else data + + def create_mailbox(self, username: str | None = None) -> dict[str, Any]: + payload = {key: value for key, value in {"prefix": username, "domain": self.default_domain}.items() if value} + data = self._request("POST" if payload else "GET", "/api/generate-email", payload=payload or None) + return {"provider": self.name, "provider_ref": self.provider_ref, "address": str(data["email"])} + + def fetch_latest_message(self, mailbox: dict[str, Any]) -> dict[str, Any] | None: + data = self._request("GET", "/api/emails", params={"email": mailbox["address"]}) + emails = data if isinstance(data, list) else data.get("emails") or [] + if not emails: + return None + item = max(emails, key=lambda value: (float(value.get("timestamp") or 0), str(value.get("id") or ""))) + if item.get("id"): + item = self._request("GET", f"/api/email/{item['id']}") + return {"provider": self.name, "mailbox": mailbox["address"], "message_id": str(item.get("id") or ""), "subject": str(item.get("subject") or ""), "sender": str(item.get("from_address") or ""), "text_content": str(item.get("content") or ""), "html_content": str(item.get("html_content") or ""), "received_at": _parse_received_at(item.get("timestamp") or item.get("created_at")), "raw": item} + + def close(self) -> None: + self.session.close() + + +class MoEmailProvider(BaseMailProvider): + name = "moemail" + + def __init__(self, entry: dict, conf: dict): + super().__init__(conf, str(entry.get("provider_ref") or "")) + self.api_base = str(entry["api_base"]).rstrip("/") + self.api_key = str(entry["api_key"]).strip() + raw_domains = entry.get("domain") or [] + if isinstance(raw_domains, list): + self.domain = [str(item).strip() for item in raw_domains if str(item).strip()] + else: + self.domain = [str(raw_domains).strip()] if str(raw_domains).strip() else [] + self.expiry_time = int(entry.get("expiry_time") or 0) + self.session = _create_session(conf) + + def _request(self, method: str, path: str, params: dict | None = None, payload: dict | None = None, expected: tuple[int, ...] = (200,)): + resp = self.session.request(method.upper(), f"{self.api_base}{path}", headers={"X-API-Key": self.api_key, "Content-Type": "application/json", "User-Agent": self.conf["user_agent"]}, params=params, json=payload, timeout=self.conf["request_timeout"], verify=False) + if resp.status_code not in expected: + raise RuntimeError(f"MoEmail 请求失败: {method} {path}, HTTP {resp.status_code}, body={resp.text[:300]}") + data = resp.json() + if not isinstance(data, dict): + raise RuntimeError(f"MoEmail {method} {path} 返回结构不是对象") + return data + + def create_mailbox(self, username: str | None = None) -> dict[str, Any]: + data = self._request("POST", "/api/emails/generate", payload={"name": username or _random_mailbox_name(), "expiryTime": self.expiry_time, "domain": _next_domain(self.domain)}, expected=(200, 201)) + address = str(data.get("email") or "").strip() + email_id = str(data.get("id") or data.get("email_id") or "").strip() + if not address or not email_id: + raise RuntimeError("MoEmail 缺少 email 或 id") + return {"provider": self.name, "provider_ref": self.provider_ref, "address": address, "email_id": email_id} + + def fetch_latest_message(self, mailbox: dict[str, Any]) -> dict[str, Any] | None: + email_id = str(mailbox.get("email_id") or "").strip() + if not email_id: + raise RuntimeError("MoEmail 缺少 email_id") + data = self._request("GET", f"/api/emails/{email_id}") + items = data.get("messages") or [] + messages = [item for item in items if isinstance(item, dict)] if isinstance(items, list) else [] + if not messages: + return None + _, item = max(enumerate(messages), key=lambda pair: (((_parse_received_at(pair[1].get("createdAt") or pair[1].get("created_at") or pair[1].get("receivedAt") or pair[1].get("date") or pair[1].get("timestamp")) or datetime.fromtimestamp(0, tz=timezone.utc)).timestamp()), pair[0])) + message_id = str(item.get("id") or item.get("message_id") or item.get("_id") or "").strip() + detail = self._request("GET", f"/api/emails/{email_id}/{message_id}") if message_id else {"message": item} + message = detail.get("message") if isinstance(detail.get("message"), dict) else detail + text_content, html_content = _extract_content(message) + sender = message.get("from") or message.get("sender") or "" + if isinstance(sender, dict): + sender = sender.get("address") or sender.get("email") or sender.get("name") or "" + return {"provider": self.name, "mailbox": mailbox["address"], "message_id": message_id, "subject": str(message.get("subject") or item.get("subject") or ""), "sender": str(sender), "text_content": text_content, "html_content": html_content, "received_at": _parse_received_at(message.get("createdAt") or message.get("created_at") or message.get("receivedAt") or message.get("date") or message.get("timestamp") or item.get("createdAt") or item.get("created_at") or item.get("receivedAt") or item.get("date") or item.get("timestamp")), "raw": detail} + + def close(self) -> None: + self.session.close() + + +class InbucketMailProvider(BaseMailProvider): + name = "inbucket" + + def __init__(self, entry: dict, conf: dict): + super().__init__(conf, str(entry.get("provider_ref") or "")) + self.api_base = str(entry["api_base"]).rstrip("/") + raw_domains = entry.get("domain") or [] + if isinstance(raw_domains, list): + self.domain = [str(item).strip() for item in raw_domains if str(item).strip()] + else: + self.domain = [str(raw_domains).strip()] if str(raw_domains).strip() else [] + self.random_subdomain = bool(entry.get("random_subdomain", True)) + self.session = _create_session(conf) + self.session.headers.update({ + "User-Agent": conf["user_agent"], + "Accept": "application/json", + }) + + def _request(self, method: str, path: str, expected: tuple[int, ...] = (200,)): + resp = self.session.request( + method.upper(), + f"{self.api_base}{path}", + timeout=self.conf["request_timeout"], + verify=False, + ) + if resp.status_code not in expected: + raise RuntimeError(f"Inbucket 请求失败: {method} {path}, HTTP {resp.status_code}, body={resp.text[:300]}") + if resp.status_code == 204: + return {} + content_type = str(resp.headers.get("content-type") or "").lower() + if "application/json" in content_type: + return resp.json() + return resp.text + + def _resolve_domain(self) -> str: + if self.domain: + return _next_domain(self.domain) + raise RuntimeError("Inbucket 需要至少配置一个 domain") + + def _mailbox_name(self, address: str) -> str: + local_part, _, _ = str(address or "").partition("@") + return local_part.strip() + + def create_mailbox(self, username: str | None = None) -> dict[str, Any]: + local_part = username or _random_mailbox_name() + base_domain = self._resolve_domain() + domain = f"{_random_subdomain_label()}.{base_domain}" if self.random_subdomain else base_domain + address = f"{local_part}@{domain}" + mailbox_name = self._mailbox_name(address) + return { + "provider": self.name, + "provider_ref": self.provider_ref, + "address": address, + "base_domain": base_domain, + "mailbox_name": mailbox_name, + } + + def fetch_latest_message(self, mailbox: dict[str, Any]) -> dict[str, Any] | None: + mailbox_name = str(mailbox.get("mailbox_name") or self._mailbox_name(str(mailbox.get("address") or ""))).strip() + if not mailbox_name: + raise RuntimeError("Inbucket 缺少 mailbox_name") + data = self._request("GET", f"/api/v1/mailbox/{mailbox_name}") + items = [item for item in data if isinstance(item, dict)] if isinstance(data, list) else [] + if not items: + return None + items.sort( + key=lambda value: ( + (_parse_received_at(value.get("date")) or datetime.fromtimestamp(0, tz=timezone.utc)).timestamp(), + str(value.get("id") or ""), + ), + reverse=True, + ) + address = str(mailbox.get("address") or "").strip() + for item in items: + message_id = str(item.get("id") or "").strip() + if not message_id: + continue + detail = self._request("GET", f"/api/v1/mailbox/{mailbox_name}/{message_id}") + if not isinstance(detail, dict): + continue + header = detail.get("header") if isinstance(detail.get("header"), dict) else {} + body = detail.get("body") if isinstance(detail.get("body"), dict) else {} + normalized = { + "provider": self.name, + "mailbox": mailbox_name, + "message_id": message_id, + "subject": str(detail.get("subject") or item.get("subject") or ""), + "sender": str(detail.get("from") or item.get("from") or ""), + "text_content": str(body.get("text") or ""), + "html_content": str(body.get("html") or ""), + "received_at": _parse_received_at(detail.get("date") or item.get("date")), + "to": header.get("To") if isinstance(header, dict) else None, + "raw": detail, + } + if _message_matches_email(normalized, address): + return normalized + return None + + def close(self) -> None: + self.session.close() + + +class YydsMailProvider(BaseMailProvider): + name = "yyds_mail" + + def __init__(self, entry: dict, conf: dict): + super().__init__(conf, str(entry.get("provider_ref") or "")) + self.api_base = str(entry.get("api_base") or "https://maliapi.215.im/v1").rstrip("/") + self.api_key = str(entry["api_key"]).strip() + self.domain = [str(item).strip() for item in (entry.get("domain") or []) if str(item).strip()] + self.subdomain = str(entry.get("subdomain") or "").strip() + self.wildcard = bool(entry.get("wildcard")) + self.session = _create_session(conf) + self.session.headers.update({"User-Agent": conf["user_agent"], "Accept": "application/json", "Content-Type": "application/json"}) + + def _request(self, method: str, path: str, token: str = "", params: dict | None = None, payload: dict | None = None, expected: tuple[int, ...] = (200, 201, 204)): + headers = {"Authorization": f"Bearer {token}"} if token else {"X-API-Key": self.api_key} + resp = self.session.request(method.upper(), f"{self.api_base}{path}", headers=headers, params=params, json=payload, timeout=self.conf["request_timeout"], verify=False) + if resp.status_code not in expected: + raise RuntimeError(f"YYDSMail 请求失败: {method} {path}, HTTP {resp.status_code}, body={resp.text[:300]}") + if resp.status_code == 204: + return {} + data = resp.json() + if isinstance(data, dict) and data.get("success") is False: + raise RuntimeError(f"YYDSMail 请求失败: {data.get('errorCode') or data.get('error')}") + return data.get("data") if isinstance(data, dict) and isinstance(data.get("data"), (dict, list)) else data + + @staticmethod + def _items(data): + return data if isinstance(data, list) else data.get("items") or data.get("messages") or data.get("data") or [] + + def create_mailbox(self, username: str | None = None) -> dict[str, Any]: + payload = {"localPart": username or _random_mailbox_name()} + if self.domain: + payload["domain"] = _next_domain(self.domain) + if self.subdomain: + payload["subdomain"] = self.subdomain + data = self._request("POST", "/accounts/wildcard" if self.wildcard else "/accounts", payload=payload) + address = str(data.get("address") or data.get("email") or "").strip() + token = str(data.get("token") or data.get("temp_token") or data.get("tempToken") or data.get("access_token") or "").strip() + if not address or not token: + raise RuntimeError("YYDSMail 缺少 address 或 token") + return {"provider": self.name, "provider_ref": self.provider_ref, "address": address, "token": token, "account_id": str(data.get("id") or "")} + + def fetch_latest_message(self, mailbox: dict[str, Any]) -> dict[str, Any] | None: + data = self._request("GET", "/messages", token=str(mailbox.get("token") or ""), params={"address": mailbox["address"]}) + messages = [item for item in self._items(data) if isinstance(item, dict)] + if not messages: + return None + item = max(messages, key=lambda value: ((_parse_received_at(value.get("createdAt") or value.get("created_at") or value.get("receivedAt") or value.get("date") or value.get("timestamp")) or datetime.fromtimestamp(0, tz=timezone.utc)).timestamp(), str(value.get("id") or ""))) + message_id = str(item.get("id") or item.get("message_id") or "").strip() + if message_id: + item = self._request("GET", f"/messages/{message_id}", token=str(mailbox.get("token") or ""), params={"address": mailbox["address"]}) + text_content, html_content = _extract_content(item) + sender = item.get("from") or item.get("sender") or "" + if isinstance(sender, dict): + sender = sender.get("address") or sender.get("email") or sender.get("name") or "" + return {"provider": self.name, "mailbox": mailbox["address"], "message_id": message_id, "subject": str(item.get("subject") or ""), "sender": str(sender), "text_content": text_content, "html_content": html_content, "received_at": _parse_received_at(item.get("createdAt") or item.get("created_at") or item.get("receivedAt") or item.get("date") or item.get("timestamp")), "raw": item} + + def close(self) -> None: + self.session.close() + + +def _entries(mail_config: dict) -> list[dict]: + result: list[dict] = [] + counters: dict[str, int] = {} + for item in mail_config["providers"]: + idx = len(result) + 1 + t = item.get("type", "") + cnt = counters.get(t, 0) + 1 + counters[t] = cnt + label = f"DDG-{cnt}" if t == "ddg_mail" else f"{t}#{idx}" + result.append({**item, "provider_ref": f"{item['type']}#{idx}", "label": label}) + return result + + +def _enabled_entries(mail_config: dict) -> list[dict]: + items = [item for item in _entries(mail_config) if item.get("enable")] + if not items: + raise RuntimeError("mail.providers 没有启用的 provider") + return items + + +def _next_entry(mail_config: dict) -> dict: + global provider_index + items = _enabled_entries(mail_config) + if len(items) == 1: + return dict(items[0]) + with provider_lock: + value = dict(items[provider_index % len(items)]) + provider_index = (provider_index + 1) % len(items) + return value + + +def _create_provider(mail_config: dict, provider: str = "", provider_ref: str = "") -> BaseMailProvider: + entry = next((dict(item) for item in _entries(mail_config) if provider_ref and item["provider_ref"] == provider_ref), None) + entry = entry or next((dict(item) for item in _enabled_entries(mail_config) if provider and item["type"] == provider), None) or _next_entry(mail_config) + conf = _config(mail_config) + if entry["type"] == "cloudmail_gen": + return CloudMailGenProvider(entry, conf) + if entry["type"] == "cloudflare_temp_email": + return CloudflareTempMailProvider(entry, conf) + if entry["type"] == "ddg_mail": + return DDGMailProvider(entry, conf) + if entry["type"] == "tempmail_lol": + return TempMailLolProvider(entry, conf) + if entry["type"] == "duckmail": + return DuckMailProvider(entry, conf) + if entry["type"] == "gptmail": + return GptMailProvider(entry, conf) + if entry["type"] == "moemail": + return MoEmailProvider(entry, conf) + if entry["type"] == "inbucket": + return InbucketMailProvider(entry, conf) + if entry["type"] == "yyds_mail": + return YydsMailProvider(entry, conf) + raise RuntimeError(f"不支持的 mail.provider: {entry['type']}") + + +def create_mailbox(mail_config: dict, username: str | None = None) -> dict: + enabled = _enabled_entries(mail_config) + tried: set[str] = set() + last_error = "" + for _ in range(len(enabled)): + provider = _create_provider(mail_config) + provider_key = f"{provider.name}#{provider.provider_ref}" + try: + if provider_key in tried: + continue + tried.add(provider_key) + mailbox = provider.create_mailbox(username) + return mailbox + except RuntimeError as error: + last_error = str(error) + if "DDG日上限已达" not in last_error: + raise + finally: + provider.close() + raise RuntimeError(last_error or "所有启用的邮箱提供商均无法创建邮箱") + + +def wait_for_code(mail_config: dict, mailbox: dict) -> str | None: + provider = _create_provider(mail_config, str(mailbox.get("provider") or ""), str(mailbox.get("provider_ref") or "")) + try: + return provider.wait_for_code(mailbox) + finally: + provider.close() diff --git a/services/register/openai_register.py b/services/register/openai_register.py new file mode 100644 index 0000000000000000000000000000000000000000..a6ee203028945757241303a141891103799b7707 --- /dev/null +++ b/services/register/openai_register.py @@ -0,0 +1,558 @@ +from __future__ import annotations + +import base64 +import hashlib +import json +import random +import secrets +import string +import threading +import time +import uuid +from datetime import datetime, timezone +from pathlib import Path +from typing import Any +from urllib.parse import parse_qs, urlencode, urlparse + +from curl_cffi import requests + +from services.account_service import account_service +from services.register import mail_provider + +base_dir = Path(__file__).resolve().parent +config = { + "mail": { + "request_timeout": 30, + "wait_timeout": 30, + "wait_interval": 2, + "providers": [], + }, + "proxy": "", + "total": 10, + "threads": 3, +} +register_config_file = base_dir.parents[1] / "data" / "register.json" +try: + saved_config = json.loads(register_config_file.read_text(encoding="utf-8")) + config.update({key: saved_config[key] for key in ("mail", "proxy", "total", "threads") if key in saved_config}) +except Exception: + pass + +auth_base = "https://auth.openai.com" +platform_base = "https://platform.openai.com" +platform_oauth_client_id = "app_2SKx67EdpoN0G6j64rFvigXD" +platform_oauth_redirect_uri = f"{platform_base}/auth/callback" +platform_oauth_audience = "https://api.openai.com/v1" +platform_auth0_client = "eyJuYW1lIjoiYXV0aDAtc3BhLWpzIiwidmVyc2lvbiI6IjEuMjEuMCJ9" +user_agent = ( + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) " + "AppleWebKit/537.36 (KHTML, like Gecko) " + "Chrome/145.0.0.0 Safari/537.36" +) +sec_ch_ua = '"Google Chrome";v="145", "Not?A_Brand";v="8", "Chromium";v="145"' +sec_ch_ua_full_version_list = '"Chromium";v="145.0.0.0", "Not:A-Brand";v="99.0.0.0", "Google Chrome";v="145.0.0.0"' +default_timeout = 30 +print_lock = threading.Lock() +stats_lock = threading.Lock() +stats = {"done": 0, "success": 0, "fail": 0, "start_time": 0.0} +register_log_sink = None + +common_headers = { + "accept": "application/json", + "accept-language": "en-US,en;q=0.9", + "content-type": "application/json", + "origin": auth_base, + "priority": "u=1, i", + "user-agent": user_agent, + "sec-ch-ua": sec_ch_ua, + "sec-ch-ua-arch": '"x86_64"', + "sec-ch-ua-bitness": '"64"', + "sec-ch-ua-full-version-list": sec_ch_ua_full_version_list, + "sec-ch-ua-mobile": "?0", + "sec-ch-ua-model": '""', + "sec-ch-ua-platform": '"Windows"', + "sec-ch-ua-platform-version": '"10.0.0"', + "sec-fetch-dest": "empty", + "sec-fetch-mode": "cors", + "sec-fetch-site": "same-origin", +} + +navigate_headers = { + "accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8", + "accept-language": "en-US,en;q=0.9", + "user-agent": user_agent, + "sec-ch-ua": sec_ch_ua, + "sec-ch-ua-arch": '"x86_64"', + "sec-ch-ua-bitness": '"64"', + "sec-ch-ua-full-version-list": sec_ch_ua_full_version_list, + "sec-ch-ua-mobile": "?0", + "sec-ch-ua-model": '""', + "sec-ch-ua-platform": '"Windows"', + "sec-ch-ua-platform-version": '"10.0.0"', + "sec-fetch-dest": "document", + "sec-fetch-mode": "navigate", + "sec-fetch-site": "same-origin", + "sec-fetch-user": "?1", + "upgrade-insecure-requests": "1", +} + + +def log(text: str, color: str = "") -> None: + colors = {"red": "\033[31m", "green": "\033[32m", "yellow": "\033[33m"} + if register_log_sink: + try: + register_log_sink(text, color) + except Exception: + pass + with print_lock: + prefix = colors.get(color, "") + suffix = "\033[0m" if prefix else "" + print(f"{prefix}{datetime.now().strftime('%H:%M:%S')} {text}{suffix}") + + +def step(index: int, text: str, color: str = "") -> None: + log(f"[任务{index}] {text}", color) + + +def _make_trace_headers() -> dict[str, str]: + trace_id = str(random.getrandbits(64)) + parent_id = str(random.getrandbits(64)) + return { + "traceparent": f"00-{uuid.uuid4().hex}-{format(int(parent_id), '016x')}-01", + "tracestate": "dd=s:1;o:rum", + "x-datadog-origin": "rum", + "x-datadog-parent-id": parent_id, + "x-datadog-sampling-priority": "1", + "x-datadog-trace-id": trace_id, + } + + +def _generate_pkce() -> tuple[str, str]: + code_verifier = base64.urlsafe_b64encode(secrets.token_bytes(64)).rstrip(b"=").decode("ascii") + code_challenge = base64.urlsafe_b64encode(hashlib.sha256(code_verifier.encode("ascii")).digest()).rstrip(b"=").decode("ascii") + return code_verifier, code_challenge + + +def _random_password(length: int = 16) -> str: + chars = string.ascii_letters + string.digits + "!@#$%" + value = list( + secrets.choice(string.ascii_uppercase) + + secrets.choice(string.ascii_lowercase) + + secrets.choice(string.digits) + + secrets.choice("!@#$%") + + "".join(secrets.choice(chars) for _ in range(max(0, length - 4))) + ) + random.shuffle(value) + return "".join(value) + + +def _random_name() -> tuple[str, str]: + return random.choice(["James", "Robert", "John", "Michael", "David", "Mary", "Emma", "Olivia"]), random.choice( + ["Smith", "Johnson", "Williams", "Brown", "Jones", "Garcia", "Miller"] + ) + + +def _random_birthdate() -> str: + return f"{random.randint(1996, 2006):04d}-{random.randint(1, 12):02d}-{random.randint(1, 28):02d}" + + +def _response_json(resp) -> dict: + try: + data = resp.json() + return data if isinstance(data, dict) else {} + except Exception: + return {} + + +def _response_debug_detail(resp, limit: int = 800) -> str: + if resp is None: + return "" + data = _response_json(resp) + parts = [ + f"url={str(getattr(resp, 'url', '') or '')[:300]}", + f"content_type={str(getattr(resp, 'headers', {}).get('content-type') or '')}", + ] + for key in ("cf-ray", "x-request-id", "openai-processing-ms"): + value = str(getattr(resp, "headers", {}).get(key) or "").strip() + if value: + parts.append(f"{key}={value}") + if data: + parts.append(f"json={json.dumps(data, ensure_ascii=False)[:limit]}") + else: + parts.append(f"body={str(getattr(resp, 'text', '') or '')[:limit]}") + return ", ".join(parts) + + +def _is_cloudflare_challenge(resp) -> bool: + if resp is None: + return False + text = str(getattr(resp, "text", "") or "").lower() + headers = getattr(resp, "headers", {}) or {} + server = str(headers.get("server") or "").lower() + return ( + "cloudflare" in server + or "challenges.cloudflare.com" in text + or "just a moment" in text + ) + + +def create_mailbox(username: str | None = None) -> dict: + return mail_provider.create_mailbox(config["mail"], username) + + +def wait_for_code(mailbox: dict) -> str | None: + return mail_provider.wait_for_code(config["mail"], mailbox) + + +class SentinelTokenGenerator: + MAX_ATTEMPTS = 500000 + ERROR_PREFIX = "wQ8Lk5FbGpA2NcR9dShT6gYjU7VxZ4D" + + def __init__(self, device_id: str, ua: str): + self.device_id = device_id + self.user_agent = ua + self.sid = str(uuid.uuid4()) + + @staticmethod + def _fnv1a_32(text: str) -> str: + h = 2166136261 + for ch in text: + h ^= ord(ch) + h = (h * 16777619) & 0xFFFFFFFF + h ^= h >> 16 + h = (h * 2246822507) & 0xFFFFFFFF + h ^= h >> 13 + h = (h * 3266489909) & 0xFFFFFFFF + h ^= h >> 16 + return format(h & 0xFFFFFFFF, "08x") + + def _get_config(self) -> list: + perf_now = random.uniform(1000, 50000) + return [ + "1920x1080", + time.strftime("%a %b %d %Y %H:%M:%S GMT+0000 (Coordinated Universal Time)", time.gmtime()), + 4294705152, + random.random(), + self.user_agent, + "https://sentinel.openai.com/sentinel/20260124ceb8/sdk.js", + None, + None, + "en-US", + random.random(), + random.choice(["vendorSub-undefined", "plugins-undefined", "mimeTypes-undefined", "hardwareConcurrency-undefined"]), + random.choice(["location", "implementation", "URL", "documentURI", "compatMode"]), + random.choice(["Object", "Function", "Array", "Number", "parseFloat", "undefined"]), + perf_now, + self.sid, + "", + random.choice([4, 8, 12, 16]), + time.time() * 1000 - perf_now, + ] + + @staticmethod + def _b64(data) -> str: + return base64.b64encode(json.dumps(data, separators=(",", ":"), ensure_ascii=False).encode("utf-8")).decode("ascii") + + def generate_requirements_token(self) -> str: + data = self._get_config() + data[3] = 1 + data[9] = round(random.uniform(5, 50)) + return "gAAAAAC" + self._b64(data) + + def generate_token(self, seed: str, difficulty: str) -> str: + start = time.time() + data = self._get_config() + difficulty = str(difficulty or "0") + for i in range(self.MAX_ATTEMPTS): + data[3] = i + data[9] = round((time.time() - start) * 1000) + payload = self._b64(data) + if self._fnv1a_32(seed + payload)[: len(difficulty)] <= difficulty: + return "gAAAAAB" + payload + "~S" + return "gAAAAAB" + self.ERROR_PREFIX + self._b64(str(None)) + + +def build_sentinel_token(session: requests.Session, device_id: str, flow: str) -> str: + generator = SentinelTokenGenerator(device_id, user_agent) + resp = session.post( + "https://sentinel.openai.com/backend-api/sentinel/req", + data=json.dumps({"p": generator.generate_requirements_token(), "id": device_id, "flow": flow}), + headers={ + "Content-Type": "text/plain;charset=UTF-8", + "Referer": "https://sentinel.openai.com/backend-api/sentinel/frame.html", + "Origin": "https://sentinel.openai.com", + "User-Agent": user_agent, + "sec-ch-ua": sec_ch_ua, + "sec-ch-ua-mobile": "?0", + "sec-ch-ua-platform": '"Windows"', + }, + timeout=20, + verify=False, + ) + data = _response_json(resp) + token = str(data.get("token") or "").strip() + if resp.status_code != 200 or not token: + raise RuntimeError(f"sentinel_req_failed_{resp.status_code}") + pow_data = data.get("proofofwork") or {} + p_value = ( + generator.generate_token(str(pow_data.get("seed") or ""), str(pow_data.get("difficulty") or "0")) + if pow_data.get("required") and pow_data.get("seed") + else generator.generate_requirements_token() + ) + return json.dumps({"p": p_value, "t": "", "c": token, "id": device_id, "flow": flow}, separators=(",", ":")) + + +def create_session(proxy: str = "") -> Any: + kwargs = {"impersonate": "chrome", "verify": False} + if proxy: + kwargs["proxy"] = proxy + return requests.Session(**kwargs) + + +def request_with_local_retry(session: requests.Session, method: str, url: str, retry_attempts: int = 3, **kwargs): + last_error = "" + for _ in range(max(1, retry_attempts)): + try: + return session.request(method.upper(), url, timeout=default_timeout, **kwargs), "" + except Exception as error: + last_error = str(error) + time.sleep(1) + return None, last_error + + +def validate_otp(session: requests.Session, device_id: str, code: str): + headers = dict(common_headers) + headers["referer"] = f"{auth_base}/email-verification" + headers["oai-device-id"] = device_id + headers.update(_make_trace_headers()) + resp, error = request_with_local_retry(session, "post", f"{auth_base}/api/accounts/email-otp/validate", json={"code": code}, headers=headers, verify=False) + if resp is not None and resp.status_code == 200: + return resp, "" + headers["openai-sentinel-token"] = build_sentinel_token(session, device_id, "authorize_continue") + resp, error = request_with_local_retry(session, "post", f"{auth_base}/api/accounts/email-otp/validate", json={"code": code}, headers=headers, verify=False) + return resp, error + + +def extract_oauth_callback_params_from_url(url: str) -> dict[str, str] | None: + if not url: + return None + try: + params = parse_qs(urlparse(url).query) + except Exception: + return None + code = str((params.get("code") or [""])[0]).strip() + if not code: + return None + return {"code": code, "state": str((params.get("state") or [""])[0]).strip(), "scope": str((params.get("scope") or [""])[0]).strip()} + + +def request_platform_oauth_token(session: requests.Session, code: str, code_verifier: str) -> dict | None: + headers = { + "accept": "*/*", + "accept-language": "zh-CN,zh;q=0.9", + "auth0-client": platform_auth0_client, + "cache-control": "no-cache", + "content-type": "application/json", + "origin": platform_base, + "pragma": "no-cache", + "priority": "u=1, i", + "referer": f"{platform_base}/", + "sec-ch-ua": sec_ch_ua, + "sec-ch-ua-mobile": "?0", + "sec-ch-ua-platform": '"Windows"', + "sec-fetch-dest": "empty", + "sec-fetch-mode": "cors", + "sec-fetch-site": "same-site", + "user-agent": user_agent, + } + resp = session.post( + f"{auth_base}/api/accounts/oauth/token", + headers=headers, + json={ + "client_id": platform_oauth_client_id, + "code_verifier": code_verifier, + "grant_type": "authorization_code", + "code": code, + "redirect_uri": platform_oauth_redirect_uri, + }, + verify=False, + timeout=60, + ) + if resp.status_code != 200: + print(resp.text) + return None + return _response_json(resp) + + +class PlatformRegistrar: + def __init__(self, proxy: str = "") -> None: + self.session = create_session(proxy) + self.device_id = str(uuid.uuid4()) + self.code_verifier = "" + self.platform_auth_code = "" + + def close(self) -> None: + self.session.close() + + def _navigate_headers(self, referer: str = "") -> dict[str, str]: + headers = dict(navigate_headers) + if referer: + headers["referer"] = referer + return headers + + def _json_headers(self, referer: str) -> dict[str, str]: + headers = dict(common_headers) + headers["referer"] = referer + headers["oai-device-id"] = self.device_id + headers.update(_make_trace_headers()) + return headers + + def _platform_authorize(self, email: str, index: int) -> None: + step(index, "开始 platform authorize") + self.session.cookies.set("oai-did", self.device_id, domain=".auth.openai.com") + self.session.cookies.set("oai-did", self.device_id, domain="auth.openai.com") + self.code_verifier, code_challenge = _generate_pkce() + params = { + "issuer": auth_base, + "client_id": platform_oauth_client_id, + "audience": platform_oauth_audience, + "redirect_uri": platform_oauth_redirect_uri, + "device_id": self.device_id, + "screen_hint": "login_or_signup", + "max_age": "0", + "login_hint": email, + "scope": "openid profile email offline_access", + "response_type": "code", + "response_mode": "query", + "state": secrets.token_urlsafe(32), + "nonce": secrets.token_urlsafe(32), + "code_challenge": code_challenge, + "code_challenge_method": "S256", + "auth0Client": platform_auth0_client, + } + resp, error = request_with_local_retry(self.session, "get", f"{auth_base}/api/accounts/authorize?{urlencode(params)}", headers=self._navigate_headers(f"{platform_base}/"), allow_redirects=True, verify=False) + if resp is None or resp.status_code != 200: + err = _response_json(resp).get("error", {}) if resp is not None else {} + detail = f": {err.get('code', '')} - {err.get('message', '')}".strip(" -") if err else "" + if _is_cloudflare_challenge(resp): + raise RuntimeError("被 Cloudflare 拦截,请更换 IP 重试") + debug = _response_debug_detail(resp) + status = getattr(resp, "status_code", "unknown") + raise RuntimeError(error or f"platform_authorize_http_{status}{detail}, {debug}") + step(index, "platform authorize 完成") + + def _register_user(self, email: str, password: str, index: int) -> None: + step(index, "开始提交注册密码") + headers = self._json_headers(f"{auth_base}/create-account/password") + headers["openai-sentinel-token"] = build_sentinel_token(self.session, self.device_id, "username_password_create") + resp, error = request_with_local_retry(self.session, "post", f"{auth_base}/api/accounts/user/register", json={"username": email, "password": password}, headers=headers, verify=False) + if resp is None or resp.status_code != 200: + data = _response_json(resp) if resp is not None else {} + if data.get("message") == "Failed to create account. Please try again.": + step(index, "注册失败提示: 邮箱域名很可能因滥用被封禁,请更换邮箱域名", "yellow") + detail = f", detail={json.dumps(data, ensure_ascii=False)}" if data else "" + raise RuntimeError(error or f"user_register_http_{getattr(resp, 'status_code', 'unknown')}{detail}") + step(index, "提交注册密码完成") + + def _send_otp(self, index: int) -> None: + step(index, "开始发送验证码") + resp, error = request_with_local_retry(self.session, "get", f"{auth_base}/api/accounts/email-otp/send", headers=self._navigate_headers(f"{auth_base}/create-account/password"), allow_redirects=True, verify=False) + if resp is None or resp.status_code not in (200, 302): + raise RuntimeError(error or f"send_otp_http_{getattr(resp, 'status_code', 'unknown')}") + step(index, "发送验证码完成") + + def _validate_otp(self, code: str, index: int) -> None: + step(index, f"开始校验验证码 {code}") + resp, error = validate_otp(self.session, self.device_id, code) + if resp is None or resp.status_code != 200: + body = "" + try: + body = (resp.text or "")[:500] if resp is not None else "" + except Exception: + pass + raise RuntimeError(error or f"validate_otp_http_{getattr(resp, 'status_code', 'unknown')}_body={body}") + step(index, "验证码校验完成") + + def _create_account(self, name: str, birthdate: str, index: int) -> None: + step(index, "开始创建账号资料") + headers = self._json_headers(f"{auth_base}/about-you") + headers["openai-sentinel-token"] = build_sentinel_token(self.session, self.device_id, "oauth_create_account") + resp, error = request_with_local_retry(self.session, "post", f"{auth_base}/api/accounts/create_account", json={"name": name, "birthdate": birthdate}, headers=headers, verify=False) + if resp is None or resp.status_code not in (200, 302): + data = _response_json(resp) if resp is not None else {} + if data.get("message") == "Failed to create account. Please try again.": + step(index, "创建账号失败提示: 邮箱域名很可能因滥用被封禁,请更换邮箱域名", "yellow") + detail = f", detail={json.dumps(data, ensure_ascii=False)}" if data else "" + raise RuntimeError(error or f"create_account_http_{getattr(resp, 'status_code', 'unknown')}{detail}") + data = _response_json(resp) + callback_params = extract_oauth_callback_params_from_url(str(data.get("continue_url") or "").strip()) + self.platform_auth_code = str((callback_params or {}).get("code") or "").strip() + step(index, "创建账号资料完成") + + def _exchange_registered_tokens(self, index: int) -> dict: + step(index, "开始换 token") + tokens = request_platform_oauth_token(self.session, self.platform_auth_code, self.code_verifier) + if not tokens: + raise RuntimeError("token换取失败") + step(index, "token 换取完成") + return tokens + + def register(self, index: int) -> dict: + step(index, "开始创建邮箱") + mailbox = create_mailbox() + email = str(mailbox.get("address") or "").strip() + if not email: + raise RuntimeError("邮箱服务未返回 address") + label = str(mailbox.get("label") or "") + step(index, f"邮箱创建完成[{label}]: {email}") + password = _random_password() + first_name, last_name = _random_name() + self._platform_authorize(email, index) + self._register_user(email, password, index) + self._send_otp(index) + step(index, "开始等待注册验证码") + code = wait_for_code(mailbox) + if not code: + raise RuntimeError("等待注册验证码超时") + step(index, f"收到注册验证码: {code}") + self._validate_otp(code, index) + self._create_account(f"{first_name} {last_name}", _random_birthdate(), index) + tokens = self._exchange_registered_tokens(index) + return { + "email": email, + "password": password, + "access_token": str(tokens.get("access_token") or "").strip(), + "refresh_token": str(tokens.get("refresh_token") or "").strip(), + "id_token": str(tokens.get("id_token") or "").strip(), + "source_type": "web", + "created_at": datetime.now(timezone.utc).isoformat(), + } + + +def worker(index: int) -> dict: + start = time.time() + registrar = PlatformRegistrar(config["proxy"]) + try: + step(index, "任务启动") + result = registrar.register(index) + cost = time.time() - start + access_token = str(result["access_token"]) + account_service.add_account_items([result]) + refresh_result = account_service.refresh_accounts([access_token]) + if refresh_result.get("errors"): + step(index, f"账号已保存,刷新状态暂未成功,稍后可重试: {refresh_result['errors']}", "yellow") + with stats_lock: + stats["done"] += 1 + stats["success"] += 1 + avg = (time.time() - stats["start_time"]) / stats["success"] + log(f'{result["email"]} 注册成功,本次耗时{cost:.1f}s,全局平均每个号注册耗时{avg:.1f}s', "green") + return {"ok": True, "index": index, "result": result} + except Exception as e: + cost = time.time() - start + with stats_lock: + stats["done"] += 1 + stats["fail"] += 1 + log(f"任务{index} 注册失败,本次耗时{cost:.1f}s,原因: {e}", "red") + return {"ok": False, "index": index, "error": str(e)} + finally: + registrar.close() diff --git a/services/register_service.py b/services/register_service.py new file mode 100644 index 0000000000000000000000000000000000000000..a29ccb73d5739ed35af05266987ab73b83e73ea5 --- /dev/null +++ b/services/register_service.py @@ -0,0 +1,197 @@ +from __future__ import annotations + +import json +import threading +import time +import uuid +from concurrent.futures import FIRST_COMPLETED, ThreadPoolExecutor, wait +from datetime import datetime, timezone +from pathlib import Path + +from services.account_service import account_service +from services.config import DATA_DIR +from services.register import openai_register + + +REGISTER_FILE = DATA_DIR / "register.json" + + +def _now() -> str: + return datetime.now(timezone.utc).isoformat() + + +def _default_config() -> dict: + return {**openai_register.config, "mode": "total", "target_quota": 100, "target_available": 10, "check_interval": 5, "enabled": False, "stats": {"success": 0, "fail": 0, "done": 0, "running": 0, "threads": openai_register.config["threads"], "elapsed_seconds": 0, "avg_seconds": 0, "success_rate": 0, "current_quota": 0, "current_available": 0}} + + +def _normalize(raw: dict) -> dict: + cfg = _default_config() + cfg.update({k: v for k, v in raw.items() if k not in {"stats", "logs"}}) + cfg["total"] = max(1, int(cfg.get("total") or 1)) + cfg["threads"] = max(1, int(cfg.get("threads") or 1)) + cfg["mode"] = str(cfg.get("mode") or "total").strip() if str(cfg.get("mode") or "total").strip() in {"total", "quota", "available"} else "total" + cfg["target_quota"] = max(1, int(cfg.get("target_quota") or 1)) + cfg["target_available"] = max(1, int(cfg.get("target_available") or 1)) + cfg["check_interval"] = max(1, int(cfg.get("check_interval") or 5)) + cfg["proxy"] = str(cfg.get("proxy") or "").strip() + cfg["enabled"] = bool(cfg.get("enabled")) + stats = {**_default_config()["stats"], **(raw.get("stats") if isinstance(raw.get("stats"), dict) else {}), + "threads": cfg["threads"]} + cfg["stats"] = stats + return cfg + + +class RegisterService: + def __init__(self, store_file: Path): + self._store_file = store_file + self._lock = threading.RLock() + self._runner: threading.Thread | None = None + self._logs: list[dict] = [] + openai_register.register_log_sink = self._append_log + self._config = self._load() + if self._config["enabled"]: + self.start() + + def _load(self) -> dict: + try: + return _normalize(json.loads(self._store_file.read_text(encoding="utf-8"))) + except Exception: + return _normalize({}) + + def _save(self) -> None: + self._store_file.parent.mkdir(parents=True, exist_ok=True) + self._store_file.write_text(json.dumps(self._config, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") + + def get(self) -> dict: + with self._lock: + return json.loads(json.dumps({**self._config, "logs": self._logs[-300:]}, ensure_ascii=False)) + + def _inject_proxy_to_mail(self) -> None: + proxy = str(self._config.get("proxy") or "").strip() + if proxy and isinstance(self._config.get("mail"), dict): + self._config["mail"]["proxy"] = proxy + + def update(self, updates: dict) -> dict: + with self._lock: + self._config = _normalize({**self._config, **updates}) + self._inject_proxy_to_mail() + openai_register.config.update({k: self._config[k] for k in ("mail", "proxy", "total", "threads")}) + self._save() + return self.get() + + def start(self) -> dict: + with self._lock: + if self._runner and self._runner.is_alive(): + self._config["enabled"] = True + self._save() + return self.get() + self._config["enabled"] = True + self._inject_proxy_to_mail() + self._logs = [] + metrics = self._pool_metrics() + self._config["stats"] = {"job_id": uuid.uuid4().hex, "success": 0, "fail": 0, "done": 0, "running": 0, "threads": self._config["threads"], **metrics, "started_at": _now(), "updated_at": _now()} + openai_register.config.update({k: self._config[k] for k in ("mail", "proxy", "total", "threads")}) + with openai_register.stats_lock: + openai_register.stats.update({"done": 0, "success": 0, "fail": 0, "start_time": time.time()}) + self._save() + self._runner = threading.Thread(target=self._run, daemon=True, name="openai-register") + self._runner.start() + self._append_log(f"注册任务启动,模式={self._config['mode']},线程数={self._config['threads']}", "yellow") + return self.get() + + def stop(self) -> dict: + with self._lock: + self._config["enabled"] = False + self._config["stats"]["updated_at"] = _now() + self._save() + self._append_log("已请求停止注册任务,正在等待当前运行任务结束", "yellow") + return self.get() + + def reset(self) -> dict: + with self._lock: + self._logs = [] + self._config["stats"] = {"success": 0, "fail": 0, "done": 0, "running": 0, "threads": self._config["threads"], "elapsed_seconds": 0, "avg_seconds": 0, "success_rate": 0, **self._pool_metrics(), "updated_at": _now()} + with openai_register.stats_lock: + openai_register.stats.update({"done": 0, "success": 0, "fail": 0, "start_time": 0.0}) + self._save() + return self.get() + + def _append_log(self, text: str, color: str = "") -> None: + with self._lock: + self._logs.append({"time": _now(), "text": str(text), "level": str(color or "info")}) + self._logs = self._logs[-300:] + + def _pool_metrics(self) -> dict: + items = account_service.list_accounts() + normal = [item for item in items if item.get("status") == "正常"] + return { + "current_quota": sum(int(item.get("quota") or 0) for item in normal if not item.get("image_quota_unknown")), + "current_available": len(normal), + } + + def _target_reached(self, cfg: dict, submitted: int) -> bool: + mode = str(cfg.get("mode") or "total") + metrics = self._pool_metrics() + self._bump(**metrics) + if mode == "quota": + reached = metrics["current_quota"] >= int(cfg.get("target_quota") or 1) + self._append_log(f"检查号池:当前正常账号={metrics['current_available']},当前剩余额度={metrics['current_quota']},目标额度={cfg.get('target_quota')},{'跳过注册' if reached else '继续注册'}", "yellow") + return reached + if mode == "available": + reached = metrics["current_available"] >= int(cfg.get("target_available") or 1) + self._append_log(f"检查号池:当前正常账号={metrics['current_available']},目标账号={cfg.get('target_available')},当前剩余额度={metrics['current_quota']},{'跳过注册' if reached else '继续注册'}", "yellow") + return reached + return submitted >= int(cfg.get("total") or 1) + + def _bump(self, **updates) -> None: + with self._lock: + self._config["stats"].update(updates) + stats = self._config["stats"] + started_at = str(stats.get("started_at") or "") + if started_at: + try: + elapsed = max(0.0, (datetime.now(timezone.utc) - datetime.fromisoformat(started_at)).total_seconds()) + except Exception: + elapsed = 0.0 + done = int(stats.get("done") or 0) + success = int(stats.get("success") or 0) + fail = int(stats.get("fail") or 0) + stats["elapsed_seconds"] = round(elapsed, 1) + stats["avg_seconds"] = round(elapsed / success, 1) if success else 0 + stats["success_rate"] = round(success * 100 / max(1, success + fail), 1) + self._config["stats"]["updated_at"] = _now() + self._save() + + def _run(self) -> None: + threads = int(self.get()["threads"]) + submitted, done, success, fail = 0, 0, 0, 0 + with ThreadPoolExecutor(max_workers=threads) as executor: + futures = set() + while True: + cfg = self.get() + while self.get()["enabled"] and not self._target_reached(cfg, submitted) and len(futures) < threads: + submitted += 1 + futures.add(executor.submit(openai_register.worker, submitted)) + self._bump(running=len(futures), done=done, success=success, fail=fail) + if not futures and (not self.get()["enabled"] or str(cfg.get("mode") or "total") == "total"): + break + if not futures: + time.sleep(max(1, int(cfg.get("check_interval") or 5))) + continue + finished, futures = wait(futures, return_when=FIRST_COMPLETED) + for future in finished: + done += 1 + try: + result = future.result() + success += 1 if result.get("ok") else 0 + fail += 0 if result.get("ok") else 1 + except Exception: + fail += 1 + self._bump(running=0, done=done, success=success, fail=fail, finished_at=_now()) + with self._lock: + self._config["enabled"] = False + self._save() + self._append_log(f"注册任务结束,成功{success},失败{fail}", "yellow") + + +register_service = RegisterService(REGISTER_FILE) diff --git a/services/storage/__init__.py b/services/storage/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..0f32c64a60a2066fc162d7c0dbfb4f7d82e972d6 --- /dev/null +++ b/services/storage/__init__.py @@ -0,0 +1,5 @@ +from __future__ import annotations + +from services.storage.factory import create_storage_backend + +__all__ = ["create_storage_backend"] diff --git a/services/storage/base.py b/services/storage/base.py new file mode 100644 index 0000000000000000000000000000000000000000..14097b88c1224971d50f3d41fde4259636cbed8d --- /dev/null +++ b/services/storage/base.py @@ -0,0 +1,38 @@ +from __future__ import annotations + +from abc import ABC, abstractmethod +from typing import Any + + +class StorageBackend(ABC): + """抽象存储后端基类""" + + @abstractmethod + def load_accounts(self) -> list[dict[str, Any]]: + """加载所有账号数据""" + pass + + @abstractmethod + def save_accounts(self, accounts: list[dict[str, Any]]) -> None: + """保存所有账号数据""" + pass + + @abstractmethod + def load_auth_keys(self) -> list[dict[str, Any]]: + """加载所有鉴权密钥数据""" + pass + + @abstractmethod + def save_auth_keys(self, auth_keys: list[dict[str, Any]]) -> None: + """保存所有鉴权密钥数据""" + pass + + @abstractmethod + def health_check(self) -> dict[str, Any]: + """健康检查,返回存储后端状态""" + pass + + @abstractmethod + def get_backend_info(self) -> dict[str, Any]: + """获取存储后端信息""" + pass diff --git a/services/storage/database_storage.py b/services/storage/database_storage.py new file mode 100644 index 0000000000000000000000000000000000000000..91d8d8d1bc76c25891418d05e4dc8db8341fcdf5 --- /dev/null +++ b/services/storage/database_storage.py @@ -0,0 +1,174 @@ +from __future__ import annotations + +import json +from typing import Any + +from sqlalchemy import Column, String, Text, create_engine, Integer, text +from sqlalchemy.ext.declarative import declarative_base +from sqlalchemy.orm import sessionmaker + +from services.storage.base import StorageBackend + +Base = declarative_base() + + +class AccountModel(Base): + """账号数据模型""" + __tablename__ = "accounts" + + id = Column(Integer, primary_key=True, autoincrement=True) + access_token = Column(String(2048), unique=True, nullable=False, index=True) + data = Column(Text, nullable=False) # JSON 格式存储完整账号数据 + + +class AuthKeyModel(Base): + """鉴权密钥数据模型""" + __tablename__ = "auth_keys" + + id = Column(Integer, primary_key=True, autoincrement=True) + key_id = Column(String(255), unique=True, nullable=False, index=True) + data = Column(Text, nullable=False) + + +class DatabaseStorageBackend(StorageBackend): + """数据库存储后端(支持 SQLite、PostgreSQL、MySQL 等)""" + + def __init__(self, database_url: str): + self.database_url = database_url + self.engine = create_engine( + database_url, + pool_pre_ping=True, # 自动检测连接是否有效 + pool_recycle=3600, # 1小时回收连接 + ) + Base.metadata.create_all(self.engine) + self.Session = sessionmaker(bind=self.engine) + + def load_accounts(self) -> list[dict[str, Any]]: + """从数据库加载账号数据""" + session = self.Session() + try: + accounts = [] + for row in session.query(AccountModel).all(): + try: + account_data = json.loads(row.data) + if isinstance(account_data, dict): + accounts.append(account_data) + except json.JSONDecodeError: + continue + return accounts + finally: + session.close() + + def save_accounts(self, accounts: list[dict[str, Any]]) -> None: + """保存账号数据到数据库""" + self._save_rows(AccountModel, accounts, "access_token") + + def load_auth_keys(self) -> list[dict[str, Any]]: + """从数据库加载鉴权密钥数据""" + return self._load_rows(AuthKeyModel) + + def save_auth_keys(self, auth_keys: list[dict[str, Any]]) -> None: + """保存鉴权密钥数据到数据库""" + self._save_rows(AuthKeyModel, auth_keys, "id", "key_id") + + def _load_rows(self, model: type[AccountModel] | type[AuthKeyModel]) -> list[dict[str, Any]]: + session = self.Session() + try: + items = [] + for row in session.query(model).all(): + try: + item_data = json.loads(row.data) + if isinstance(item_data, dict): + items.append(item_data) + except json.JSONDecodeError: + continue + return items + finally: + session.close() + + def _save_rows( + self, + model: type[AccountModel] | type[AuthKeyModel], + items: list[dict[str, Any]], + source_key: str, + target_key: str | None = None, + ) -> None: + session = self.Session() + try: + session.query(model).delete() + for item in items: + if not isinstance(item, dict): + continue + key_value = str(item.get(source_key) or "").strip() + if not key_value: + continue + session.add( + model( + **{target_key or source_key: key_value}, + data=json.dumps(item, ensure_ascii=False), + ) + ) + session.commit() + except Exception as e: + session.rollback() + raise e + finally: + session.close() + + def health_check(self) -> dict[str, Any]: + """健康检查""" + try: + session = self.Session() + try: + # 尝试执行简单查询 + session.execute(text("SELECT 1")) + count = session.query(AccountModel).count() + auth_key_count = session.query(AuthKeyModel).count() + return { + "status": "healthy", + "backend": "database", + "database_url": self._mask_password(self.database_url), + "account_count": count, + "auth_key_count": auth_key_count, + } + finally: + session.close() + except Exception as e: + return { + "status": "unhealthy", + "backend": "database", + "error": str(e), + } + + def get_backend_info(self) -> dict[str, Any]: + """获取存储后端信息""" + db_type = "unknown" + if "sqlite" in self.database_url: + db_type = "sqlite" + elif "postgresql" in self.database_url or "postgres" in self.database_url: + db_type = "postgresql" + elif "mysql" in self.database_url: + db_type = "mysql" + + return { + "type": "database", + "db_type": db_type, + "description": f"数据库存储 ({db_type})", + "database_url": self._mask_password(self.database_url), + } + + @staticmethod + def _mask_password(url: str) -> str: + """隐藏数据库连接字符串中的密码""" + if "://" not in url: + return url + try: + protocol, rest = url.split("://", 1) + if "@" in rest: + credentials, host = rest.split("@", 1) + if ":" in credentials: + username, _ = credentials.split(":", 1) + return f"{protocol}://{username}:****@{host}" + return url + except Exception: + return url diff --git a/services/storage/factory.py b/services/storage/factory.py new file mode 100644 index 0000000000000000000000000000000000000000..585a4f207556776e9477a1ef46afca1392b887d9 --- /dev/null +++ b/services/storage/factory.py @@ -0,0 +1,104 @@ +from __future__ import annotations + +import os +from pathlib import Path + +from services.storage.base import StorageBackend +from services.storage.database_storage import DatabaseStorageBackend +from services.storage.git_storage import GitStorageBackend +from services.storage.json_storage import JSONStorageBackend + + +def create_storage_backend(data_dir: Path) -> StorageBackend: + """ + 根据环境变量创建存储后端 + + 环境变量: + - STORAGE_BACKEND: json|sqlite|postgres|git (默认 json) + - DATABASE_URL: 数据库连接字符串 (用于 sqlite/postgres) + - GIT_REPO_URL: Git 仓库地址 (用于 git) + - GIT_TOKEN: Git 访问令牌 (用于 git) + - GIT_BRANCH: Git 分支 (默认 main) + - GIT_FILE_PATH: Git 仓库中的文件路径 (默认 accounts.json) + """ + backend_type = os.getenv("STORAGE_BACKEND", "json").lower().strip() + + print(f"[storage] Initializing storage backend: {backend_type}") + + if backend_type == "json": + # 本地 JSON 文件存储 + file_path = data_dir / "accounts.json" + auth_keys_path = data_dir / "auth_keys.json" + print(f"[storage] Using JSON storage: {file_path}") + return JSONStorageBackend(file_path, auth_keys_path) + + elif backend_type in ("sqlite", "postgres", "postgresql", "mysql", "database"): + # 数据库存储 + database_url = os.getenv("DATABASE_URL", "").strip() + + if not database_url: + # 如果没有指定 DATABASE_URL,使用本地 SQLite + database_url = f"sqlite:///{data_dir / 'accounts.db'}" + print(f"[storage] No DATABASE_URL provided, using local SQLite: {database_url}") + else: + print(f"[storage] Using database storage: {_mask_password(database_url)}") + + return DatabaseStorageBackend(database_url) + + elif backend_type == "git": + # Git 仓库存储 + repo_url = os.getenv("GIT_REPO_URL", "").strip() + token = os.getenv("GIT_TOKEN", "").strip() + branch = os.getenv("GIT_BRANCH", "main").strip() + file_path = os.getenv("GIT_FILE_PATH", "accounts.json").strip() + auth_keys_file_path = os.getenv("GIT_AUTH_KEYS_FILE_PATH", "auth_keys.json").strip() + + if not repo_url: + raise ValueError( + "GIT_REPO_URL is required when using git storage backend. " + "Please set GIT_REPO_URL environment variable." + ) + + print(f"[storage] Using Git storage: {_mask_token(repo_url)}, branch: {branch}, file: {file_path}") + + cache_dir = data_dir / "git_cache" + return GitStorageBackend( + repo_url=repo_url, + token=token, + branch=branch, + file_path=file_path, + auth_keys_file_path=auth_keys_file_path, + local_cache_dir=cache_dir, + ) + + else: + raise ValueError( + f"Unknown storage backend: {backend_type}. " + f"Supported backends: json, sqlite, postgres, git" + ) + + +def _mask_password(url: str) -> str: + """隐藏数据库连接字符串中的密码""" + if "://" not in url: + return url + try: + protocol, rest = url.split("://", 1) + if "@" in rest: + credentials, host = rest.split("@", 1) + if ":" in credentials: + username, _ = credentials.split(":", 1) + return f"{protocol}://{username}:****@{host}" + return url + except Exception: + return url + + +def _mask_token(url: str) -> str: + """隐藏 URL 中的 token""" + if "@" in url and "://" in url: + protocol, rest = url.split("://", 1) + if "@" in rest: + _, host = rest.split("@", 1) + return f"{protocol}://****@{host}" + return url diff --git a/services/storage/git_storage.py b/services/storage/git_storage.py new file mode 100644 index 0000000000000000000000000000000000000000..33b062751970784c4516d0e291421db74abcdb53 --- /dev/null +++ b/services/storage/git_storage.py @@ -0,0 +1,183 @@ +from __future__ import annotations + +import json +import shutil +import tempfile +from pathlib import Path +from typing import Any + +from git import Repo +from git.exc import GitCommandError + +from services.storage.base import StorageBackend + + +class GitStorageBackend(StorageBackend): + """Git 私有仓库存储后端""" + + def __init__( + self, + repo_url: str, + token: str, + branch: str = "main", + file_path: str = "accounts.json", + auth_keys_file_path: str = "auth_keys.json", + local_cache_dir: Path | None = None, + ): + self.repo_url = repo_url + self.token = token + self.branch = branch + self.file_path = file_path + self.auth_keys_file_path = auth_keys_file_path + + # 本地缓存目录 + if local_cache_dir is None: + local_cache_dir = Path(tempfile.gettempdir()) / "chatgpt2api_git_cache" + self.local_cache_dir = local_cache_dir + self.local_cache_dir.mkdir(parents=True, exist_ok=True) + + # 构建带认证的 Git URL + self.auth_repo_url = self._build_auth_url(repo_url, token) + + @staticmethod + def _build_auth_url(repo_url: str, token: str) -> str: + """构建带认证的 Git URL""" + if not token: + return repo_url + + # 支持 HTTPS 格式:https://github.com/user/repo.git + if repo_url.startswith("https://"): + # 插入 token + return repo_url.replace("https://", f"https://{token}@") + + # 支持 git@ 格式:git@github.com:user/repo.git + # 转换为 HTTPS 格式 + if repo_url.startswith("git@"): + repo_url = repo_url.replace("git@", "https://") + repo_url = repo_url.replace(".com:", ".com/") + return repo_url.replace("https://", f"https://{token}@") + + return repo_url + + def _clone_or_pull(self) -> Repo: + """克隆或拉取仓库""" + repo_path = self.local_cache_dir / "repo" + + if repo_path.exists() and (repo_path / ".git").exists(): + # 仓库已存在,拉取最新代码 + try: + repo = Repo(repo_path) + origin = repo.remote("origin") + origin.pull(self.branch) + return repo + except GitCommandError: + # 拉取失败,删除重新克隆 + shutil.rmtree(repo_path) + + # 克隆仓库 + repo = Repo.clone_from( + self.auth_repo_url, + repo_path, + branch=self.branch, + ) + return repo + + def load_accounts(self) -> list[dict[str, Any]]: + """从 Git 仓库加载账号数据""" + try: + return self._load_json_file(self.file_path) + except Exception as e: + print(f"[git-storage] load failed: {e}") + raise + + def save_accounts(self, accounts: list[dict[str, Any]]) -> None: + """保存账号数据到 Git 仓库""" + try: + self._save_json_file(self.file_path, accounts, "Update accounts data") + except Exception as e: + print(f"[git-storage] save failed: {e}") + raise e + + def load_auth_keys(self) -> list[dict[str, Any]]: + """从 Git 仓库加载鉴权密钥数据""" + try: + data = self._load_json_value(self.auth_keys_file_path) + if isinstance(data, dict): + data = data.get("items") + return data if isinstance(data, list) else [] + except Exception as e: + print(f"[git-storage] load failed: {e}") + raise + + def save_auth_keys(self, auth_keys: list[dict[str, Any]]) -> None: + """保存鉴权密钥数据到 Git 仓库""" + try: + self._save_json_file(self.auth_keys_file_path, {"items": auth_keys}, "Update auth keys data") + except Exception as e: + print(f"[git-storage] save failed: {e}") + raise e + + def _load_json_file(self, file_path: str) -> list[dict[str, Any]]: + data = self._load_json_value(file_path) + return data if isinstance(data, list) else [] + + def _load_json_value(self, file_path: str) -> Any: + repo = self._clone_or_pull() + file_full_path = Path(repo.working_dir) / file_path + if not file_full_path.exists(): + return None + return json.loads(file_full_path.read_text(encoding="utf-8")) + + def _save_json_file(self, file_path: str, items: Any, message: str) -> None: + repo = self._clone_or_pull() + file_full_path = Path(repo.working_dir) / file_path + file_full_path.parent.mkdir(parents=True, exist_ok=True) + file_full_path.write_text( + json.dumps(items, ensure_ascii=False, indent=2) + "\n", + encoding="utf-8", + ) + repo.index.add([file_path]) + if repo.is_dirty(): + repo.index.commit(message) + repo.remote("origin").push(self.branch) + + def health_check(self) -> dict[str, Any]: + """健康检查""" + try: + repo = self._clone_or_pull() + return { + "status": "healthy", + "backend": "git", + "repo_url": self._mask_token(self.repo_url), + "branch": self.branch, + "file_path": self.file_path, + "auth_keys_file_path": self.auth_keys_file_path, + "last_commit": repo.head.commit.hexsha[:8], + } + except Exception as e: + return { + "status": "unhealthy", + "backend": "git", + "error": str(e), + } + + def get_backend_info(self) -> dict[str, Any]: + """获取存储后端信息""" + return { + "type": "git", + "description": "Git 私有仓库存储", + "repo_url": self._mask_token(self.repo_url), + "branch": self.branch, + "file_path": self.file_path, + "auth_keys_file_path": self.auth_keys_file_path, + } + + @staticmethod + def _mask_token(url: str) -> str: + """隐藏 URL 中的 token""" + if "@" in url and "://" in url: + protocol, rest = url.split("://", 1) + if "@" in rest: + _, host = rest.split("@", 1) + return f"{protocol}://****@{host}" + return url diff --git a/services/storage/json_storage.py b/services/storage/json_storage.py new file mode 100644 index 0000000000000000000000000000000000000000..591e376cf62ed3cda3e5e336bd4375a139373ba4 --- /dev/null +++ b/services/storage/json_storage.py @@ -0,0 +1,95 @@ +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any + +from services.storage.base import StorageBackend + + +class JSONStorageBackend(StorageBackend): + """本地 JSON 文件存储后端""" + + def __init__(self, file_path: Path, auth_keys_path: Path | None = None): + self.file_path = file_path + self.auth_keys_path = auth_keys_path or file_path.with_name("auth_keys.json") + self.file_path.parent.mkdir(parents=True, exist_ok=True) + self.auth_keys_path.parent.mkdir(parents=True, exist_ok=True) + + @staticmethod + def _load_json_list(file_path: Path) -> list[dict[str, Any]]: + if not file_path.exists(): + return [] + try: + data = json.loads(file_path.read_text(encoding="utf-8")) + return data if isinstance(data, list) else [] + except (json.JSONDecodeError, Exception): + return [] + + @staticmethod + def _save_json_list(file_path: Path, items: list[dict[str, Any]]) -> None: + file_path.parent.mkdir(parents=True, exist_ok=True) + file_path.write_text( + json.dumps(items, ensure_ascii=False, indent=2) + "\n", + encoding="utf-8", + ) + + def load_accounts(self) -> list[dict[str, Any]]: + """从 JSON 文件加载账号数据""" + return self._load_json_list(self.file_path) + + def save_accounts(self, accounts: list[dict[str, Any]]) -> None: + """保存账号数据到 JSON 文件""" + self._save_json_list(self.file_path, accounts) + + def load_auth_keys(self) -> list[dict[str, Any]]: + """从 JSON 文件加载鉴权密钥数据""" + if not self.auth_keys_path.exists(): + return [] + try: + data = json.loads(self.auth_keys_path.read_text(encoding="utf-8")) + except (json.JSONDecodeError, Exception): + return [] + if isinstance(data, dict): + data = data.get("items") + return data if isinstance(data, list) else [] + + def save_auth_keys(self, auth_keys: list[dict[str, Any]]) -> None: + """保存鉴权密钥数据到 JSON 文件""" + self.auth_keys_path.parent.mkdir(parents=True, exist_ok=True) + self.auth_keys_path.write_text( + json.dumps({"items": auth_keys}, ensure_ascii=False, indent=2) + "\n", + encoding="utf-8", + ) + + def health_check(self) -> dict[str, Any]: + """健康检查""" + try: + # 检查文件是否可读写 + if self.file_path.exists(): + self.file_path.read_text(encoding="utf-8") + return { + "status": "healthy", + "backend": "json", + "file_exists": self.file_path.exists(), + "file_path": str(self.file_path), + "auth_keys_file_exists": self.auth_keys_path.exists(), + "auth_keys_file_path": str(self.auth_keys_path), + } + except Exception as e: + return { + "status": "unhealthy", + "backend": "json", + "error": str(e), + } + + def get_backend_info(self) -> dict[str, Any]: + """获取存储后端信息""" + return { + "type": "json", + "description": "本地 JSON 文件存储", + "file_path": str(self.file_path), + "file_exists": self.file_path.exists(), + "auth_keys_file_path": str(self.auth_keys_path), + "auth_keys_file_exists": self.auth_keys_path.exists(), + } diff --git a/services/sub2api_service.py b/services/sub2api_service.py new file mode 100644 index 0000000000000000000000000000000000000000..51635d58a164ed20662a52ea19c916a02e4f676f --- /dev/null +++ b/services/sub2api_service.py @@ -0,0 +1,523 @@ +"""Sub2API integration for browsing and importing ChatGPT OAuth accounts from a sub2api admin.""" + +from __future__ import annotations + +import json +import threading +import time +import uuid +from concurrent.futures import ThreadPoolExecutor, as_completed +from datetime import datetime, timezone +from pathlib import Path +from threading import Lock + +from curl_cffi.requests import Session + +from services.account_service import account_service +from services.config import DATA_DIR + + +SUB2API_CONFIG_FILE = DATA_DIR / "sub2api_config.json" + +# Cached JWT per server to avoid re-login on every list/import call. +# Token lifetime on sub2api defaults to 24h; we refresh 5 min before expiry. +_TOKEN_REFRESH_SKEW = 5 * 60 + + +def _new_id() -> str: + return uuid.uuid4().hex[:12] + + +def _now_iso() -> str: + return datetime.now(timezone.utc).isoformat() + + +def _clean(value: object) -> str: + return str(value or "").strip() + + +def _normalize_import_job(raw: object, *, fail_unfinished: bool) -> dict | None: + if not isinstance(raw, dict): + return None + status = _clean(raw.get("status")) or "failed" + if fail_unfinished and status in {"pending", "running"}: + status = "failed" + return { + "job_id": _clean(raw.get("job_id")) or uuid.uuid4().hex, + "status": status, + "created_at": _clean(raw.get("created_at")) or _now_iso(), + "updated_at": _clean(raw.get("updated_at")) or _clean(raw.get("created_at")) or _now_iso(), + "total": int(raw.get("total") or 0), + "completed": int(raw.get("completed") or 0), + "added": int(raw.get("added") or 0), + "skipped": int(raw.get("skipped") or 0), + "refreshed": int(raw.get("refreshed") or 0), + "failed": int(raw.get("failed") or 0), + "errors": raw.get("errors") if isinstance(raw.get("errors"), list) else [], + } + + +def _normalize_server(raw: dict) -> dict: + return { + "id": _clean(raw.get("id")) or _new_id(), + "name": _clean(raw.get("name")), + "base_url": _clean(raw.get("base_url")), + "email": _clean(raw.get("email")), + "password": _clean(raw.get("password")), + "api_key": _clean(raw.get("api_key")), + "group_id": _clean(raw.get("group_id")), + "import_job": _normalize_import_job(raw.get("import_job"), fail_unfinished=True), + } + + +class Sub2APIConfig: + def __init__(self, store_file: Path): + self._store_file = store_file + self._lock = Lock() + self._servers: list[dict] = self._load() + + def _load(self) -> list[dict]: + if not self._store_file.exists(): + return [] + try: + raw = json.loads(self._store_file.read_text(encoding="utf-8")) + if isinstance(raw, list): + return [_normalize_server(item) for item in raw if isinstance(item, dict)] + except Exception: + pass + return [] + + def _save(self) -> None: + self._store_file.parent.mkdir(parents=True, exist_ok=True) + self._store_file.write_text( + json.dumps(self._servers, ensure_ascii=False, indent=2) + "\n", + encoding="utf-8", + ) + + def list_servers(self) -> list[dict]: + with self._lock: + return [dict(server) for server in self._servers] + + def get_server(self, server_id: str) -> dict | None: + with self._lock: + for server in self._servers: + if server["id"] == server_id: + return dict(server) + return None + + def add_server( + self, + *, + name: str, + base_url: str, + email: str, + password: str, + api_key: str, + group_id: str = "", + ) -> dict: + server = _normalize_server({ + "id": _new_id(), + "name": name, + "base_url": base_url, + "email": email, + "password": password, + "api_key": api_key, + "group_id": group_id, + }) + with self._lock: + self._servers.append(server) + self._save() + _token_cache.pop(server["id"], None) + return dict(server) + + def update_server(self, server_id: str, updates: dict) -> dict | None: + with self._lock: + for index, server in enumerate(self._servers): + if server["id"] != server_id: + continue + merged = {**server, **{k: v for k, v in updates.items() if v is not None}, "id": server_id} + self._servers[index] = _normalize_server(merged) + self._save() + result = dict(self._servers[index]) + break + else: + return None + _token_cache.pop(server_id, None) + return result + + def delete_server(self, server_id: str) -> bool: + with self._lock: + before = len(self._servers) + self._servers = [server for server in self._servers if server["id"] != server_id] + removed = len(self._servers) < before + if removed: + self._save() + if removed: + _token_cache.pop(server_id, None) + return removed + + def set_import_job(self, server_id: str, import_job: dict | None) -> dict | None: + with self._lock: + for index, server in enumerate(self._servers): + if server["id"] != server_id: + continue + next_server = dict(server) + next_server["import_job"] = _normalize_import_job(import_job, fail_unfinished=False) + self._servers[index] = next_server + self._save() + return dict(next_server) + return None + + def get_import_job(self, server_id: str) -> dict | None: + with self._lock: + for server in self._servers: + if server["id"] == server_id: + job = server.get("import_job") + return dict(job) if isinstance(job, dict) else None + return None + + +# Per-server cached access token: {server_id: (jwt, expires_at_epoch)} +_token_cache: dict[str, tuple[str, float]] = {} +_token_cache_lock = Lock() + + +def _login(base_url: str, email: str, password: str) -> tuple[str, float]: + url = f"{base_url.rstrip('/')}/api/v1/auth/login" + session = Session(verify=True) + try: + response = session.post( + url, + json={"email": email, "password": password}, + headers={"Accept": "application/json", "Content-Type": "application/json"}, + timeout=30, + ) + if not response.ok: + raise RuntimeError(f"sub2api login failed: HTTP {response.status_code} {response.text[:200]}") + payload = response.json() + finally: + session.close() + + body = _unwrap_envelope(payload) + if not isinstance(body, dict): + raise RuntimeError("sub2api login payload is invalid") + + token = _clean(body.get("access_token")) + if not token: + raise RuntimeError("sub2api login did not return access_token") + + expires_in = int(body.get("expires_in") or 3600) + expires_at = time.time() + max(60, expires_in) - _TOKEN_REFRESH_SKEW + return token, expires_at + + +def _auth_headers(server: dict) -> dict[str, str]: + api_key = _clean(server.get("api_key")) + if api_key: + return {"x-api-key": api_key, "Accept": "application/json"} + + email = _clean(server.get("email")) + password = _clean(server.get("password")) + if not email or not password: + raise RuntimeError("sub2api server requires email+password or api_key") + + server_id = _clean(server.get("id")) + base_url = _clean(server.get("base_url")) + + with _token_cache_lock: + cached = _token_cache.get(server_id) + if cached and cached[1] > time.time(): + return {"Authorization": f"Bearer {cached[0]}", "Accept": "application/json"} + + token, expires_at = _login(base_url, email, password) + with _token_cache_lock: + _token_cache[server_id] = (token, expires_at) + return {"Authorization": f"Bearer {token}", "Accept": "application/json"} + + +def _extract_access_token(credentials: object) -> str: + if not isinstance(credentials, dict): + return "" + for key in ("access_token", "accessToken", "token"): + value = _clean(credentials.get(key)) + if value: + return value + return "" + + +def _unwrap_envelope(payload: object) -> object: + """Peel sub2api's `{code, message, data}` envelope, returning the inner `data` field + when present. Also handles unwrapped responses from older/alt versions.""" + if isinstance(payload, dict) and "data" in payload and "code" in payload: + return payload.get("data") + return payload + + +def _extract_paged_items(payload: object) -> tuple[list, int]: + """Return (items, total) from a paginated sub2api response. + + Handles both the wrapped shape `{code,data:{items,total,...}}` and a few looser + variants (`{data:[...]}`, `[...]`, `{items:[...],total:N}`).""" + inner = _unwrap_envelope(payload) + if isinstance(inner, list): + return inner, len(inner) + if isinstance(inner, dict): + for key in ("items", "data", "list"): + value = inner.get(key) + if isinstance(value, list): + return value, int(inner.get("total") or len(value)) + return [], 0 + + +def list_remote_accounts(server: dict) -> list[dict]: + """Return a flat list of OpenAI OAuth accounts from a sub2api server.""" + base_url = _clean(server.get("base_url")) + if not base_url: + return [] + + headers = _auth_headers(server) + group_id = _clean(server.get("group_id")) + + session = Session(verify=True) + items: list[dict] = [] + try: + page = 1 + while True: + params: dict[str, object] = { + "platform": "openai", + "type": "oauth", + "page": page, + "page_size": 200, + } + if group_id: + params["group"] = group_id + response = session.get( + f"{base_url.rstrip('/')}/api/v1/admin/accounts", + headers=headers, + params=params, + timeout=30, + ) + if not response.ok: + raise RuntimeError(f"sub2api list failed: HTTP {response.status_code} {response.text[:200]}") + payload = response.json() + + data, total = _extract_paged_items(payload) + if not data: + break + + for account in data: + if not isinstance(account, dict): + continue + credentials = account.get("credentials") if isinstance(account.get("credentials"), dict) else {} + access_token = _extract_access_token(credentials) + if not access_token: + continue + account_id = account.get("id") + items.append({ + "id": str(account_id) if account_id is not None else _clean(credentials.get("chatgpt_account_id")), + "name": _clean(account.get("name")), + "email": _clean(credentials.get("email")) or _clean(account.get("name")), + "plan_type": _clean(credentials.get("plan_type")), + "status": _clean(account.get("status")), + "expires_at": _clean(credentials.get("expires_at")), + "has_refresh_token": bool(_clean(credentials.get("refresh_token"))), + }) + + if page * 200 >= total or len(data) < 200: + break + page += 1 + finally: + session.close() + + return items + + +def list_remote_groups(server: dict) -> list[dict]: + """Return OpenAI account groups from a sub2api server.""" + base_url = _clean(server.get("base_url")) + if not base_url: + return [] + + headers = _auth_headers(server) + + session = Session(verify=True) + items: list[dict] = [] + try: + page = 1 + while True: + response = session.get( + f"{base_url.rstrip('/')}/api/v1/admin/groups", + headers=headers, + params={ + "page": page, + "page_size": 200, + }, + timeout=30, + ) + if not response.ok: + raise RuntimeError(f"sub2api groups failed: HTTP {response.status_code} {response.text[:200]}") + payload = response.json() + + data, total = _extract_paged_items(payload) + if not data: + break + + for group in data: + if not isinstance(group, dict): + continue + group_id = group.get("id") + if group_id is None: + continue + items.append({ + "id": str(group_id), + "name": _clean(group.get("name")), + "description": _clean(group.get("description")), + "platform": _clean(group.get("platform")), + "status": _clean(group.get("status")), + "account_count": int(group.get("account_count") or 0), + "active_account_count": int(group.get("active_account_count") or 0), + }) + + if page * 200 >= total or len(data) < 200: + break + page += 1 + finally: + session.close() + + return items + + +def _fetch_access_token_for_account(server: dict, account_id: str) -> tuple[str, dict]: + """Return (access_token, account_meta) for a single sub2api account id.""" + base_url = _clean(server.get("base_url")) + headers = _auth_headers(server) + + session = Session(verify=True) + try: + response = session.get( + f"{base_url.rstrip('/')}/api/v1/admin/accounts/{account_id}", + headers=headers, + timeout=30, + ) + if not response.ok: + raise RuntimeError(f"HTTP {response.status_code}") + payload = response.json() + finally: + session.close() + + account = _unwrap_envelope(payload) + if not isinstance(account, dict): + account = payload if isinstance(payload, dict) else {} + credentials = account.get("credentials") if isinstance(account.get("credentials"), dict) else {} + access_token = _extract_access_token(credentials) + if not access_token: + raise RuntimeError("missing access_token") + return access_token, { + "email": _clean(credentials.get("email")), + "plan_type": _clean(credentials.get("plan_type")), + } + + +class Sub2APIImportService: + def __init__(self, sub2api_config: Sub2APIConfig): + self._config = sub2api_config + + def start_import(self, server: dict, account_ids: list[str]) -> dict: + ids = [_clean(item) for item in account_ids if _clean(item)] + if not ids: + raise ValueError("account ids is required") + + server_id = _clean(server.get("id")) + job = { + "job_id": uuid.uuid4().hex, + "status": "pending", + "created_at": _now_iso(), + "updated_at": _now_iso(), + "total": len(ids), + "completed": 0, + "added": 0, + "skipped": 0, + "refreshed": 0, + "failed": 0, + "errors": [], + } + saved = self._config.set_import_job(server_id, job) + if saved is None: + raise ValueError("server not found") + + thread = threading.Thread( + target=self._run_import, + args=(server_id, server, ids), + name=f"sub2api-import-{server_id}", + daemon=True, + ) + thread.start() + return dict(saved.get("import_job") or job) + + def _update_job(self, server_id: str, **updates) -> None: + current = self._config.get_import_job(server_id) + if current is None: + return + next_job = {**current, **updates, "updated_at": _now_iso()} + self._config.set_import_job(server_id, next_job) + + def _append_error(self, server_id: str, account_id: str, message: str) -> None: + current = self._config.get_import_job(server_id) + if current is None: + return + errors = list(current.get("errors") or []) + errors.append({"name": account_id, "error": message}) + self._update_job(server_id, errors=errors, failed=len(errors)) + + def _run_import(self, server_id: str, server: dict, account_ids: list[str]) -> None: + self._update_job(server_id, status="running") + + tokens: list[str] = [] + max_workers = min(8, max(1, len(account_ids))) + with ThreadPoolExecutor(max_workers=max_workers) as executor: + future_map = { + executor.submit(_fetch_access_token_for_account, server, account_id): account_id + for account_id in account_ids + } + for future in as_completed(future_map): + account_id = future_map[future] + try: + token, _meta = future.result() + tokens.append(token) + except Exception as exc: + self._append_error(server_id, account_id, str(exc) or "unknown error") + + current = self._config.get_import_job(server_id) or {} + failed = len(current.get("errors") or []) + self._update_job( + server_id, + completed=int(current.get("completed") or 0) + 1, + failed=failed, + ) + + if not tokens: + current = self._config.get_import_job(server_id) or {} + self._update_job( + server_id, + status="failed", + completed=int(current.get("total") or 0), + failed=len(current.get("errors") or []), + ) + return + + add_result = account_service.add_accounts(tokens, source_type="codex") + refresh_result = account_service.refresh_accounts(tokens) + current = self._config.get_import_job(server_id) or {} + self._update_job( + server_id, + status="completed", + completed=len(account_ids), + added=int(add_result.get("added") or 0), + skipped=int(add_result.get("skipped") or 0), + refreshed=int(refresh_result.get("refreshed") or 0), + failed=len(current.get("errors") or []), + ) + + +sub2api_config = Sub2APIConfig(SUB2API_CONFIG_FILE) +sub2api_import_service = Sub2APIImportService(sub2api_config) diff --git a/test/__init__.py b/test/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..8b137891791fe96927ad78e64b0aad7bded08bdc --- /dev/null +++ b/test/__init__.py @@ -0,0 +1 @@ + diff --git a/test/test_account_export.py b/test/test_account_export.py new file mode 100644 index 0000000000000000000000000000000000000000..095c5dbf6d1d846cb801f73e7e19a71f88d5e532 --- /dev/null +++ b/test/test_account_export.py @@ -0,0 +1,118 @@ +import base64 +import json +import unittest +from typing import Any + +from services.account_service import AccountService + + +class MemoryStorage: + def __init__(self, accounts: list[dict[str, Any]] | None = None) -> None: + self.accounts = list(accounts or []) + + def load_accounts(self) -> list[dict[str, Any]]: + return list(self.accounts) + + def save_accounts(self, accounts: list[dict[str, Any]]) -> None: + self.accounts = list(accounts) + + def load_auth_keys(self) -> list[dict[str, Any]]: + return [] + + def save_auth_keys(self, auth_keys: list[dict[str, Any]]) -> None: + pass + + def health_check(self) -> dict[str, Any]: + return {"ok": True} + + def get_backend_info(self) -> dict[str, Any]: + return {"type": "memory"} + + +def make_jwt(payload: dict[str, Any]) -> str: + def encode(value: dict[str, Any]) -> str: + raw = json.dumps(value, separators=(",", ":")).encode("utf-8") + return base64.urlsafe_b64encode(raw).decode("ascii").rstrip("=") + + return f'{encode({"alg": "none", "typ": "JWT"})}.{encode(payload)}.sig' + + +class AccountExportTests(unittest.TestCase): + def test_build_export_items_uses_codex_shape_and_jwt_claims(self) -> None: + access_token = make_jwt( + { + "exp": 0, + "iat": 3600, + "https://api.openai.com/auth": {"chatgpt_account_id": "acct_123"}, + "https://api.openai.com/profile": {"email": "test@example.com"}, + } + ) + id_token = make_jwt({"email": "fallback@example.com"}) + service = AccountService( + MemoryStorage( + [ + { + "access_token": access_token, + "id_token": id_token, + "refresh_token": "rt_test", + } + ] + ) + ) + + [item] = service.build_export_items([access_token]) + + self.assertEqual(item["type"], "codex") + self.assertEqual(item["email"], "test@example.com") + self.assertEqual(item["expired"], "1970-01-01T08:00:00+08:00") + self.assertEqual(item["account_id"], "acct_123") + self.assertEqual(item["access_token"], access_token) + self.assertEqual(item["last_refresh"], "1970-01-01T09:00:00+08:00") + self.assertEqual(item["id_token"], id_token) + self.assertEqual(item["refresh_token"], "rt_test") + + def test_build_export_items_skips_accounts_missing_complete_tokens(self) -> None: + complete_access_token = make_jwt({"exp": 0}) + complete_id_token = make_jwt({"email": "complete@example.com"}) + service = AccountService( + MemoryStorage( + [ + {"access_token": "only_access"}, + {"access_token": "missing_id", "refresh_token": "rt_missing_id"}, + {"access_token": complete_access_token, "id_token": complete_id_token, "refresh_token": "rt_complete"}, + ] + ) + ) + + items = service.build_export_items() + + self.assertEqual(len(items), 1) + self.assertEqual(items[0]["access_token"], complete_access_token) + self.assertEqual(items[0]["id_token"], complete_id_token) + self.assertEqual(items[0]["refresh_token"], "rt_complete") + + def test_add_account_items_preserves_export_fields_without_overwriting_plan_type(self) -> None: + service = AccountService(MemoryStorage()) + + result = service.add_account_items( + [ + { + "type": "codex", + "access_token": "access_token_test", + "refresh_token": "rt_test", + "account_id": "acct_123", + } + ] + ) + + account = service.get_account("access_token_test") + self.assertEqual(result["added"], 1) + self.assertIsNotNone(account) + self.assertEqual(account["type"], "free") + self.assertEqual(account["export_type"], "codex") + self.assertEqual(account["refresh_token"], "rt_test") + self.assertEqual(account["account_id"], "acct_123") + + +if __name__ == "__main__": + unittest.main() diff --git a/test/test_account_image_capabilities.py b/test/test_account_image_capabilities.py new file mode 100644 index 0000000000000000000000000000000000000000..5aaf01c0590514f79dd71e60fb932a9b155815d3 --- /dev/null +++ b/test/test_account_image_capabilities.py @@ -0,0 +1,182 @@ +from __future__ import annotations + +import os +import tempfile +import unittest +from pathlib import Path + +os.environ.setdefault("CHATGPT2API_AUTH_KEY", "test-auth") + +from services.account_service import AccountService +from services.auth_service import AuthService +from services.storage.json_storage import JSONStorageBackend +from utils.helper import anonymize_token, split_image_model + + +class AccountCapabilityTests(unittest.TestCase): + def test_unknown_quota_accounts_are_available_only_when_not_throttled(self) -> None: + self.assertFalse( + AccountService._is_image_account_available( + {"status": "限流", "image_quota_unknown": True, "quota": 0} + ) + ) + self.assertTrue( + AccountService._is_image_account_available( + {"status": "正常", "image_quota_unknown": True, "quota": 0} + ) + ) + + def test_prolite_variants_are_normalized(self) -> None: + with tempfile.TemporaryDirectory() as tmp_dir: + service = AccountService(JSONStorageBackend(Path(tmp_dir) / "accounts.json")) + self.assertEqual(service._normalize_account_type("prolite"), "ProLite") + self.assertEqual(service._normalize_account_type("pro_lite"), "ProLite") + + def test_search_account_type_ignores_unrelated_scalar_values(self) -> None: + with tempfile.TemporaryDirectory() as tmp_dir: + service = AccountService(JSONStorageBackend(Path(tmp_dir) / "accounts.json")) + self.assertIsNone( + service._search_account_type( + { + "amr": ["pwd", "otp", "mfa"], + "chatgpt_compute_residency": "no_constraint", + "chatgpt_data_residency": "no_constraint", + "user_id": "user-I52GFfLGFM0dokFk2dBiKEBn", + } + ) + ) + + def test_mark_image_result_does_not_consume_unknown_quota(self) -> None: + with tempfile.TemporaryDirectory() as tmp_dir: + service = AccountService(JSONStorageBackend(Path(tmp_dir) / "accounts.json")) + service.add_accounts(["token-1"]) + service.update_account( + "token-1", + { + "status": "正常", + "quota": 0, + "image_quota_unknown": True, + }, + ) + + updated = service.mark_image_result("token-1", success=True) + + self.assertIsNotNone(updated) + self.assertEqual(updated["quota"], 0) + self.assertEqual(updated["status"], "正常") + self.assertTrue(updated["image_quota_unknown"]) + + def test_split_image_model_supports_plan_type_prefix(self) -> None: + self.assertEqual(split_image_model("gpt-image-2"), (None, "gpt-image-2")) + self.assertEqual(split_image_model("plus-codex-gpt-image-2"), ("plus", "codex-gpt-image-2")) + self.assertEqual(split_image_model("team-codex-gpt-image-2"), ("team", "codex-gpt-image-2")) + self.assertEqual(split_image_model("pro-codex-gpt-image-2"), ("pro", "codex-gpt-image-2")) + self.assertEqual(split_image_model("plus-gpt-image-2"), (None, None)) + self.assertEqual(split_image_model("unknown-image-model"), (None, None)) + + def test_get_available_access_token_filters_by_plan_type(self) -> None: + with tempfile.TemporaryDirectory() as tmp_dir: + service = AccountService(JSONStorageBackend(Path(tmp_dir) / "accounts.json")) + service.add_account_items( + [ + {"access_token": "token-plus", "type": "Plus", "status": "正常", "quota": 3}, + {"access_token": "token-pro", "type": "Pro", "status": "正常", "quota": 3}, + ] + ) + + service.fetch_remote_info = lambda access_token, event="fetch_remote_info": service.get_account(access_token) + + plus_token = service.get_available_access_token(plan_type="plus") + pro_token = service.get_available_access_token(plan_type="pro") + service.release_image_slot(plus_token) + service.release_image_slot(pro_token) + + self.assertEqual(plus_token, "token-plus") + self.assertEqual(pro_token, "token-pro") + + +class TokenLogTests(unittest.TestCase): + def test_anonymize_token_hides_raw_value(self) -> None: + token = "super-secret-token" + token_ref = anonymize_token(token) + + self.assertTrue(token_ref.startswith("token:")) + self.assertNotIn(token, token_ref) + + +class AuthServiceTests(unittest.TestCase): + def test_create_authenticate_disable_and_delete_user_key(self) -> None: + with tempfile.TemporaryDirectory() as tmp_dir: + service = AuthService(JSONStorageBackend(Path(tmp_dir) / "accounts.json", Path(tmp_dir) / "auth_keys.json")) + + item, raw_key = service.create_key(role="user", name="Alice") + + self.assertEqual(item["role"], "user") + self.assertEqual(item["name"], "Alice") + self.assertTrue(item["enabled"]) + self.assertTrue(raw_key.startswith("sk-")) + + authed = service.authenticate(raw_key) + self.assertIsNotNone(authed) + self.assertEqual(authed["id"], item["id"]) + self.assertEqual(authed["role"], "user") + self.assertIsNotNone(authed["last_used_at"]) + + updated = service.update_key(item["id"], {"enabled": False}, role="user") + self.assertIsNotNone(updated) + self.assertFalse(updated["enabled"]) + self.assertIsNone(service.authenticate(raw_key)) + + self.assertTrue(service.delete_key(item["id"], role="user")) + self.assertFalse(service.delete_key(item["id"], role="user")) + self.assertEqual(service.list_keys(role="user"), []) + + def test_authenticate_ignores_last_used_save_failure(self) -> None: + with tempfile.TemporaryDirectory() as tmp_dir: + service = AuthService(JSONStorageBackend(Path(tmp_dir) / "accounts.json", Path(tmp_dir) / "auth_keys.json")) + item, raw_key = service.create_key(role="user", name="Alice") + + def fail_save() -> None: + raise OSError("disk unavailable") + + service._save = fail_save + + authed = service.authenticate(raw_key) + + self.assertIsNotNone(authed) + self.assertEqual(authed["id"], item["id"]) + self.assertIsNotNone(authed["last_used_at"]) + + def test_update_user_key_replaces_raw_key(self) -> None: + with tempfile.TemporaryDirectory() as tmp_dir: + service = AuthService(JSONStorageBackend(Path(tmp_dir) / "accounts.json", Path(tmp_dir) / "auth_keys.json")) + item, raw_key = service.create_key(role="user", name="Alice") + + updated = service.update_key(item["id"], {"key": "sk-user-custom-key"}, role="user") + + self.assertIsNotNone(updated) + self.assertIsNone(service.authenticate(raw_key)) + + authed = service.authenticate("sk-user-custom-key") + self.assertIsNotNone(authed) + self.assertEqual(authed["id"], item["id"]) + + def test_user_key_name_must_be_unique(self) -> None: + with tempfile.TemporaryDirectory() as tmp_dir: + service = AuthService(JSONStorageBackend(Path(tmp_dir) / "accounts.json", Path(tmp_dir) / "auth_keys.json")) + first, _ = service.create_key(role="user", name="Alice") + second, _ = service.create_key(role="user", name="Bob") + + with self.assertRaisesRegex(ValueError, "这个名称已经在使用中了"): + service.create_key(role="user", name="Alice") + + with self.assertRaisesRegex(ValueError, "这个名称已经在使用中了"): + service.update_key(second["id"], {"name": "Alice"}, role="user") + + updated = service.update_key(first["id"], {"name": "Alice"}, role="user") + self.assertIsNotNone(updated) + self.assertEqual(updated["name"], "Alice") + + +if __name__ == "__main__": + unittest.main() diff --git a/test/test_chat_completion_cache.py b/test/test_chat_completion_cache.py new file mode 100644 index 0000000000000000000000000000000000000000..92af6b43f8c90575b8a9e483935343e1a80035fc --- /dev/null +++ b/test/test_chat_completion_cache.py @@ -0,0 +1,218 @@ +from __future__ import annotations + +import unittest +from unittest import mock +import json + +from services.config import config +from services.protocol import openai_v1_chat_complete, openai_v1_response +from services.protocol.chat_completion_cache import chat_completion_cache +from services.protocol.conversation import iter_conversation_payloads, sanitize_output_text + + +class ChatCompletionCacheTests(unittest.TestCase): + def setUp(self) -> None: + self.old_cache_settings = config.data.get("chat_completion_cache") + config.data["chat_completion_cache"] = { + "enabled": True, + "ttl_seconds": 60, + "max_entries": 32, + "dedupe_inflight": True, + "stream_cache": True, + "normalize_messages": True, + "drop_adjacent_duplicates": True, + "drop_assistant_history": False, + } + chat_completion_cache.clear() + + def tearDown(self) -> None: + if self.old_cache_settings is None: + config.data.pop("chat_completion_cache", None) + else: + config.data["chat_completion_cache"] = self.old_cache_settings + chat_completion_cache.clear() + + def test_repeated_non_stream_text_completion_uses_cache(self) -> None: + calls = 0 + + def fake_collect_text(_backend, _request): + nonlocal calls + calls += 1 + return f"cached answer {calls}" + + body = { + "model": "auto", + "messages": [{"role": "user", "content": "cache this exact prompt"}], + } + + with ( + mock.patch("services.protocol.openai_v1_chat_complete.text_backend", return_value=object()), + mock.patch("services.protocol.openai_v1_chat_complete.collect_text", side_effect=fake_collect_text), + ): + first = openai_v1_chat_complete.handle(body) + second = openai_v1_chat_complete.handle(body) + + self.assertEqual(calls, 1) + self.assertEqual( + first["choices"][0]["message"]["content"], + second["choices"][0]["message"]["content"], + ) + + def test_repeated_stream_text_completion_replays_cached_chunks(self) -> None: + calls = 0 + + def fake_stream_text_deltas(_backend, _request): + nonlocal calls + calls += 1 + yield "streamed" + yield " answer" + + body = { + "model": "auto", + "stream": True, + "messages": [{"role": "user", "content": "stream cache this exact prompt"}], + } + + with ( + mock.patch("services.protocol.openai_v1_chat_complete.text_backend", return_value=object()), + mock.patch( + "services.protocol.openai_v1_chat_complete.stream_text_deltas", + side_effect=fake_stream_text_deltas, + ), + ): + first = list(openai_v1_chat_complete.handle(body)) + second = list(openai_v1_chat_complete.handle(body)) + + self.assertEqual(calls, 1) + self.assertEqual(first, second) + content = "".join(str(chunk["choices"][0]["delta"].get("content") or "") for chunk in second) + self.assertEqual(content, "streamed answer") + + def test_adjacent_duplicate_messages_are_removed_before_upstream_call(self) -> None: + captured_messages = [] + + def fake_collect_text(_backend, request): + captured_messages.extend(request.messages or []) + return "ok" + + body = { + "model": "auto", + "messages": [ + {"role": "user", "content": "repeat me"}, + {"role": "user", "content": "repeat me"}, + {"role": "assistant", "content": "old answer"}, + {"role": "user", "content": "next prompt"}, + ], + } + + with ( + mock.patch("services.protocol.openai_v1_chat_complete.text_backend", return_value=object()), + mock.patch("services.protocol.openai_v1_chat_complete.collect_text", side_effect=fake_collect_text), + ): + openai_v1_chat_complete.handle(body) + + self.assertEqual( + captured_messages, + [ + {"role": "user", "content": "repeat me"}, + {"role": "assistant", "content": "old answer"}, + {"role": "user", "content": "next prompt"}, + ], + ) + + def test_chat_completion_usage_includes_cached_tokens(self) -> None: + with ( + mock.patch("services.protocol.openai_v1_chat_complete.text_backend", return_value=object()), + mock.patch("services.protocol.openai_v1_chat_complete.collect_text", return_value="ok"), + ): + response = openai_v1_chat_complete.handle({ + "model": "auto", + "messages": [{"role": "user", "content": "usage shape"}], + }) + + details = response["usage"]["prompt_tokens_details"] + self.assertEqual(details["cached_tokens"], 0) + output_details = response["usage"]["completion_tokens_details"] + self.assertEqual(output_details["reasoning_tokens"], 0) + + def test_responses_completed_usage_includes_cached_tokens(self) -> None: + with ( + mock.patch("services.protocol.openai_v1_response.text_backend", return_value=object()), + mock.patch("services.protocol.openai_v1_response.stream_text_deltas", return_value=iter(["ok"])), + ): + response = openai_v1_response.handle({ + "model": "auto", + "input": "usage shape", + }) + + details = response["usage"]["input_tokens_details"] + self.assertEqual(details["cached_tokens"], 0) + output_details = response["usage"]["output_tokens_details"] + self.assertEqual(output_details["reasoning_tokens"], 0) + + def test_repeated_responses_text_request_uses_cache(self) -> None: + calls = 0 + + def fake_stream_text_deltas(_backend, _request): + nonlocal calls + calls += 1 + yield f"response cache {calls}" + + body = { + "model": "auto", + "input": "cache this responses prompt", + "stream": True, + } + + with ( + mock.patch("services.protocol.openai_v1_response.text_backend", return_value=object()), + mock.patch("services.protocol.openai_v1_response.stream_text_deltas", side_effect=fake_stream_text_deltas), + ): + first = list(openai_v1_response.handle(body)) + second = list(openai_v1_response.handle(body)) + + self.assertEqual(calls, 1) + self.assertEqual(first, second) + + def test_output_sanitizer_removes_chatgpt_annotation_markup(self) -> None: + text = ( + "Repo: \ue200url\ue202basketikun/chatgpt2api" + "\ue202https://github.com/basketikun/chatgpt2api\ue201 " + "details \ue200cite\ue202turn0search0\ue201." + ) + + self.assertEqual( + sanitize_output_text(text), + "Repo: basketikun/chatgpt2api (https://github.com/basketikun/chatgpt2api) details .", + ) + + def test_stream_sanitizer_does_not_emit_partial_annotation_or_repeat_prefix(self) -> None: + events = [ + {"p": "/message/content/parts/0", "o": "append", "v": "Repo: \ue200url\ue202chat"}, + {"p": "/message/content/parts/0", "o": "append", "v": "gpt2api\ue202turn0search0\ue201 done \ue200cite\ue202turn0\ue201."}, + "[DONE]", + ] + payloads = [json.dumps(event, ensure_ascii=False) if isinstance(event, dict) else event for event in events] + deltas = [ + str(event.get("delta") or "") + for event in iter_conversation_payloads(iter(payloads)) + if event.get("type") == "conversation.delta" + ] + + self.assertEqual("".join(deltas), "Repo: chatgpt2api done .") + self.assertFalse(any("\ue200" in delta or "\ue202" in delta or "\ue201" in delta for delta in deltas)) + + def test_responses_tools_add_honest_no_tool_guard(self) -> None: + model, messages = openai_v1_response.text_response_parts({ + "model": "auto", + "input": "run echo hi", + "tools": [{"type": "function", "name": "shell"}], + }) + + self.assertEqual(model, "auto") + self.assertEqual(messages[0]["role"], "system") + self.assertIn("cannot execute local tools", str(messages[0]["content"])) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/test_codex_4k.py b/test/test_codex_4k.py new file mode 100644 index 0000000000000000000000000000000000000000..db1ffcea324a2c6b8d1520a40de01c6298c5b96c --- /dev/null +++ b/test/test_codex_4k.py @@ -0,0 +1,86 @@ +#!/usr/bin/env python3 +# Test script: directly call https://chatgpt.com/backend-api/codex/responses to generate one 2K image. +# Only edit ACCESS_TOKEN, then run: python codex_responses_image_test.py +# Fixed request parameters: +# prompt: A highly detailed square 2K image of a quiet futuristic library at sunrise +# responses model: gpt-5.5 +# image model: gpt-image-2 +# size: 2048x2048 +# quality: auto +# output_format: png +# output file: codex_4k.png + +import base64 +import json +import time +import urllib.request + +ACCESS_TOKEN = "" + + +def parse_events(raw): + ctype, text = raw.headers.get("content-type", ""), raw.read().decode("utf-8", "replace") + if "application/json" in ctype: + return [json.loads(text)] + events, lines = [], [] + for line in text.splitlines() + [""]: + if not line: + if lines: + data = "\n".join(lines).strip() + if data and data != "[DONE]": + events.append(json.loads(data)) + lines = [] + elif line.startswith("data:"): + lines.append(line[5:].lstrip()) + return events + + +def find_images(value): + if isinstance(value, dict): + if value.get("type") == "image_generation_call" and isinstance(value.get("result"), str): + result = value["result"].strip() + return [result.split(",", 1)[1] if result.startswith("data:image/") else result] + return [image for item in value.values() for image in find_images(item)] + if isinstance(value, list): + return [image for item in value for image in find_images(item)] + return [] + + +def main(): + start_time = time.time() + body = { + "model": "gpt-5.5", + "instructions": "Use the image_generation tool to create exactly one image for the user's request. Return the generated image result.", + "store": False, + "input": [{"role": "user", "content": [{"type": "input_text", "text": "A highly detailed square 2K image of a quiet futuristic library at sunrise"}]}], + "tools": [{ + "type": "image_generation", + "model": "gpt-image-2", + "action": "generate", + "size": "3840x2160", + "quality": "auto", + "output_format": "png" + }], + "tool_choice": {"type": "image_generation"}, + "stream": True + } + headers = {"Authorization": f"Bearer {ACCESS_TOKEN}", "Content-Type": "application/json"} + + req = urllib.request.Request("https://chatgpt.com/backend-api/codex/responses", json.dumps(body).encode(), headers, method="POST") + try: + images = find_images(parse_events(urllib.request.urlopen(req, timeout=1200))) + except urllib.error.HTTPError as error: + raise SystemExit(f"HTTP {error.code}: {error.read().decode('utf-8', 'replace')[:1000]}") + + if not images: + raise SystemExit("No image result found in response") + + with open("codex_4k.png", "wb") as file: + file.write(base64.b64decode(images[0])) + print("saved codex_4k.png") + end_time = time.time() + print(f"total time: {end_time - start_time:.2f} seconds") + + +if __name__ == "__main__": + main() diff --git a/test/test_config.py b/test/test_config.py new file mode 100644 index 0000000000000000000000000000000000000000..d9e4f3d596a4ba06b0cd389925a0a556d9365da9 --- /dev/null +++ b/test/test_config.py @@ -0,0 +1,63 @@ +import json +import tempfile +import unittest +from pathlib import Path + + +ROOT_DIR = Path(__file__).resolve().parents[1] +ROOT_CONFIG_FILE = ROOT_DIR / "config.json" + + +class ConfigLoadingTests(unittest.TestCase): + @classmethod + def setUpClass(cls) -> None: + cls._created_root_config = False + if not ROOT_CONFIG_FILE.exists(): + ROOT_CONFIG_FILE.write_text(json.dumps({"auth-key": "test-auth"}), encoding="utf-8") + cls._created_root_config = True + + from services import config as config_module + + cls.config_module = config_module + + @classmethod + def tearDownClass(cls) -> None: + if cls._created_root_config and ROOT_CONFIG_FILE.exists(): + ROOT_CONFIG_FILE.unlink() + + def test_load_settings_ignores_directory_config_path(self) -> None: + with tempfile.TemporaryDirectory() as tmp_dir: + base_dir = Path(tmp_dir) + data_dir = base_dir / "data" + config_dir = base_dir / "config.json" + os_auth_key = "env-auth" + + config_dir.mkdir() + + module = self.config_module + old_base_dir = module.BASE_DIR + old_data_dir = module.DATA_DIR + old_config_file = module.CONFIG_FILE + old_env_auth_key = module.os.environ.get("CHATGPT2API_AUTH_KEY") + try: + module.BASE_DIR = base_dir + module.DATA_DIR = data_dir + module.CONFIG_FILE = config_dir + module.os.environ["CHATGPT2API_AUTH_KEY"] = os_auth_key + + settings = module._load_settings() + + self.assertEqual(settings.auth_key, os_auth_key) + self.assertEqual(settings.refresh_account_interval_minute, 5) + finally: + module.BASE_DIR = old_base_dir + module.DATA_DIR = old_data_dir + module.CONFIG_FILE = old_config_file + if old_env_auth_key is None: + module.os.environ.pop("CHATGPT2API_AUTH_KEY", None) + else: + module.os.environ["CHATGPT2API_AUTH_KEY"] = old_env_auth_key + + +if __name__ == "__main__": + unittest.main() diff --git a/test/test_generations.py b/test/test_generations.py new file mode 100644 index 0000000000000000000000000000000000000000..4345dab6a2eb2de2047fbfaa5d9120087f6e3a41 --- /dev/null +++ b/test/test_generations.py @@ -0,0 +1,12 @@ +from test.utils import post_json, save_image + + +def main() -> None: + prompt = "A cute orange cat sitting on a chair" + result = post_json("/v1/images/generations", {"prompt": prompt, "model": "gpt-image-2", "n": 1}) + for index, item in enumerate(result["data"], start=1): + print(save_image(item["b64_json"], f"generations_{index}")) + + +if __name__ == "__main__": + main() diff --git a/test/test_generations_url.py b/test/test_generations_url.py new file mode 100644 index 0000000000000000000000000000000000000000..2f7a04f26982cd34d8ea2f58fceb206f32cbf81a --- /dev/null +++ b/test/test_generations_url.py @@ -0,0 +1,19 @@ +from test.utils import post_json + + +def main() -> None: + result = post_json( + "/v1/images/generations", + { + "prompt": "一只橘猫坐在窗边,午后阳光,写实摄影", + "model": "gpt-image-2", + "n": 1, + "response_format": "url", + }, + ) + for item in result.get("data", []): + print(item.get("url", "")) + + +if __name__ == "__main__": + main() diff --git a/test/test_image.py b/test/test_image.py new file mode 100644 index 0000000000000000000000000000000000000000..25ef8f2b0e96396c499f5f0edf3fa7b7a2b2b8a1 --- /dev/null +++ b/test/test_image.py @@ -0,0 +1,15 @@ +from __future__ import annotations + +from services.protocol import openai_v1_image_generations +from test.utils import save_image + + +def main() -> None: + prompt = "一只橘猫坐在窗台上,午后阳光,写实摄影" + data = openai_v1_image_generations.handle({"prompt": prompt, "model": "gpt-5-3", "n": 1}) + for index, item in enumerate(data["data"], start=1): + print(save_image(item["b64_json"], f"image_{index}")) + + +if __name__ == "__main__": + main() diff --git a/test/test_image_base_url_api.py b/test/test_image_base_url_api.py new file mode 100644 index 0000000000000000000000000000000000000000..c2ad577b83a0bf7dcb30b1880fb1614bd9afaf0e --- /dev/null +++ b/test/test_image_base_url_api.py @@ -0,0 +1,43 @@ +import unittest +from types import SimpleNamespace +from unittest import mock + +import api.support as api_support + + +class ImageBaseUrlApiTests(unittest.TestCase): + def setUp(self) -> None: + self.fake_config = SimpleNamespace(base_url="https://public.example.com") + patcher = mock.patch.object(api_support, "config", self.fake_config) + patcher.start() + self.addCleanup(patcher.stop) + + def test_prefers_configured_base_url(self) -> None: + request = SimpleNamespace( + url=SimpleNamespace(scheme="http", netloc="127.0.0.1:8000"), + headers={"host": "127.0.0.1:8000"}, + ) + + self.assertEqual(api_support.resolve_image_base_url(request), "https://public.example.com") + + def test_falls_back_to_request_host(self) -> None: + self.fake_config.base_url = "" + request = SimpleNamespace( + url=SimpleNamespace(scheme="http", netloc="127.0.0.1:8000"), + headers={"host": "internal.example:9000"}, + ) + + self.assertEqual(api_support.resolve_image_base_url(request), "http://internal.example:9000") + + def test_falls_back_to_request_netloc_when_host_missing(self) -> None: + self.fake_config.base_url = "" + request = SimpleNamespace( + url=SimpleNamespace(scheme="https", netloc="public.example.com"), + headers={}, + ) + + self.assertEqual(api_support.resolve_image_base_url(request), "https://public.example.com") + + +if __name__ == "__main__": + unittest.main() diff --git a/test/test_image_output_tokens.py b/test/test_image_output_tokens.py new file mode 100644 index 0000000000000000000000000000000000000000..71a8e5dbf97138de6531ca936171f412cc79bf08 --- /dev/null +++ b/test/test_image_output_tokens.py @@ -0,0 +1,33 @@ +import json +import urllib.error +import urllib.request + +from test.utils import BASE_URL, load_auth_key + + +def main() -> None: + payload = { + "prompt": "一只橘猫坐在窗台上,午后阳光,写实摄影", + "model": "gpt-image-2", + "n": 1, + "response_format": "url", + } + request = urllib.request.Request( + BASE_URL + "/v1/images/generations", + data=json.dumps(payload).encode(), + headers={"Content-Type": "application/json", "Authorization": f"Bearer {load_auth_key()}"}, + method="POST", + ) + try: + with urllib.request.urlopen(request) as response: + body = response.read().decode() + except urllib.error.HTTPError as error: + body = error.read().decode() + try: + print(json.dumps(json.loads(body), ensure_ascii=False, indent=2)) + except json.JSONDecodeError: + print(body) + + +if __name__ == "__main__": + main() diff --git a/test/test_image_storage_service.py b/test/test_image_storage_service.py new file mode 100644 index 0000000000000000000000000000000000000000..ea926de9f9569a8f1d4e6232aa80bb547919c9ef --- /dev/null +++ b/test/test_image_storage_service.py @@ -0,0 +1,139 @@ +from __future__ import annotations + +import tempfile +import unittest +from pathlib import Path +from unittest import mock + +from PIL import Image + +from services.image_storage_service import ImageStorageService + + +def png_bytes() -> bytes: + path = Path(tempfile.gettempdir()) / "chatgpt2api-test-image.png" + Image.new("RGB", (2, 2), color=(255, 0, 0)).save(path, format="PNG") + return path.read_bytes() + + +class FakeWebDAVClient: + uploaded: dict[str, bytes] = {} + deleted: list[str] = [] + + def __init__(self, _settings): + pass + + def put(self, rel: str, payload: bytes) -> str: + self.uploaded[rel] = payload + return f"https://dav.example.test/{rel}" + + def get(self, rel: str) -> bytes: + return self.uploaded[rel] + + def delete(self, rel: str) -> bool: + self.deleted.append(rel) + self.uploaded.pop(rel, None) + return True + + def test(self) -> dict[str, object]: + self.put(".chatgpt2api_webdav_test.txt", b"chatgpt2api webdav test\n") + self.delete(".chatgpt2api_webdav_test.txt") + return {"ok": True, "status": 200, "error": None} + + +class ImageStorageServiceTests(unittest.TestCase): + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + self.addCleanup(self.tmp.cleanup) + self.data_dir = Path(self.tmp.name) + self.images_dir = self.data_dir / "images" + self.settings = { + "enabled": False, + "mode": "local", + "webdav_url": "", + "webdav_username": "", + "webdav_password": "", + "webdav_root_path": "chatgpt2api/images", + "public_base_url": "", + } + self.config_patcher = mock.patch("services.image_storage_service.config") + self.mock_config = self.config_patcher.start() + self.addCleanup(self.config_patcher.stop) + self.mock_config.images_dir = self.images_dir + self.mock_config.base_url = "http://app.test" + self.mock_config.cleanup_old_images.return_value = 0 + self.mock_config.get_image_storage_settings.side_effect = lambda: dict(self.settings) + FakeWebDAVClient.uploaded = {} + FakeWebDAVClient.deleted = [] + + def service(self) -> ImageStorageService: + return ImageStorageService(self.data_dir / "image_index.json") + + def test_local_mode_saves_to_local_directory(self): + stored = self.service().save(png_bytes(), "http://app.test") + + self.assertEqual(stored.storage, "local") + self.assertTrue((self.images_dir / stored.rel).is_file()) + self.assertEqual(stored.url, f"http://app.test/images/{stored.rel}") + + def test_webdav_mode_uploads_without_local_file(self): + self.settings.update({ + "enabled": True, + "mode": "webdav", + "webdav_url": "https://dav.example.test", + "webdav_password": "secret", + }) + with mock.patch("services.image_storage_service.WebDAVClient", FakeWebDAVClient): + stored = self.service().save(png_bytes(), "http://app.test") + payload = self.service().get_bytes(stored.rel) + + self.assertEqual(stored.storage, "webdav") + self.assertFalse((self.images_dir / stored.rel).exists()) + self.assertIn(stored.rel, FakeWebDAVClient.uploaded) + self.assertEqual(payload, FakeWebDAVClient.uploaded[stored.rel]) + + def test_list_items_ignores_non_image_files(self): + image = png_bytes() + image_path = self.images_dir / "2026" / "05" / "07" / "sample.png" + image_path.parent.mkdir(parents=True, exist_ok=True) + image_path.write_bytes(image) + (self.images_dir / ".DS_Store").write_text("not an image", encoding="utf-8") + (self.images_dir / "2026" / ".DS_Store").write_text("not an image", encoding="utf-8") + + items = self.service().list_items("http://app.test") + + self.assertEqual([item["rel"] for item in items], ["2026/05/07/sample.png"]) + self.assertEqual(items[0]["storage"], "local") + + def test_both_mode_saves_to_local_and_webdav(self): + self.settings.update({ + "enabled": True, + "mode": "both", + "webdav_url": "https://dav.example.test", + "webdav_password": "secret", + "public_base_url": "https://cdn.example.test/images", + }) + with mock.patch("services.image_storage_service.WebDAVClient", FakeWebDAVClient): + stored = self.service().save(png_bytes(), "http://app.test") + + self.assertEqual(stored.storage, "both") + self.assertTrue((self.images_dir / stored.rel).is_file()) + self.assertIn(stored.rel, FakeWebDAVClient.uploaded) + self.assertEqual(stored.url, f"https://cdn.example.test/images/{stored.rel}") + + def test_test_webdav_writes_and_deletes_probe_file(self): + self.settings.update({ + "enabled": True, + "mode": "webdav", + "webdav_url": "https://dav.example.test", + "webdav_password": "secret", + }) + with mock.patch("services.image_storage_service.WebDAVClient", FakeWebDAVClient): + result = self.service().test_webdav() + + self.assertTrue(result["ok"]) + self.assertIn(".chatgpt2api_webdav_test.txt", FakeWebDAVClient.deleted) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/test_image_task_service.py b/test/test_image_task_service.py new file mode 100644 index 0000000000000000000000000000000000000000..566db0f8124250b77125adaf50678f0f99d8063d --- /dev/null +++ b/test/test_image_task_service.py @@ -0,0 +1,149 @@ +from __future__ import annotations + +import json +import tempfile +import time +import unittest +from pathlib import Path + +from services.image_task_service import ImageTaskService + + +OWNER = {"id": "owner-1", "name": "Owner", "role": "admin"} +OTHER_OWNER = {"id": "owner-2", "name": "Other", "role": "user"} + + +def wait_for_task(service: ImageTaskService, identity: dict[str, object], task_id: str, status: str, timeout: float = 2.0): + deadline = time.time() + timeout + last = None + while time.time() < deadline: + result = service.list_tasks(identity, [task_id]) + last = (result.get("items") or [None])[0] + if last and last.get("status") == status: + return last + time.sleep(0.02) + raise AssertionError(f"task {task_id} did not reach {status}, last={last}") + + +class ImageTaskServiceTests(unittest.TestCase): + def make_service(self, path: Path, handler=None) -> ImageTaskService: + return ImageTaskService( + path, + generation_handler=handler or (lambda _payload: {"data": [{"url": "http://example.test/image.png"}]}), + edit_handler=handler or (lambda _payload: {"data": [{"url": "http://example.test/edit.png"}]}), + retention_days_getter=lambda: 30, + ) + + def test_duplicate_submit_uses_existing_task(self): + with tempfile.TemporaryDirectory() as tmp_dir: + calls = 0 + + def handler(_payload): + nonlocal calls + calls += 1 + time.sleep(0.05) + return {"data": [{"url": "http://example.test/image.png"}]} + + service = self.make_service(Path(tmp_dir) / "image_tasks.json", handler) + first = service.submit_generation( + OWNER, + client_task_id="task-1", + prompt="cat", + model="gpt-image-2", + size=None, + base_url="http://local.test", + ) + second = service.submit_generation( + OWNER, + client_task_id="task-1", + prompt="cat", + model="gpt-image-2", + size=None, + base_url="http://local.test", + ) + + self.assertEqual(first["id"], "task-1") + self.assertEqual(second["id"], "task-1") + task = wait_for_task(service, OWNER, "task-1", "success") + self.assertEqual(task["data"][0]["url"], "http://example.test/image.png") + self.assertEqual(calls, 1) + + def test_different_owner_cannot_query_task(self): + with tempfile.TemporaryDirectory() as tmp_dir: + service = self.make_service(Path(tmp_dir) / "image_tasks.json") + service.submit_generation( + OWNER, + client_task_id="private-task", + prompt="cat", + model="gpt-image-2", + size=None, + base_url="http://local.test", + ) + + wait_for_task(service, OWNER, "private-task", "success") + result = service.list_tasks(OTHER_OWNER, ["private-task"]) + + self.assertEqual(result["items"], []) + self.assertEqual(result["missing_ids"], ["private-task"]) + + def test_success_task_persists_to_new_service_instance(self): + with tempfile.TemporaryDirectory() as tmp_dir: + path = Path(tmp_dir) / "image_tasks.json" + service = self.make_service(path) + service.submit_generation( + OWNER, + client_task_id="persisted-task", + prompt="cat", + model="gpt-image-2", + size=None, + base_url="http://local.test", + ) + wait_for_task(service, OWNER, "persisted-task", "success") + + reloaded = self.make_service(path) + result = reloaded.list_tasks(OWNER, ["persisted-task"]) + + self.assertEqual(result["missing_ids"], []) + self.assertEqual(result["items"][0]["status"], "success") + self.assertEqual(result["items"][0]["data"][0]["url"], "http://example.test/image.png") + + def test_startup_marks_unfinished_tasks_as_error(self): + with tempfile.TemporaryDirectory() as tmp_dir: + path = Path(tmp_dir) / "image_tasks.json" + path.write_text( + json.dumps( + { + "tasks": [ + { + "id": "queued-task", + "owner_id": "owner-1", + "status": "queued", + "mode": "generate", + "model": "gpt-image-2", + "created_at": "2099-01-01 00:00:00", + "updated_at": "2099-01-01 00:00:00", + }, + { + "id": "running-task", + "owner_id": "owner-1", + "status": "running", + "mode": "generate", + "model": "gpt-image-2", + "created_at": "2099-01-01 00:00:00", + "updated_at": "2099-01-01 00:00:00", + }, + ] + } + ), + encoding="utf-8", + ) + + service = self.make_service(path) + result = service.list_tasks(OWNER, ["queued-task", "running-task"]) + + self.assertEqual([item["status"] for item in result["items"]], ["error", "error"]) + self.assertTrue(all("已中断" in item.get("error", "") for item in result["items"])) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/test_image_tasks_api.py b/test/test_image_tasks_api.py new file mode 100644 index 0000000000000000000000000000000000000000..0afc4cb19d650648ec6d932a29054fb76b5d2cbe --- /dev/null +++ b/test/test_image_tasks_api.py @@ -0,0 +1,131 @@ +from __future__ import annotations + +import base64 +import unittest +from unittest import mock + +from fastapi import FastAPI +from fastapi.testclient import TestClient + +import api.image_tasks as image_tasks_module + + +AUTH_HEADERS = {"Authorization": "Bearer chatgpt2api"} +PNG_BYTES = b"\x89PNG\r\n\x1a\n" +DATA_IMAGE_URL = f"data:image/png;base64,{base64.b64encode(PNG_BYTES).decode('ascii')}" + + +class FakeImageTaskService: + def __init__(self): + self.generation_calls = [] + self.edit_calls = [] + + def submit_generation(self, identity, **kwargs): + self.generation_calls.append((identity, kwargs)) + return { + "id": kwargs["client_task_id"], + "status": "success", + "mode": "generate", + "created_at": "2026-01-01 00:00:00", + "updated_at": "2026-01-01 00:00:00", + "data": [{"url": f"{kwargs['base_url']}/images/fake.png"}], + } + + def submit_edit(self, identity, **kwargs): + self.edit_calls.append((identity, kwargs)) + return { + "id": kwargs["client_task_id"], + "status": "queued", + "mode": "edit", + "created_at": "2026-01-01 00:00:00", + "updated_at": "2026-01-01 00:00:00", + } + + def list_tasks(self, _identity, ids): + return { + "items": [ + { + "id": task_id, + "status": "success", + "mode": "generate", + "created_at": "2026-01-01 00:00:00", + "updated_at": "2026-01-01 00:00:00", + "data": [{"url": "http://testserver/images/fake.png"}], + } + for task_id in ids + if task_id != "missing" + ], + "missing_ids": [task_id for task_id in ids if task_id == "missing"], + } + + +class ImageTasksApiTests(unittest.TestCase): + def setUp(self): + self.fake_service = FakeImageTaskService() + self.service_patcher = mock.patch.object(image_tasks_module, "image_task_service", self.fake_service) + self.service_patcher.start() + self.addCleanup(self.service_patcher.stop) + app = FastAPI() + app.include_router(image_tasks_module.create_router()) + self.client = TestClient(app) + + def test_create_generation_task(self): + response = self.client.post( + "/api/image-tasks/generations", + headers=AUTH_HEADERS, + json={"client_task_id": "task-1", "prompt": "cat", "model": "gpt-image-2"}, + ) + + self.assertEqual(response.status_code, 200, response.text) + payload = response.json() + self.assertEqual(payload["id"], "task-1") + self.assertEqual(payload["status"], "success") + self.assertEqual(len(self.fake_service.generation_calls), 1) + + def test_create_edit_task_accepts_multiple_images(self): + """测试图片编辑任务接口支持多个上传图片。""" + response = self.client.post( + "/api/image-tasks/edits", + headers=AUTH_HEADERS, + data={"client_task_id": "edit-1", "prompt": "edit", "model": "gpt-image-2"}, + files=[ + ("image", ("one.png", b"one", "image/png")), + ("image", ("two.png", b"two", "image/png")), + ], + ) + + self.assertEqual(response.status_code, 200, response.text) + self.assertEqual(response.json()["id"], "edit-1") + self.assertEqual(len(self.fake_service.edit_calls), 1) + images = self.fake_service.edit_calls[0][1]["images"] + self.assertEqual(len(images), 2) + + def test_create_edit_task_accepts_image_url(self): + """测试图片编辑任务接口支持表单 image_url 引用。""" + response = self.client.post( + "/api/image-tasks/edits", + headers=AUTH_HEADERS, + data={ + "client_task_id": "edit-url-1", + "prompt": "edit", + "model": "gpt-image-2", + "image_url": DATA_IMAGE_URL, + }, + ) + + self.assertEqual(response.status_code, 200, response.text) + self.assertEqual(len(self.fake_service.edit_calls), 1) + images = self.fake_service.edit_calls[0][1]["images"] + self.assertEqual(images, [(PNG_BYTES, "image_url.png", "image/png")]) + + def test_list_tasks_reports_missing_ids(self): + response = self.client.get("/api/image-tasks?ids=task-1,missing", headers=AUTH_HEADERS) + + self.assertEqual(response.status_code, 200, response.text) + payload = response.json() + self.assertEqual([item["id"] for item in payload["items"]], ["task-1"]) + self.assertEqual(payload["missing_ids"], ["missing"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/test_image_tokens.py b/test/test_image_tokens.py new file mode 100644 index 0000000000000000000000000000000000000000..cdd2554f03be03be6a8fdd7f591bfc43da0e0b93 --- /dev/null +++ b/test/test_image_tokens.py @@ -0,0 +1,29 @@ +from __future__ import annotations + +import unittest + +from utils.image_tokens import ( + count_image_input_tokens, + count_image_output_tokens, +) + + +class ImageTokenTests(unittest.TestCase): + def test_patch_token_examples_match_openai_docs(self): + self.assertEqual(count_image_input_tokens(1024, 1024, "gpt-4.1-mini", "high"), 1659) + self.assertEqual(count_image_input_tokens(1800, 2400, "gpt-4.1-mini", "high"), 2353) + + def test_image_input_tokens_force_gpt_54_mini(self): + expected = count_image_input_tokens(1024, 1024, "gpt-5.4-mini", "low") + self.assertEqual(expected, 415) + self.assertEqual(count_image_input_tokens(1024, 1024, "gpt-4o", "low"), expected) + self.assertEqual(count_image_input_tokens(1024, 1024, "gpt-image-2", "low"), expected) + + def test_image_output_tokens_scale_by_count_and_size(self): + single = count_image_output_tokens("1024x1024", "auto", 1) + self.assertGreater(single, 0) + self.assertEqual(count_image_output_tokens("1024x1024", "auto", 2), single * 2) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/test_v1_chat_completions.py b/test/test_v1_chat_completions.py new file mode 100644 index 0000000000000000000000000000000000000000..2c9d9c6b53bcd32181d339f1b90bee3cfb29eec4 --- /dev/null +++ b/test/test_v1_chat_completions.py @@ -0,0 +1,125 @@ +from __future__ import annotations + +import json +import time +import unittest + +import requests + +from utils.helper import save_images_from_text + +AUTH_KEY = "chatgpt2api" +BASE_URL = "http://localhost:8000" + + +class ChatCompletionsTests(unittest.TestCase): + def test_text_completion_http(self): + """测试文本对话的非流式 HTTP 调用。""" + response = requests.post( + f"{BASE_URL}/v1/chat/completions", + headers={"Authorization": f"Bearer {AUTH_KEY}"}, + json={ + "model": "auto", + "messages": [ + {"role": "user", "content": "你好。"}, + {"role": "assistant", "content": "你好,我可以帮助你处理文本和图片相关请求。"}, + {"role": "user", "content": "那你再简单介绍一下你自己。"}, + ], + }, + timeout=300, + ) + print("text non-stream status:") + print(response.status_code) + print("text non-stream result:") + print(json.dumps(response.json(), ensure_ascii=False, indent=2)) + + def test_text_completion_stream_http(self): + """测试文本对话的流式 HTTP 调用。""" + response = requests.post( + f"{BASE_URL}/v1/chat/completions", + headers={"Authorization": f"Bearer {AUTH_KEY}"}, + json={ + "model": "auto", + "stream": True, + "messages": [ + {"role": "user", "content": "你好。"}, + {"role": "assistant", "content": "你好,我的名字是Claude。"}, + {"role": "user", "content": "那你再简单介绍一下你自己,比如你的名字是什么。"}, + ], + }, + stream=True, + timeout=300, + ) + print("text stream status:") + print(response.status_code) + print("text stream result:") + for line in response.iter_lines(): + if line: + print(line.decode("utf-8", errors="replace")) + + def test_image_completion_http(self): + """测试图片对话的非流式 HTTP 调用。""" + response = requests.post( + f"{BASE_URL}/v1/chat/completions", + headers={"Authorization": f"Bearer {AUTH_KEY}"}, + json={ + "model": "gpt-image-2", + "messages": [ + {"role": "user", "content": "我想做一张南京城市宣传海报图。"}, + ], + "n": 1, + }, + timeout=300, + ) + payload = response.json() + content = str((((payload.get("choices") or [{}])[0].get("message") or {}).get("content") or "")) + saved_paths = save_images_from_text(content, "chat_completions_image_non_stream") + print("image non-stream status:") + print(response.status_code) + print("image non-stream saved files:") + for path in saved_paths: + print(path) + + def test_image_completion_stream_http(self): + """测试图片对话的流式 HTTP 调用。""" + response = requests.post( + f"{BASE_URL}/v1/chat/completions", + headers={"Authorization": f"Bearer {AUTH_KEY}"}, + json={ + "model": "gpt-image-2", + "stream": True, + "messages": [ + {"role": "user", "content": "我想做一张南京城市宣传海报图。"}, + ], + "n": 1, + }, + stream=True, + timeout=300, + ) + parts: list[str] = [] + started_at = time.time() + print("image stream status:") + print(response.status_code) + print("image stream chunks:") + for line in response.iter_lines(): + if not line: + continue + text = line.decode("utf-8", errors="replace") + print(f"{time.time() - started_at:6.2f}s {text}") + if not text.startswith("data:"): + continue + payload = text[5:].strip() + if payload == "[DONE]": + break + try: + chunk = json.loads(payload) + except Exception: + continue + delta = ((chunk.get("choices") or [{}])[0].get("delta") or {}) + content = str(delta.get("content") or "") + if content: + parts.append(content) + saved_paths = save_images_from_text("".join(parts), "chat_completions_image_stream") + print("image stream saved files:") + for path in saved_paths: + print(path) diff --git a/test/test_v1_images_edits.py b/test/test_v1_images_edits.py new file mode 100644 index 0000000000000000000000000000000000000000..ee09106687cff47774ed92c1313a6b9d961f62f2 --- /dev/null +++ b/test/test_v1_images_edits.py @@ -0,0 +1,144 @@ +from __future__ import annotations + +import json +import time +import unittest +from pathlib import Path + +import requests + +from test.utils import save_image +from utils.log import logger + +AUTH_KEY = "chatgpt2api" +BASE_URL = "http://localhost:8000" +ASSETS_DIR = Path(__file__).resolve().parents[1] / "assets" + + +def load_asset_bytes(name: str) -> bytes: + return (ASSETS_DIR / name).read_bytes() + + +def summarize_chunk(chunk: dict[str, object]) -> dict[str, object]: + data = chunk.get("data") + data_items = data if isinstance(data, list) else [] + return { + "object": chunk.get("object"), + "index": chunk.get("index"), + "total": chunk.get("total"), + "created": chunk.get("created"), + "finish_reason": chunk.get("finish_reason"), + "progress_text": chunk.get("progress_text"), + "upstream_event_type": chunk.get("upstream_event_type"), + "data_count": len(data_items), + "has_b64_json": any(isinstance(item, dict) and bool(item.get("b64_json")) for item in data_items), + } + + +class ImageEditsTests(unittest.TestCase): + def test_image_edit_http(self): + """测试图片编辑的非流式 HTTP 调用。""" + response = requests.post( + f"{BASE_URL}/v1/images/edits", + headers={"Authorization": f"Bearer {AUTH_KEY}"}, + data={ + "model": "gpt-image-2", + "prompt": "参考输入图片,保持人物主体和二次元插画风格不变,让女孩怀里抱着一只可爱的小猫,画面自然协调。", + "n": "1", + "response_format": "b64_json", + }, + files={"image": ("chery_studio.png", load_asset_bytes("chery_studio.png"), "image/png")}, + timeout=300, + ) + self.assertEqual(response.status_code, 200, response.text) + payload = response.json() + saved_paths = [] + for index, item in enumerate(payload.get("data") or [], start=1): + b64_json = str((item or {}).get("b64_json") or "") + if b64_json: + saved_paths.append(save_image(b64_json, f"images_edits_non_stream_{index}")) + self.assertGreater(len(saved_paths), 0, "非流式接口未输出图片。") + logger.info({ + "event": "test_images_edits_non_stream_done", + "status_code": response.status_code, + "created": payload.get("created"), + "saved_paths": [str(path) for path in saved_paths], + "image_count": len(saved_paths), + }) + + def test_image_edit_stream_http(self): + """测试图片编辑的流式 HTTP 调用。""" + response = requests.post( + f"{BASE_URL}/v1/images/edits", + headers={"Authorization": f"Bearer {AUTH_KEY}"}, + data={ + "model": "gpt-image-2", + "prompt": "请提取两张输入界面截图中的 6 个任务,并把这 6 个任务整合排版到同一张图里,做成一张清晰的中文任务总览海报,标题明确,六个任务分区展示,版面整洁。", + "n": "1", + "response_format": "b64_json", + "stream": "true", + }, + files=[ + ("image", ("image.png", load_asset_bytes("image.png"), "image/png")), + ("image", ("image_edit.png", load_asset_bytes("image_edit.png"), "image/png")), + ], + stream=True, + timeout=300, + ) + image_items: list[dict[str, object]] = [] + stream_errors: list[dict[str, object]] = [] + started_at = time.time() + self.assertEqual(response.status_code, 200, response.text) + self.assertTrue( + response.headers.get("content-type", "").startswith("text/event-stream"), + response.headers.get("content-type", ""), + ) + logger.info({ + "event": "test_images_edits_stream_start", + "status_code": response.status_code, + "content_type": response.headers.get("content-type"), + }) + try: + for line in response.iter_lines(): + if not line: + continue + text = line.decode("utf-8", errors="replace") + if not text.startswith("data:"): + continue + payload = text[5:].strip() + if payload == "[DONE]": + break + try: + chunk = json.loads(payload) + except Exception: + continue + elapsed = time.time() - started_at + if isinstance(chunk.get("error"), dict): + stream_errors.append(chunk["error"]) + logger.info({ + "event": "test_images_edits_stream_chunk", + "elapsed_seconds": round(elapsed, 2), + "chunk": summarize_chunk(chunk), + }) + data = chunk.get("data") + if isinstance(data, list): + image_items.extend(item for item in data if isinstance(item, dict)) + finally: + response.close() + + saved_paths = [] + for index, item in enumerate(image_items, start=1): + b64_json = str(item.get("b64_json") or "") + if b64_json: + saved_paths.append(save_image(b64_json, f"images_edits_stream_{index}")) + self.assertFalse(stream_errors, f"流式接口返回错误: {stream_errors}") + self.assertGreater(len(saved_paths), 0, "流式接口未输出图片。") + logger.info({ + "event": "test_images_edits_stream_done", + "saved_paths": [str(path) for path in saved_paths], + "image_count": len(saved_paths), + }) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/test_v1_images_edits_api.py b/test/test_v1_images_edits_api.py new file mode 100644 index 0000000000000000000000000000000000000000..22e2027fb714fd522cceb9643d2e7224a0f387c7 --- /dev/null +++ b/test/test_v1_images_edits_api.py @@ -0,0 +1,72 @@ +from __future__ import annotations + +import base64 +import unittest +from unittest import mock + +from fastapi import FastAPI +from fastapi.testclient import TestClient + +import api.ai as ai_module + + +AUTH_HEADERS = {"Authorization": "Bearer chatgpt2api"} +PNG_BYTES = b"\x89PNG\r\n\x1a\n" +DATA_IMAGE_URL = f"data:image/png;base64,{base64.b64encode(PNG_BYTES).decode('ascii')}" + + +class ImagesEditsApiTests(unittest.TestCase): + def setUp(self): + self.handle_calls = [] + + def fake_handle(payload): + self.handle_calls.append(payload) + return {"created": 1, "data": [{"b64_json": base64.b64encode(b"out").decode("ascii")}]} + + self.handler_patcher = mock.patch.object(ai_module.openai_v1_image_edit, "handle", fake_handle) + self.handler_patcher.start() + self.addCleanup(self.handler_patcher.stop) + app = FastAPI() + app.include_router(ai_module.create_router()) + self.client = TestClient(app) + + def test_edit_accepts_json_image_url(self): + """测试图片编辑接口支持官方 JSON image_url 引用。""" + response = self.client.post( + "/v1/images/edits", + headers=AUTH_HEADERS, + json={ + "model": "gpt-image-2", + "prompt": "edit", + "images": [{"image_url": DATA_IMAGE_URL}], + "n": 1, + "response_format": "b64_json", + }, + ) + + self.assertEqual(response.status_code, 200, response.text) + self.assertEqual(len(self.handle_calls), 1) + payload = self.handle_calls[0] + self.assertEqual(payload["prompt"], "edit") + self.assertEqual(payload["n"], 1) + self.assertEqual(payload["images"], [(PNG_BYTES, "image_url.png", "image/png")]) + + def test_edit_rejects_file_id_reference(self): + """测试图片编辑接口对暂不支持的 file_id 返回明确错误。""" + response = self.client.post( + "/v1/images/edits", + headers=AUTH_HEADERS, + json={ + "model": "gpt-image-2", + "prompt": "edit", + "images": [{"file_id": "file-abc123"}], + }, + ) + + self.assertEqual(response.status_code, 400, response.text) + self.assertIn("file_id image references are not supported", response.text) + self.assertEqual(self.handle_calls, []) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/test_v1_images_edits_json.py b/test/test_v1_images_edits_json.py new file mode 100644 index 0000000000000000000000000000000000000000..c3d83502865cea60c93b3f3dc95f6587d93feb48 --- /dev/null +++ b/test/test_v1_images_edits_json.py @@ -0,0 +1,130 @@ +from __future__ import annotations + +import base64 +import os +import unittest +from unittest import mock + +os.environ.setdefault("CHATGPT2API_AUTH_KEY", "chatgpt2api") + +from fastapi import FastAPI +from fastapi.testclient import TestClient + +import api.ai as ai_module + +AUTH_HEADERS = {"Authorization": "Bearer chatgpt2api"} +PNG_DATA_URL = "data:image/png;base64," + base64.b64encode(b"fake-png").decode("ascii") +JPEG_DATA_URL = "data:image/jpeg;base64," + base64.b64encode(b"fake-jpeg").decode("ascii") + + +class ImageEditsJsonApiTests(unittest.TestCase): + def setUp(self): + self.calls = [] + + def fake_handle(payload): + self.calls.append(payload) + return {"created": 1, "data": [{"b64_json": "ZmFrZQ=="}]} + + self.handle_patcher = mock.patch.object(ai_module.openai_v1_image_edit, "handle", fake_handle) + self.filter_patcher = mock.patch.object(ai_module, "filter_or_log", mock.AsyncMock()) + self.handle_patcher.start() + self.filter_patcher.start() + self.addCleanup(self.handle_patcher.stop) + self.addCleanup(self.filter_patcher.stop) + + app = FastAPI() + app.include_router(ai_module.create_router()) + self.client = TestClient(app) + + def test_json_model_omitted_uses_existing_default_logic(self): + response = self.client.post("/v1/images/edits", headers=AUTH_HEADERS, json={"prompt": "未传 model", "image": PNG_DATA_URL}) + self.assertEqual(response.status_code, 200, response.text) + self.assertEqual(self.calls[0]["model"], "gpt-image-2") + + def test_json_model_is_not_overwritten_when_provided(self): + response = self.client.post( + "/v1/images/edits", + headers=AUTH_HEADERS, + json={"model": "codex-gpt-image-2", "prompt": "保留 model", "image": PNG_DATA_URL}, + ) + self.assertEqual(response.status_code, 200, response.text) + self.assertEqual(self.calls[0]["model"], "codex-gpt-image-2") + + def test_image_edit_accepts_json_image_url(self): + response = self.client.post( + "/v1/images/edits", + headers=AUTH_HEADERS, + json={ + "model": "gpt-image-2", + "prompt": "把图片改成夜景风格", + "n": 1, + "size": "1024x1536", + "response_format": "b64_json", + "images": [{"image_url": PNG_DATA_URL}], + }, + ) + self.assertEqual(response.status_code, 200, response.text) + payload = self.calls[0] + self.assertEqual(payload["images"], [(b"fake-png", "image_1.png", "image/png")]) + self.assertEqual(payload["size"], "1024x1536") + + def test_image_edit_accepts_json_multiple_images_and_b64_json(self): + response = self.client.post( + "/v1/images/edits", + headers=AUTH_HEADERS, + json={ + "prompt": "把两张图合成海报", + "images": [ + PNG_DATA_URL, + {"b64_json": base64.b64encode(b"raw-jpeg").decode("ascii"), "mime_type": "image/jpeg", "filename": "two.jpg"}, + {"image_url": {"url": JPEG_DATA_URL}}, + ], + }, + ) + self.assertEqual(response.status_code, 200, response.text) + self.assertEqual(self.calls[0]["images"], [ + (b"fake-png", "image_1.png", "image/png"), + (b"raw-jpeg", "two.jpg", "image/jpeg"), + (b"fake-jpeg", "image_3.jpg", "image/jpeg"), + ]) + + def test_image_edit_keeps_original_multipart_multiple_image_logic(self): + response = self.client.post( + "/v1/images/edits", + headers=AUTH_HEADERS, + data={"prompt": "multipart 多图仍然可用", "model": "gpt-image-2", "n": "1"}, + files=[ + ("image", ("one.png", b"one", "image/png")), + ("image", ("two.jpg", b"two", "image/jpeg")), + ("image[]", ("three.webp", b"three", "image/webp")), + ], + ) + self.assertEqual(response.status_code, 200, response.text) + self.assertEqual(self.calls[0]["images"], [ + (b"one", "one.png", "image/png"), + (b"two", "two.jpg", "image/jpeg"), + (b"three", "three.webp", "image/webp"), + ]) + + def test_image_edit_rejects_json_without_image(self): + response = self.client.post("/v1/images/edits", headers=AUTH_HEADERS, json={"prompt": "缺少图片"}) + self.assertEqual(response.status_code, 400, response.text) + self.assertIn("image file is required", response.text) + + def test_image_edit_rejects_remote_json_url(self): + response = self.client.post( + "/v1/images/edits", + headers=AUTH_HEADERS, + json={"prompt": "不允许远程拉图", "images": [{"image_url": "https://example.com/a.png"}]}, + ) + self.assertEqual(response.status_code, 400, response.text) + self.assertIn("remote image URLs are not supported", response.text) + + def test_image_edit_rejects_json_n_out_of_range(self): + response = self.client.post("/v1/images/edits", headers=AUTH_HEADERS, json={"prompt": "n 越界", "n": 5, "image": PNG_DATA_URL}) + self.assertEqual(response.status_code, 400, response.text) + self.assertFalse(self.calls) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/test_v1_images_generations.py b/test/test_v1_images_generations.py new file mode 100644 index 0000000000000000000000000000000000000000..980e16dc94e1f361ea30be7ceb22364648a8667a --- /dev/null +++ b/test/test_v1_images_generations.py @@ -0,0 +1,92 @@ +from __future__ import annotations + +import json +import time +import unittest + +import requests + +from test.utils import save_image + +AUTH_KEY = "chatgpt2api" +BASE_URL = "http://localhost:8000" + + +class ImageGenerationsTests(unittest.TestCase): + def test_image_generation_http(self): + """测试图片生成的非流式 HTTP 调用。""" + response = requests.post( + f"{BASE_URL}/v1/images/generations", + headers={"Authorization": f"Bearer {AUTH_KEY}"}, + json={ + "model": "gpt-image-2", + "prompt": "我想做一张南京城市宣传海报图。", + "n": 1, + "response_format": "b64_json", + }, + timeout=300, + ) + payload = response.json() + saved_paths = [] + for index, item in enumerate(payload.get("data") or [], start=1): + b64_json = str((item or {}).get("b64_json") or "") + if b64_json: + saved_paths.append(save_image(b64_json, f"images_generations_non_stream_{index}")) + print("images generations non-stream status:") + print(response.status_code) + print("images generations non-stream created:") + print(payload.get("created")) + print("images generations non-stream saved files:") + for path in saved_paths: + print(path) + + def test_image_generation_stream_http(self): + """测试图片生成的流式 HTTP 调用。""" + response = requests.post( + f"{BASE_URL}/v1/images/generations", + headers={"Authorization": f"Bearer {AUTH_KEY}"}, + json={ + "model": "gpt-image-2", + "prompt": "我想做一张南京城市宣传海报图。", + "n": 1, + "response_format": "b64_json", + "stream": True, + }, + stream=True, + timeout=300, + ) + image_items: list[dict[str, object]] = [] + started_at = time.time() + print("images generations stream status:") + print(response.status_code) + print("images generations stream chunks:") + for line in response.iter_lines(): + if not line: + continue + text = line.decode("utf-8", errors="replace") + print(f"{time.time() - started_at:6.2f}s {text}") + if not text.startswith("data:"): + continue + payload = text[5:].strip() + if payload == "[DONE]": + break + try: + chunk = json.loads(payload) + except Exception: + continue + data = chunk.get("data") + if isinstance(data, list): + image_items.extend(item for item in data if isinstance(item, dict)) + + saved_paths = [] + for index, item in enumerate(image_items, start=1): + b64_json = str(item.get("b64_json") or "") + if b64_json: + saved_paths.append(save_image(b64_json, f"images_generations_stream_{index}")) + print("images generations stream saved files:") + for path in saved_paths: + print(path) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/test_v1_messages.py b/test/test_v1_messages.py new file mode 100644 index 0000000000000000000000000000000000000000..e2194bb4c83a5346f578e28faf3ebb88fe356f10 --- /dev/null +++ b/test/test_v1_messages.py @@ -0,0 +1,86 @@ +from __future__ import annotations + +import json +import time +import unittest + +import requests + +AUTH_KEY = "chatgpt2api" +BASE_URL = "http://localhost:8000" +MODEL = "auto" + + +class AnthropicMessagesTests(unittest.TestCase): + @staticmethod + def _headers() -> dict[str, str]: + return { + "x-api-key": AUTH_KEY, + "anthropic-version": "2023-06-01", + } + + def test_message_http(self): + """测试 Anthropic Messages 的非流式 HTTP 调用。""" + response = requests.post( + f"{BASE_URL}/v1/messages", + headers=self._headers(), + json={ + "model": MODEL, + "messages": [ + {"role": "user", "content": "你好,请简单介绍一下你自己。"}, + ], + }, + timeout=300, + ) + print("messages non-stream status:") + print(response.status_code) + print("messages non-stream result:") + try: + print(json.dumps(response.json(), ensure_ascii=False, indent=2)) + except Exception: + print(response.text) + + def test_message_stream_http(self): + """测试 Anthropic Messages 的流式 HTTP 调用。""" + started_at = time.time() + response = requests.post( + f"{BASE_URL}/v1/messages", + headers=self._headers(), + json={ + "model": MODEL, + "stream": True, + "messages": [ + {"role": "user", "content": "你好,请简单介绍一下你自己。"}, + ], + }, + stream=True, + timeout=300, + ) + headers_at = time.time() + print("messages stream status:") + print(response.status_code) + print("messages stream content-type:") + print(response.headers.get("content-type", "")) + print("messages stream response headers:") + print(f"{headers_at - started_at:6.2f}s") + if response.status_code != 200: + print(response.text) + return + print("messages stream chunks:") + for line in response.iter_lines(chunk_size=1): + if not line: + continue + text = line.decode("utf-8", errors="replace") + print(f"{time.time() - started_at:6.2f}s {text}") + if not text.startswith("data:"): + continue + try: + payload = json.loads(text[5:].strip()) + except Exception: + continue + if payload.get("type") == "message_stop": + break + + +if __name__ == "__main__": + unittest.main() diff --git a/test/test_v1_models.py b/test/test_v1_models.py new file mode 100644 index 0000000000000000000000000000000000000000..3cd3d8535d9edd4d5307ba8255406dd070ac9823 --- /dev/null +++ b/test/test_v1_models.py @@ -0,0 +1,81 @@ +from __future__ import annotations + +import json +import unittest +from unittest import mock + +import requests + +from services.protocol import openai_v1_models + + +AUTH_KEY = "chatgpt2api" +BASE_URL = "http://localhost:8000" + + +class ModelListTests(unittest.TestCase): + def test_list_models_only_returns_image_models_backed_by_account_types(self): + with ( + mock.patch.object( + openai_v1_models.OpenAIBackendAPI, + "list_models", + return_value={"object": "list", "data": []}, + ), + mock.patch.object( + openai_v1_models.account_service, + "list_accounts", + return_value=[ + {"access_token": "token-free", "type": "free"}, + {"access_token": "token-web-team", "type": "Team", "source_type": "web"}, + {"access_token": "token-codex-team", "type": "Team", "source_type": "codex"}, + ], + ), + ): + result = openai_v1_models.list_models() + + ids = {item["id"] for item in result["data"]} + self.assertIn("gpt-image-2", ids) + self.assertIn("codex-gpt-image-2", ids) + self.assertIn("team-codex-gpt-image-2", ids) + self.assertNotIn("plus-codex-gpt-image-2", ids) + self.assertNotIn("pro-codex-gpt-image-2", ids) + + def test_list_models_does_not_return_codex_models_for_web_plus_accounts(self): + with ( + mock.patch.object( + openai_v1_models.OpenAIBackendAPI, + "list_models", + return_value={"object": "list", "data": []}, + ), + mock.patch.object( + openai_v1_models.account_service, + "list_accounts", + return_value=[ + {"access_token": "token-web-plus", "type": "Plus", "source_type": "web"}, + ], + ), + ): + result = openai_v1_models.list_models() + + ids = {item["id"] for item in result["data"]} + self.assertIn("gpt-image-2", ids) + self.assertNotIn("codex-gpt-image-2", ids) + self.assertNotIn("plus-codex-gpt-image-2", ids) + + def test_list_models_function(self): + """测试直接调用服务层获取模型列表。""" + result = openai_v1_models.list_models() + print("function result:") + print(json.dumps(result, ensure_ascii=False, indent=2)) + + def test_list_models_http(self): + """测试通过 HTTP 接口获取模型列表。""" + response = requests.get( + f"{BASE_URL}/v1/models", + headers={"Authorization": f"Bearer {AUTH_KEY}"}, + timeout=30, + ) + print("http status:") + print(response.status_code) + print("http result:") + print(json.dumps(response.json(), ensure_ascii=False, indent=2)) diff --git a/test/test_v1_responses.py b/test/test_v1_responses.py new file mode 100644 index 0000000000000000000000000000000000000000..a06bf7681980aae0bfc470ffd138445dc9425da0 --- /dev/null +++ b/test/test_v1_responses.py @@ -0,0 +1,294 @@ +from __future__ import annotations + +import json +import time +import unittest + +import requests + +from test.utils import save_image + +AUTH_KEY = "chatgpt2api" +BASE_URL = "http://localhost:8000" +TEXT_MODEL = "auto" +IMAGE_MODEL = "gpt-image-2" +CODEX_IMAGE_MODEL = "codex-gpt-image-2" + + +class ResponsesTests(unittest.TestCase): + @staticmethod + def _iter_sse_payloads(response: requests.Response): + for line in response.iter_lines(): + if not line: + continue + text = line.decode("utf-8", errors="replace") + yield text + + def test_text_response_http(self): + """测试 Responses 文本的非流式 HTTP 调用。""" + response = requests.post( + f"{BASE_URL}/v1/responses", + headers={"Authorization": f"Bearer {AUTH_KEY}"}, + json={ + "model": TEXT_MODEL, + "input": [ + { + "role": "user", + "content": [ + {"type": "input_text", "text": "你好,请简单介绍一下你自己。"}, + ], + } + ], + }, + timeout=300, + ) + self.assertEqual(response.status_code, 200, response.text) + print("responses text non-stream status:") + print(response.status_code) + print("responses text non-stream result:") + try: + payload = response.json() + print(json.dumps(payload, ensure_ascii=False, indent=2)) + self.assertEqual(payload.get("object"), "response") + self.assertEqual(payload.get("status"), "completed") + self.assertTrue(isinstance(payload.get("output"), list) and payload.get("output")) + except Exception: + print(response.text) + raise + + def test_text_response_stream_http(self): + """测试 Responses 文本的流式 HTTP 调用。""" + response = requests.post( + f"{BASE_URL}/v1/responses", + headers={"Authorization": f"Bearer {AUTH_KEY}"}, + json={ + "model": TEXT_MODEL, + "input": [ + { + "role": "user", + "content": [ + {"type": "input_text", "text": "你好,请简单介绍一下你自己。"}, + ], + } + ], + "stream": True, + }, + stream=True, + timeout=300, + ) + self.assertEqual(response.status_code, 200, response.text) + self.assertTrue( + response.headers.get("content-type", "").startswith("text/event-stream"), + response.headers.get("content-type", ""), + ) + started_at = time.time() + print("responses text stream status:") + print(response.status_code) + print("responses text stream chunks:") + event_types = [] + for text in self._iter_sse_payloads(response): + print(f"{time.time() - started_at:6.2f}s {text}") + if not text.startswith("data:"): + continue + payload_text = text[5:].strip() + if payload_text == "[DONE]": + break + try: + payload = json.loads(payload_text) + except Exception: + continue + event_type = str(payload.get("type") or "") + if event_type: + event_types.append(event_type) + self.assertIn("response.created", event_types) + self.assertIn("response.completed", event_types) + + def test_image_response_http(self): + """测试 Responses 画图的非流式 HTTP 调用。""" + response = requests.post( + f"{BASE_URL}/v1/responses", + headers={"Authorization": f"Bearer {AUTH_KEY}"}, + json={ + "model": IMAGE_MODEL, + "input": [ + { + "role": "user", + "content": [ + {"type": "input_text", "text": "我想做一张南京城市宣传海报图。"}, + ], + } + ], + "tools": [{"type": "image_generation"}], + }, + timeout=300, + ) + self.assertEqual(response.status_code, 200, response.text) + saved_paths = [] + try: + payload = response.json() + except Exception: + payload = {} + for index, item in enumerate(payload.get("output") or [], start=1): + if not isinstance(item, dict): + continue + image_b64 = str(item.get("result") or "") + if image_b64: + saved_paths.append(save_image(image_b64, f"responses_image_non_stream_{index}")) + print("responses image non-stream status:") + print(response.status_code) + print("responses image non-stream result:") + print(json.dumps(payload, ensure_ascii=False, indent=2)) + print("responses image non-stream saved files:") + for path in saved_paths: + print(path) + + def test_image_response_stream_http(self): + """测试 Responses 画图的流式 HTTP 调用。""" + response = requests.post( + f"{BASE_URL}/v1/responses", + headers={"Authorization": f"Bearer {AUTH_KEY}"}, + json={ + "model": IMAGE_MODEL, + "input": [ + { + "role": "user", + "content": [ + {"type": "input_text", "text": "我想做一张南京城市宣传海报图。"}, + ], + } + ], + "tools": [{"type": "image_generation"}], + "stream": True, + }, + stream=True, + timeout=300, + ) + self.assertEqual(response.status_code, 200, response.text) + self.assertTrue( + response.headers.get("content-type", "").startswith("text/event-stream"), + response.headers.get("content-type", ""), + ) + started_at = time.time() + saved_paths = [] + print("responses image stream status:") + print(response.status_code) + print("responses image stream chunks:") + for text in self._iter_sse_payloads(response): + print(f"{time.time() - started_at:6.2f}s {text}") + if not text.startswith("data:"): + continue + payload_text = text[5:].strip() + if payload_text == "[DONE]": + break + try: + payload = json.loads(payload_text) + except Exception: + continue + if payload.get("type") != "response.output_item.done": + continue + item = payload.get("item") or {} + if str(item.get("type") or "") != "image_generation_call": + continue + image_b64 = str(item.get("result") or "") + if image_b64: + saved_paths.append(save_image(image_b64, f"responses_image_stream_{len(saved_paths) + 1}")) + print("responses image stream saved files:") + for path in saved_paths: + print(path) + + def test_codex_image_response_http(self): + """测试 Responses 的 codex 画图非流式 HTTP 调用。""" + response = requests.post( + f"{BASE_URL}/v1/responses", + headers={"Authorization": f"Bearer {AUTH_KEY}"}, + json={ + "model": CODEX_IMAGE_MODEL, + "input": [ + { + "role": "user", + "content": [ + {"type": "input_text", "text": "我想做一张南京城市宣传海报图。"}, + ], + } + ], + "tools": [{"type": "image_generation"}], + }, + timeout=300, + ) + self.assertEqual(response.status_code, 200, response.text) + saved_paths = [] + try: + payload = response.json() + except Exception: + payload = {} + for index, item in enumerate(payload.get("output") or [], start=1): + if not isinstance(item, dict): + continue + image_b64 = str(item.get("result") or "") + if image_b64: + saved_paths.append(save_image(image_b64, f"responses_codex_image_non_stream_{index}")) + print("responses codex image non-stream status:") + print(response.status_code) + print("responses codex image non-stream result:") + print(json.dumps(payload, ensure_ascii=False, indent=2)) + print("responses codex image non-stream saved files:") + for path in saved_paths: + print(path) + + def test_codex_image_response_stream_http(self): + """测试 Responses 的 codex 画图流式 HTTP 调用。""" + response = requests.post( + f"{BASE_URL}/v1/responses", + headers={"Authorization": f"Bearer {AUTH_KEY}"}, + json={ + "model": CODEX_IMAGE_MODEL, + "input": [ + { + "role": "user", + "content": [ + {"type": "input_text", "text": "我想做一张南京城市宣传海报图。"}, + ], + } + ], + "tools": [{"type": "image_generation"}], + "stream": True, + }, + stream=True, + timeout=300, + ) + self.assertEqual(response.status_code, 200, response.text) + self.assertTrue( + response.headers.get("content-type", "").startswith("text/event-stream"), + response.headers.get("content-type", ""), + ) + started_at = time.time() + saved_paths = [] + print("responses codex image stream status:") + print(response.status_code) + print("responses codex image stream chunks:") + for text in self._iter_sse_payloads(response): + print(f"{time.time() - started_at:6.2f}s {text}") + if not text.startswith("data:"): + continue + payload_text = text[5:].strip() + if payload_text == "[DONE]": + break + try: + payload = json.loads(payload_text) + except Exception: + continue + if payload.get("type") != "response.output_item.done": + continue + item = payload.get("item") or {} + if str(item.get("type") or "") != "image_generation_call": + continue + image_b64 = str(item.get("result") or "") + if image_b64: + saved_paths.append(save_image(image_b64, f"responses_codex_image_stream_{len(saved_paths) + 1}")) + print("responses codex image stream saved files:") + for path in saved_paths: + print(path) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/utils.py b/test/utils.py new file mode 100644 index 0000000000000000000000000000000000000000..bfb5ba0f6e65325392f6cbfa55a55ca97f20653c --- /dev/null +++ b/test/utils.py @@ -0,0 +1,47 @@ +import base64 +import json +import sys +import time +import urllib.request +from pathlib import Path + + +ROOT_DIR = Path(__file__).resolve().parents[1] +OUTPUT_DIR = ROOT_DIR / "data" / "output" +BASE_URL = "http://127.0.0.1:8000" + +if str(ROOT_DIR) not in sys.path: + sys.path.insert(0, str(ROOT_DIR)) + + +def load_auth_key() -> str: + return json.loads((ROOT_DIR / "config.json").read_text(encoding="utf-8"))["auth-key"] + + +def post_json(path: str, payload: dict) -> dict: + request = urllib.request.Request( + BASE_URL + path, + data=json.dumps(payload).encode(), + headers={"Content-Type": "application/json", "Authorization": f"Bearer {load_auth_key()}"}, + method="POST", + ) + with urllib.request.urlopen(request) as response: + return json.loads(response.read().decode()) + + +def detect_ext(image_bytes: bytes) -> str: + if image_bytes.startswith(b"\xff\xd8\xff"): + return ".jpg" + if image_bytes.startswith(b"RIFF") and image_bytes[8:12] == b"WEBP": + return ".webp" + if image_bytes.startswith((b"GIF87a", b"GIF89a")): + return ".gif" + return ".png" + + +def save_image(image_b64: str, name: str) -> Path: + image_bytes = base64.b64decode(image_b64) + OUTPUT_DIR.mkdir(parents=True, exist_ok=True) + path = OUTPUT_DIR / f"{name}_{int(time.time())}{detect_ext(image_bytes)}" + path.write_bytes(image_bytes) + return path diff --git a/utils/__init__.py b/utils/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/utils/helper.py b/utils/helper.py new file mode 100644 index 0000000000000000000000000000000000000000..c990fd4fc09ddcb0b3b1bc4da4fd9d96e2a64562 --- /dev/null +++ b/utils/helper.py @@ -0,0 +1,396 @@ +import base64 +import hashlib +import json +import re +import time +import uuid +from pathlib import Path +from typing import Any, Iterator + +from curl_cffi import requests +from fastapi import HTTPException +from utils.log import logger + +BASE_IMAGE_MODELS = {"gpt-image-2", "codex-gpt-image-2"} +IMAGE_MODEL_PLAN_TYPES = ("plus", "team", "pro") +CODEX_IMAGE_MODEL = "codex-gpt-image-2" +PREFIXED_CODEX_IMAGE_MODELS = { + f"{plan_type}-{CODEX_IMAGE_MODEL}" + for plan_type in IMAGE_MODEL_PLAN_TYPES +} +IMAGE_MODELS = BASE_IMAGE_MODELS | PREFIXED_CODEX_IMAGE_MODELS +PUBLIC_IMAGE_MODELS = BASE_IMAGE_MODELS | PREFIXED_CODEX_IMAGE_MODELS +OUTPUT_DIR = Path(__file__).resolve().parent / "output" + +SUPPORTED_JSON_IMAGE_MIME_TYPES = {"image/png", "image/jpeg", "image/jpg", "image/webp", "image/gif"} +MAX_JSON_IMAGE_BYTES = 10 * 1024 * 1024 +MAX_JSON_EDIT_IMAGES = 10 +DATA_URL_IMAGE_RE = re.compile(r"^data:(?P<mime>[-+./\w]+);base64,(?P<data>.*)$", re.DOTALL) + + +def _image_extension(mime_type: str) -> str: + image_type = mime_type.split("/", 1)[1].split(";", 1)[0].lower() if "/" in mime_type else "png" + return "jpg" if image_type == "jpeg" else image_type or "png" + + +def _decode_json_image_string(value: str, index: int, filename: str | None = None, mime_type: str | None = None) -> tuple[bytes, str, str]: + text = value.strip() + if not text: + raise HTTPException(status_code=400, detail={"error": "image file is empty"}) + match = DATA_URL_IMAGE_RE.match(text) + if match: + resolved_mime = (match.group("mime") or "image/png").lower() + encoded = match.group("data") + else: + if text.startswith(("http://", "https://")): + raise HTTPException(status_code=400, detail={"error": "remote image URLs are not supported"}) + resolved_mime = (mime_type or "image/png").lower() + encoded = text + if resolved_mime == "image/jpg": + resolved_mime = "image/jpeg" + if resolved_mime not in SUPPORTED_JSON_IMAGE_MIME_TYPES: + raise HTTPException(status_code=400, detail={"error": "unsupported image mime type"}) + try: + image_data = base64.b64decode(encoded, validate=True) + except Exception as exc: + raise HTTPException(status_code=400, detail={"error": "invalid base64 image data"}) from exc + if not image_data: + raise HTTPException(status_code=400, detail={"error": "image file is empty"}) + if len(image_data) > MAX_JSON_IMAGE_BYTES: + raise HTTPException(status_code=400, detail={"error": "image file is too large"}) + return image_data, filename or f"image_{index}.{_image_extension(resolved_mime)}", resolved_mime + + +def _extract_json_image_value(item: object) -> tuple[str, str | None, str | None]: + if isinstance(item, str): + return item, None, None + if not isinstance(item, dict): + raise HTTPException(status_code=400, detail={"error": "image entry must be a base64 string or object"}) + filename = str(item.get("filename") or item.get("file_name") or "").strip() or None + mime_type = str(item.get("mime_type") or item.get("mimeType") or "").strip() or None + value = item.get("b64_json") or item.get("base64") + if not value: + image_url = item.get("image_url") or item.get("url") + if isinstance(image_url, dict): + filename = filename or str(image_url.get("filename") or image_url.get("file_name") or "").strip() or None + mime_type = mime_type or str(image_url.get("mime_type") or image_url.get("mimeType") or "").strip() or None + value = image_url.get("url") or image_url.get("image_url") + else: + value = image_url + if not isinstance(value, str) or not value.strip(): + raise HTTPException(status_code=400, detail={"error": "image entry must include image data"}) + return value, filename, mime_type + + +def normalize_json_edit_images(image: object = None, images: object = None) -> list[tuple[bytes, str, str]]: + raw_images = images if images is not None else image + if raw_images is None: + raise HTTPException(status_code=400, detail={"error": "image file is required"}) + entries = raw_images if isinstance(raw_images, list) else [raw_images] + if not entries: + raise HTTPException(status_code=400, detail={"error": "image file is required"}) + if len(entries) > MAX_JSON_EDIT_IMAGES: + raise HTTPException(status_code=400, detail={"error": f"images supports up to {MAX_JSON_EDIT_IMAGES} items"}) + normalized = [] + for index, item in enumerate(entries, start=1): + value, filename, mime_type = _extract_json_image_value(item) + normalized.append(_decode_json_image_string(value, index, filename, mime_type)) + return normalized + + +def new_uuid() -> str: + return str(uuid.uuid4()) + + +def split_image_model(model: object) -> tuple[str | None, str | None]: + normalized = str(model or "").strip().lower() + if not normalized: + return None, None + if normalized in BASE_IMAGE_MODELS: + return None, normalized + for plan_type in IMAGE_MODEL_PLAN_TYPES: + prefix = f"{plan_type}-" + if normalized.startswith(prefix): + base_model = normalized[len(prefix):] + if base_model == CODEX_IMAGE_MODEL: + return plan_type, base_model + return None, None + + +def is_supported_image_model(model: object) -> bool: + _, base_model = split_image_model(model) + return base_model is not None + + +def is_codex_image_model(model: object) -> bool: + _, base_model = split_image_model(model) + return base_model == CODEX_IMAGE_MODEL + + +def is_image_chat_request(body: dict[str, object]) -> bool: + model = str(body.get("model") or "").strip() + modalities = body.get("modalities") + if is_supported_image_model(model): + return True + return isinstance(modalities, list) and "image" in {str(item or "").strip().lower() for item in modalities} + + +_UPSTREAM_BODY_LOG_LIMIT = 500 + + +class UpstreamHTTPError(RuntimeError): + """Raised when an upstream HTTP call returns a non-2xx status. + + Carries structured fields (status_code, body, retry_after) so callers can + branch on status code instead of string-matching on str(exc). The full + body is preserved on the instance; the formatted message truncates it + to keep log lines reasonable. + """ + + def __init__( + self, + context: str, + status_code: int, + body: Any, + retry_after: int | None = None, + ) -> None: + self.context = context + self.status_code = status_code + self.body = body + self.retry_after = retry_after + if isinstance(body, (dict, list)): + try: + body_str = json.dumps(body, ensure_ascii=False) + except (TypeError, ValueError): + body_str = repr(body) + else: + body_str = str(body) + if len(body_str) > _UPSTREAM_BODY_LOG_LIMIT: + body_str = body_str[:_UPSTREAM_BODY_LOG_LIMIT] + "…[truncated]" + super().__init__(f"{context} failed: status={status_code}, body={body_str}") + + +def ensure_ok(response: requests.Response, context: str) -> None: + if 200 <= response.status_code < 300: + return + body: Any = response.text + try: + body = response.json() + except Exception: + pass + retry_after_header = response.headers.get("Retry-After") if hasattr(response, "headers") else None + retry_after: int | None = None + if retry_after_header is not None: + ra_str = str(retry_after_header).strip() + if ra_str.isdigit(): + retry_after = int(ra_str) + raise UpstreamHTTPError(context, response.status_code, body, retry_after=retry_after) + + +def sse_json_stream(items) -> Iterator[str]: + yield ": stream-open\n\n" + try: + for item in items: + yield f"data: {json.dumps(item, ensure_ascii=False)}\n\n" + except Exception as exc: + logger.warning({ + "event": "sse_stream_error", + "error_type": exc.__class__.__name__, + "error": str(exc), + }) + error = exc.to_openai_error() if hasattr(exc, "to_openai_error") else { + "error": {"message": str(exc), "type": exc.__class__.__name__} + } + yield f"data: {json.dumps(error, ensure_ascii=False)}\n\n" + yield "data: [DONE]\n\n" + + +def anthropic_sse_stream(items) -> Iterator[str]: + try: + for item in items: + event = str(item.get("type") or "message_delta") if isinstance(item, dict) else "message_delta" + yield f"event: {event}\n" + yield f"data: {json.dumps(item, ensure_ascii=False)}\n\n" + except Exception as exc: + logger.warning({ + "event": "anthropic_sse_stream_error", + "error_type": exc.__class__.__name__, + "error": str(exc), + }) + error = {"type": "error", "error": {"type": exc.__class__.__name__, "message": str(exc)}} + yield "event: error\n" + yield f"data: {json.dumps(error, ensure_ascii=False)}\n\n" + + +def iter_sse_payloads(response: requests.Response) -> Iterator[str]: + for raw_line in response.iter_lines(): + if not raw_line: + continue + line = raw_line.decode("utf-8", errors="ignore") if isinstance(raw_line, bytes) else str(raw_line) + if not line.startswith("data:"): + continue + payload = line[5:].strip() + if payload: + yield payload + + +def save_images_from_text(text: str, prefix: str) -> list[Path]: + OUTPUT_DIR.mkdir(parents=True, exist_ok=True) + matches = re.findall(r"data:image/[^;]+;base64,[A-Za-z0-9+/=]+", text or "") + saved_paths: list[Path] = [] + timestamp = int(time.time() * 1000) + for index, data_url in enumerate(matches, start=1): + header, encoded = data_url.split(",", 1) + image_type = header.split(";")[0].removeprefix("data:image/").strip() or "png" + extension = "jpg" if image_type == "jpeg" else image_type + output_path = OUTPUT_DIR / f"{prefix}_{timestamp}_{index}.{extension}" + output_path.write_bytes(base64.b64decode(encoded)) + saved_paths.append(output_path) + return saved_paths + + +def anonymize_token(token: object) -> str: + value = str(token or "").strip() + if not value: + return "token:empty" + digest = hashlib.sha256(value.encode("utf-8")).hexdigest()[:10] + return f"token:{digest}" + + +def extract_response_prompt(input_value: object) -> str: + if isinstance(input_value, str): + return input_value.strip() + if isinstance(input_value, dict): + role = str(input_value.get("role") or "").strip().lower() + if role and role != "user": + return "" + return extract_prompt_from_message_content(input_value.get("content")) + if not isinstance(input_value, list): + return "" + prompt_parts: list[str] = [] + for item in input_value: + if isinstance(item, dict) and str(item.get("type") or "").strip() == "input_text": + text = str(item.get("text") or "").strip() + if text: + prompt_parts.append(text) + continue + if not isinstance(item, dict): + continue + role = str(item.get("role") or "").strip().lower() + if role and role != "user": + continue + prompt = extract_prompt_from_message_content(item.get("content")) + if prompt: + prompt_parts.append(prompt) + return "\n".join(prompt_parts).strip() + + +def has_response_image_generation_tool(body: dict[str, object]) -> bool: + tools = body.get("tools") + if isinstance(tools, list): + for tool in tools: + if isinstance(tool, dict) and str(tool.get("type") or "").strip() == "image_generation": + return True + tool_choice = body.get("tool_choice") + return isinstance(tool_choice, dict) and str(tool_choice.get("type") or "").strip() == "image_generation" + + +def extract_prompt_from_message_content(content: object) -> str: + if isinstance(content, str): + return content.strip() + if not isinstance(content, list): + return "" + parts: list[str] = [] + for item in content: + if not isinstance(item, dict): + continue + item_type = str(item.get("type") or "").strip() + if item_type == "text": + text = str(item.get("text") or "").strip() + if text: + parts.append(text) + elif item_type == "input_text": + text = str(item.get("text") or item.get("input_text") or "").strip() + if text: + parts.append(text) + return "\n".join(parts).strip() + + +def extract_image_from_message_content(content: object) -> list[tuple[bytes, str]]: + if not isinstance(content, list): + return [] + images = [] + for item in content: + if not isinstance(item, dict): + continue + item_type = str(item.get("type") or "").strip() + if item_type == "image_url": + url_obj = item.get("image_url") or item + url = str(url_obj.get("url") or "") if isinstance(url_obj, dict) else str(url_obj) + if url.startswith("data:"): + header, _, data = url.partition(",") + mime = header.split(";")[0].removeprefix("data:") + images.append((base64.b64decode(data), mime or "image/png")) + elif item_type == "input_image": + image_url = str(item.get("image_url") or "") + if image_url.startswith("data:"): + header, _, data = image_url.partition(",") + mime = header.split(";")[0].removeprefix("data:") + images.append((base64.b64decode(data), mime or "image/png")) + return images + + +def extract_chat_image(body: dict[str, object]) -> list[tuple[bytes, str]]: + messages = body.get("messages") + if not isinstance(messages, list): + return [] + for message in reversed(messages): + if not isinstance(message, dict): + continue + if str(message.get("role") or "").strip().lower() != "user": + continue + images = extract_image_from_message_content(message.get("content")) + if images: + return images + return [] + + +def extract_chat_prompt(body: dict[str, object]) -> str: + direct_prompt = str(body.get("prompt") or "").strip() + if direct_prompt: + return direct_prompt + messages = body.get("messages") + if not isinstance(messages, list): + return "" + prompt_parts: list[str] = [] + for message in messages: + if not isinstance(message, dict): + continue + if str(message.get("role") or "").strip().lower() != "user": + continue + prompt = extract_prompt_from_message_content(message.get("content")) + if prompt: + prompt_parts.append(prompt) + return "\n".join(prompt_parts).strip() + + +def parse_image_count(raw_value: object) -> int: + try: + value = int(raw_value or 1) + except (TypeError, ValueError) as exc: + raise HTTPException(status_code=400, detail={"error": "n must be an integer"}) from exc + if value < 1 or value > 4: + raise HTTPException(status_code=400, detail={"error": "n must be between 1 and 4"}) + return value + + +def build_chat_image_markdown_content(image_result: dict[str, object]) -> str: + image_items = image_result.get("data") if isinstance(image_result.get("data"), list) else [] + markdown_images: list[str] = [] + for index, item in enumerate(image_items, start=1): + if not isinstance(item, dict): + continue + b64_json = str(item.get("b64_json") or "").strip() + if b64_json: + markdown_images.append(f"![image_{index}](data:image/png;base64,{b64_json})") + return "\n\n".join(markdown_images) if markdown_images else "Image generation completed." diff --git a/utils/image_tokens.py b/utils/image_tokens.py new file mode 100644 index 0000000000000000000000000000000000000000..3ad27b20f733cac4eed34f508d9f9cdb46b37f35 --- /dev/null +++ b/utils/image_tokens.py @@ -0,0 +1,346 @@ +from __future__ import annotations + +import base64 +import math +import re +from io import BytesIO +from typing import Any + +from PIL import Image + +DEFAULT_IMAGE_SIZE = (1024, 1024) +IMAGE_INPUT_TOKEN_MODEL = "gpt-5.4-mini" + +PATCH_SIZE = 32 +TILE_SIZE = 512 +TILE_HIGH_SHORT_SIDE = 768 + +PATCH_1536_MODELS = ( + "gpt-5.4-mini", + "gpt-5.4-nano", + "gpt-5-mini", + "gpt-5-nano", + "gpt-5.2", + "gpt-5.3-codex", + "gpt-5-codex-mini", + "gpt-5.1-codex-mini", + "gpt-5.2-codex", + "gpt-5.2-chat-latest", + "o4-mini", + "gpt-4.1-mini", + "gpt-4.1-nano", +) + +PATCH_MULTIPLIERS = { + "gpt-5.4-mini": 1.62, + "gpt-5.4-nano": 2.46, + "gpt-5-mini": 1.62, + "gpt-5-nano": 2.46, + "gpt-4.1-mini": 1.62, + "gpt-4.1-nano": 2.46, + "o4-mini": 1.72, +} + + +def _model_name(model: str) -> str: + return str(model or "").strip().lower() + + +def image_size_from_bytes(data: bytes) -> tuple[int, int] | None: + if not data: + return None + try: + with Image.open(BytesIO(data)) as image: + width, height = image.size + except Exception: + return None + if width <= 0 or height <= 0: + return None + return int(width), int(height) + + +def _decode_data_url(value: str) -> bytes: + text = str(value or "").strip() + payload = text.split(",", 1)[1] if text.startswith("data:") and "," in text else text + return base64.b64decode(payload) + + +def image_size_from_data_url(value: str) -> tuple[int, int] | None: + try: + return image_size_from_bytes(_decode_data_url(value)) + except Exception: + return None + + +def parse_image_size(size: object, default: tuple[int, int] = DEFAULT_IMAGE_SIZE) -> tuple[int, int]: + if isinstance(size, (tuple, list)) and len(size) >= 2: + try: + width = int(size[0]) + height = int(size[1]) + if width > 0 and height > 0: + return width, height + except (TypeError, ValueError): + pass + match = re.search(r"(\d{2,5})\D+(\d{2,5})", str(size or "")) + if not match: + return default + width, height = int(match.group(1)), int(match.group(2)) + return (width, height) if width > 0 and height > 0 else default + + +def _patch_count(width: float, height: float) -> int: + return math.ceil(width / PATCH_SIZE) * math.ceil(height / PATCH_SIZE) + + +def _patch_multiplier(model: str) -> float: + name = _model_name(model) + for prefix, multiplier in PATCH_MULTIPLIERS.items(): + if name.startswith(prefix): + return multiplier + return 1.0 + + +def _patch_limits(model: str, detail: str) -> tuple[int, int] | None: + name = _model_name(model) + if any(name.startswith(prefix) for prefix in PATCH_1536_MODELS): + return 1536, 2048 + if name.startswith("gpt-5.5"): + return (10000, 6000) if detail in {"auto", "original"} else (2500, 2048) + if name.startswith("gpt-5.4"): + return (10000, 6000) if detail == "original" else (2500, 2048) + return None + + +def _patch_tokens(width: int, height: int, model: str, detail: str) -> int: + multiplier = _patch_multiplier(model) + if detail == "low": + return math.ceil(256 * multiplier) + + limits = _patch_limits(model, detail) + if limits is None: + return 0 + patch_budget, max_dimension = limits + scale = min(1.0, max_dimension / max(width, height)) + resized_width = width * scale + resized_height = height * scale + + if _patch_count(resized_width, resized_height) > patch_budget: + shrink_factor = math.sqrt((PATCH_SIZE * PATCH_SIZE * patch_budget) / (resized_width * resized_height)) + width_units = resized_width * shrink_factor / PATCH_SIZE + height_units = resized_height * shrink_factor / PATCH_SIZE + adjusted_shrink_factor = shrink_factor * min( + math.floor(width_units) / width_units if width_units else 1, + math.floor(height_units) / height_units if height_units else 1, + ) + resized_width *= adjusted_shrink_factor + resized_height *= adjusted_shrink_factor + + tokens = min(_patch_count(max(1, resized_width), max(1, resized_height)), patch_budget) + return math.ceil(tokens * multiplier) + + +def _tile_rates(model: str) -> tuple[int, int]: + name = _model_name(model) + if name in {"gpt-5", "gpt-5-chat-latest"}: + return 70, 140 + if name.startswith("gpt-4o-mini"): + return 2833, 5667 + if name.startswith(("o1", "o1-pro", "o3")): + return 75, 150 + if name.startswith("computer-use-preview"): + return 65, 129 + return 85, 170 + + +def _tile_tokens(width: int, height: int, model: str, detail: str) -> int: + base_tokens, tile_tokens = _tile_rates(model) + if detail == "low": + return base_tokens + + scale = min(1.0, 2048 / max(width, height)) + resized_width = width * scale + resized_height = height * scale + short_side = min(resized_width, resized_height) + if short_side > 0: + scale = TILE_HIGH_SHORT_SIDE / short_side + resized_width *= scale + resized_height *= scale + + tiles = math.ceil(resized_width / TILE_SIZE) * math.ceil(resized_height / TILE_SIZE) + return base_tokens + tiles * tile_tokens + + +def count_image_input_tokens( + width: int, + height: int, + model: str, + detail: str = "auto", + input_fidelity: str = "low", +) -> int: + if width <= 0 or height <= 0: + return 0 + detail = str(detail or "auto").strip().lower() or "auto" + return _patch_tokens(width, height, IMAGE_INPUT_TOKEN_MODEL, detail) + + +def _part_size(part: dict[str, Any]) -> tuple[int, int] | None: + try: + width = int(part.get("width") or 0) + height = int(part.get("height") or 0) + except (TypeError, ValueError): + width = height = 0 + if width > 0 and height > 0: + return width, height + + data = part.get("data") + if isinstance(data, (bytes, bytearray)): + return image_size_from_bytes(bytes(data)) + + image_url = part.get("image_url") + if isinstance(image_url, dict): + image_url = image_url.get("url") or image_url.get("image_url") + if isinstance(image_url, str) and image_url.startswith("data:"): + return image_size_from_data_url(image_url) + + source = part.get("source") + if isinstance(source, dict) and str(source.get("type") or "") == "base64": + try: + return image_size_from_bytes(base64.b64decode(str(source.get("data") or ""))) + except Exception: + return None + return None + + +def count_image_content_tokens(content: object, model: str, default_detail: str = "auto") -> int: + if not isinstance(content, list): + return 0 + total = 0 + for part in content: + if not isinstance(part, dict): + continue + part_type = str(part.get("type") or "").strip() + if part_type not in {"image", "image_url", "input_image"} and not part.get("source"): + continue + size = _part_size(part) + if not size: + continue + total += count_image_input_tokens( + size[0], + size[1], + model, + str(part.get("detail") or default_detail or "auto"), + str(part.get("input_fidelity") or part.get("inputFidelity") or "low"), + ) + return total + + +def count_image_inputs_tokens(images: object, model: str, default_detail: str = "auto") -> int: + if not images: + return 0 + total = 0 + entries = images if isinstance(images, list) else [images] + for image in entries: + data = image[0] if isinstance(image, tuple) and image else image + if not isinstance(data, (bytes, bytearray)): + continue + size = image_size_from_bytes(bytes(data)) + if size: + total += count_image_input_tokens(size[0], size[1], model, default_detail) + return total + + +def count_generated_image_tokens(width: int, height: int, quality: str = "auto") -> int: + patches = _patch_count(width, height) + quality = str(quality or "auto").strip().lower() + if quality == "low": + return math.ceil(patches * 17 / 64) + if quality in {"high", "hd"}: + return math.ceil(patches * 65 / 16) + return math.ceil(patches * 33 / 32) + + +def count_image_output_tokens(size: object = None, quality: str = "auto", count: int = 1) -> int: + width, height = parse_image_size(size) + return max(0, int(count or 0)) * count_generated_image_tokens(width, height, quality) + + +def count_image_output_items_tokens( + items: object, + size: object = None, + quality: str = "auto", +) -> int: + if not isinstance(items, list) or not items: + return 0 + fallback_size = parse_image_size(size) + total = 0 + for item in items: + image_size = None + if isinstance(item, dict): + b64_json = str(item.get("b64_json") or "").strip() + if b64_json: + try: + image_size = image_size_from_bytes(base64.b64decode(b64_json)) + except Exception: + image_size = None + width, height = image_size or fallback_size + total += count_generated_image_tokens(width, height, quality) + return total + + +def token_usage( + input_text_tokens: int = 0, + input_image_tokens: int = 0, + output_text_tokens: int = 0, + output_image_tokens: int = 0, +) -> dict[str, Any]: + input_tokens = max(0, int(input_text_tokens or 0)) + max(0, int(input_image_tokens or 0)) + output_tokens = max(0, int(output_text_tokens or 0)) + max(0, int(output_image_tokens or 0)) + return { + "input_tokens": input_tokens, + "output_tokens": output_tokens, + "total_tokens": input_tokens + output_tokens, + "input_tokens_details": { + "text_tokens": max(0, int(input_text_tokens or 0)), + "image_tokens": max(0, int(input_image_tokens or 0)), + "cached_tokens": 0, + }, + "output_tokens_details": { + "text_tokens": max(0, int(output_text_tokens or 0)), + "image_tokens": max(0, int(output_image_tokens or 0)), + "reasoning_tokens": 0, + }, + } + + +def image_usage( + input_text_tokens: int = 0, + input_image_tokens: int = 0, + output_tokens: int = 0, +) -> dict[str, Any]: + return token_usage( + input_text_tokens=input_text_tokens, + input_image_tokens=input_image_tokens, + output_image_tokens=output_tokens, + ) + + +def chat_usage_from_image_usage(usage: dict[str, Any]) -> dict[str, Any]: + input_tokens = int(usage.get("input_tokens") or 0) + output_tokens = int(usage.get("output_tokens") or 0) + input_details = usage.get("input_tokens_details") if isinstance(usage.get("input_tokens_details"), dict) else {} + output_details = usage.get("output_tokens_details") if isinstance(usage.get("output_tokens_details"), dict) else {} + return { + "prompt_tokens": input_tokens, + "completion_tokens": output_tokens, + "total_tokens": input_tokens + output_tokens, + "prompt_tokens_details": { + "text_tokens": int(input_details.get("text_tokens") or 0), + "image_tokens": int(input_details.get("image_tokens") or 0), + "cached_tokens": int(input_details.get("cached_tokens") or 0), + }, + "completion_tokens_details": { + "text_tokens": int(output_details.get("text_tokens") or 0), + "image_tokens": int(output_details.get("image_tokens") or 0), + "reasoning_tokens": int(output_details.get("reasoning_tokens") or 0), + }, + } diff --git a/utils/log.py b/utils/log.py new file mode 100644 index 0000000000000000000000000000000000000000..5532dde6c7493af19bb29e055b1e8dceef60a6ee --- /dev/null +++ b/utils/log.py @@ -0,0 +1,110 @@ +import base64 +import binascii +import json +import logging +import re +from typing import Any + + +class Logger: + _DATA_URL_RE = re.compile(r"data:image/[^;]+;base64,[A-Za-z0-9+/=]+") + _JSON_B64_RE = re.compile(r'("b64_json"\s*:\s*")([A-Za-z0-9+/=]+)(")') + + def __init__(self, name: str = "chatgpt2api") -> None: + self._logger = logging.getLogger(name) + if not self._logger.handlers: + handler = logging.StreamHandler() + handler.setFormatter(logging.Formatter("[%(levelname)s] %(message)s")) + self._logger.addHandler(handler) + self._logger.setLevel(logging.DEBUG) + self._logger.propagate = False + + def _enabled(self, level: str) -> bool: + try: + from services.config import config + levels = set(config.log_levels) + except Exception: + levels = set() + return level in (levels or {"info", "warning", "error"}) + + def _mask_string(self, value: str, keep: int = 10) -> str: + if len(value) <= keep: + return value + return value[:keep] + "..." + + def _mask_base64(self, value: str) -> str: + if value.startswith("data:") and ";base64," in value: + header, _, data = value.partition(",") + return f"{header},{self._mask_string(data, 24)} (base64 len={len(data)})" + return f"{self._mask_string(value, 24)} (base64 len={len(value)})" + + def _is_base64_string(self, value: str) -> bool: + if len(value) < 64 or len(value) % 4 != 0: + return False + if not any(char in value for char in "+/="): + return False + try: + base64.b64decode(value, validate=True) + return True + except (binascii.Error, ValueError): + return False + + def _sanitize_string(self, value: str) -> str: + stripped = value.strip() + if stripped.startswith("data:") and ";base64," in stripped: + return self._mask_base64(stripped) + if self._is_base64_string(stripped): + return self._mask_base64(stripped) + sanitized = self._DATA_URL_RE.sub(lambda match: self._mask_base64(match.group(0)), value) + sanitized = self._JSON_B64_RE.sub( + lambda match: f'{match.group(1)}{self._mask_base64(match.group(2))}{match.group(3)}', + sanitized, + ) + if sanitized != value: + return sanitized + return value + + def _sanitize(self, value: Any) -> Any: + if isinstance(value, dict): + sanitized = {} + for key, item in value.items(): + lowered_key = key.lower() + if isinstance(item, str) and ("token" in lowered_key or lowered_key == "dx"): + sanitized[key] = self._mask_string(item) + elif isinstance(item, str) and ("base64" in lowered_key or lowered_key == "b64_json"): + sanitized[key] = self._mask_base64(item) + else: + sanitized[key] = self._sanitize(item) + return sanitized + if isinstance(value, list): + return [self._sanitize(item) for item in value] + if isinstance(value, tuple): + return tuple(self._sanitize(item) for item in value) + if isinstance(value, str): + return self._sanitize_string(value) + return value + + def _message(self, value: Any) -> str: + sanitized = self._sanitize(value) + if isinstance(sanitized, str): + return sanitized + return json.dumps(sanitized, ensure_ascii=False, default=str) + + def debug(self, message: Any) -> None: + if self._enabled("debug"): + self._logger.debug(self._message(message)) + + def info(self, message: Any) -> None: + if self._enabled("info"): + self._logger.info(self._message(message)) + + def warning(self, message: Any) -> None: + if self._enabled("warning"): + self._logger.warning(self._message(message)) + + def error(self, message: Any) -> None: + if self._enabled("error"): + self._logger.error(self._message(message)) + + +logger = Logger() diff --git a/utils/pow.py b/utils/pow.py new file mode 100644 index 0000000000000000000000000000000000000000..fcbbb90b3fc4fff695f1dfb5fe58bd069c46ba30 --- /dev/null +++ b/utils/pow.py @@ -0,0 +1,204 @@ +import hashlib +import json +import random +import re +import time +from datetime import datetime, timedelta, timezone +from html.parser import HTMLParser +from typing import Any, Sequence + +import pybase64 + +DEFAULT_POW_SCRIPT = "https://chatgpt.com/backend-api/sentinel/sdk.js" +from utils.helper import new_uuid + + +CORES = [8, 16, 24, 32] +DOCUMENT_KEYS = ["_reactListeningo743lnnpvdg", "location"] + + +class ScriptSrcParser(HTMLParser): + def __init__(self) -> None: + super().__init__() + self.script_sources: list[str] = [] + self.data_build = "" + + def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None: + if tag != "script": + return + attrs_dict = dict(attrs) + src = attrs_dict.get("src") + if not src: + return + self.script_sources.append(src) + match = re.search(r"c/[^/]*/_", src) + if match: + self.data_build = match.group(0) + + +def parse_pow_resources(html_content: str) -> tuple[list[str], str]: + parser = ScriptSrcParser() + parser.feed(html_content) + script_sources = parser.script_sources or [DEFAULT_POW_SCRIPT] + data_build = parser.data_build + if not data_build: + match = re.search(r'<html[^>]*data-build="([^"]*)"', html_content) + if match: + data_build = match.group(1) + return script_sources, data_build + + +def _legacy_parse_time() -> str: + now = datetime.now(timezone(timedelta(hours=-5))) + return now.strftime("%a %b %d %Y %H:%M:%S") + " GMT-0500 (Eastern Standard Time)" + + +def build_pow_config( + user_agent: str, + script_sources: Sequence[str] | None = None, + data_build: str = "", +) -> list[Any]: + navigator_key = random.choice([ + "registerProtocolHandler−function registerProtocolHandler() { [native code] }", + "storage−[object StorageManager]", + "locks−[object LockManager]", + "appCodeName−Mozilla", + "permissions−[object Permissions]", + "share−function share() { [native code] }", + "webdriver−false", + "managed−[object NavigatorManagedData]", + "canShare−function canShare() { [native code] }", + "vendor−Google Inc.", + "mediaDevices−[object MediaDevices]", + "vibrate−function vibrate() { [native code] }", + "storageBuckets−[object StorageBucketManager]", + "mediaCapabilities−[object MediaCapabilities]", + "cookieEnabled−true", + "virtualKeyboard−[object VirtualKeyboard]", + "product−Gecko", + "presentation−[object Presentation]", + "onLine−true", + "mimeTypes−[object MimeTypeArray]", + "credentials−[object CredentialsContainer]", + "serviceWorker−[object ServiceWorkerContainer]", + "keyboard−[object Keyboard]", + "gpu−[object GPU]", + "doNotTrack", + "serial−[object Serial]", + "pdfViewerEnabled−true", + "language−zh-CN", + "geolocation−[object Geolocation]", + "userAgentData−[object NavigatorUAData]", + "getUserMedia−function getUserMedia() { [native code] }", + "sendBeacon−function sendBeacon() { [native code] }", + "hardwareConcurrency−32", + "windowControlsOverlay−[object WindowControlsOverlay]", + ]) + window_key = random.choice([ + "0", + "window", + "self", + "document", + "name", + "location", + "customElements", + "history", + "navigation", + "innerWidth", + "innerHeight", + "scrollX", + "scrollY", + "visualViewport", + "screenX", + "screenY", + "outerWidth", + "outerHeight", + "devicePixelRatio", + "screen", + "chrome", + "navigator", + "onresize", + "performance", + "crypto", + "indexedDB", + "sessionStorage", + "localStorage", + "scheduler", + "alert", + "atob", + "btoa", + "fetch", + "matchMedia", + "postMessage", + "queueMicrotask", + "requestAnimationFrame", + "setInterval", + "setTimeout", + "caches", + "__NEXT_DATA__", + "__BUILD_MANIFEST", + "__NEXT_PRELOADREADY", + ]) + script_source = random.choice(list(script_sources)) if script_sources else DEFAULT_POW_SCRIPT + return [ + random.choice([3000, 4000, 5000]), + _legacy_parse_time(), + 4294705152, + 0, + user_agent, + script_source, + data_build, + "en-US", + "en-US,es-US,en,es", + 0, + navigator_key, + random.choice(DOCUMENT_KEYS), + window_key, + time.perf_counter() * 1000, + new_uuid(), + "", + random.choice(CORES), + time.time() * 1000 - (time.perf_counter() * 1000), + ] + + +def _pow_generate(seed: str, difficulty: str, config: list[Any], limit: int = 500000) -> tuple[str, bool]: + target = bytes.fromhex(difficulty) + diff_len = len(difficulty) // 2 + seed_bytes = seed.encode() + static_1 = (json.dumps(config[:3], separators=(",", ":"), ensure_ascii=False)[:-1] + ",").encode() + static_2 = ("," + json.dumps(config[4:9], separators=(",", ":"), ensure_ascii=False)[1:-1] + ",").encode() + static_3 = ("," + json.dumps(config[10:], separators=(",", ":"), ensure_ascii=False)[1:]).encode() + for i in range(limit): + final_json = static_1 + str(i).encode() + static_2 + str(i >> 1).encode() + static_3 + encoded = pybase64.b64encode(final_json) + digest = hashlib.sha3_512(seed_bytes + encoded).digest() + if digest[:diff_len] <= target: + return encoded.decode(), True + fallback = "wQ8Lk5FbGpA2NcR9dShT6gYjU7VxZ4D" + pybase64.b64encode(f'"{seed}"'.encode()).decode() + return fallback, False + + +def build_legacy_requirements_token( + user_agent: str, + script_sources: Sequence[str] | None = None, + data_build: str = "", +) -> str: + seed = format(random.random()) + config = build_pow_config(user_agent, script_sources=script_sources, data_build=data_build) + answer, _ = _pow_generate(seed, "0fffff", config) + return "gAAAAAC" + answer + + +def build_proof_token( + seed: str, + difficulty: str, + user_agent: str, + script_sources: Sequence[str] | None = None, + data_build: str = "", +) -> str: + config = build_pow_config(user_agent, script_sources=script_sources, data_build=data_build) + answer, solved = _pow_generate(seed, difficulty, config) + if not solved: + raise RuntimeError(f"failed to solve proof token: difficulty={difficulty}") + return "gAAAAAB" + answer diff --git a/utils/turnstile.py b/utils/turnstile.py new file mode 100644 index 0000000000000000000000000000000000000000..ed2ce7ef83e76e1daac3cb3ae62b95597f8417fc --- /dev/null +++ b/utils/turnstile.py @@ -0,0 +1,184 @@ +import base64 +import json +import random +import time +from typing import Any, Dict, Optional + + +class OrderedMap: + def __init__(self) -> None: + self.keys = [] + self.values = {} + + def add(self, key: str, value: Any) -> None: + if key not in self.values: + self.keys.append(key) + self.values[key] = value + + +def _turnstile_to_str(value: Any) -> str: + if value is None: + return "undefined" + if isinstance(value, float): + return str(value) + if isinstance(value, str): + special = { + "window.Math": "[object Math]", + "window.Reflect": "[object Reflect]", + "window.performance": "[object Performance]", + "window.localStorage": "[object Storage]", + "window.Object": "function Object() { [native code] }", + "window.Reflect.set": "function set() { [native code] }", + "window.performance.now": "function () { [native code] }", + "window.Object.create": "function create() { [native code] }", + "window.Object.keys": "function keys() { [native code] }", + "window.Math.random": "function random() { [native code] }", + } + return special.get(value, value) + if isinstance(value, list) and all(isinstance(item, str) for item in value): + return ",".join(value) + return str(value) + + +def _xor_string(text: str, key: str) -> str: + if not key: + return text + return "".join(chr(ord(ch) ^ ord(key[i % len(key)])) for i, ch in enumerate(text)) + + +def solve_turnstile_token(dx: str, p: str) -> Optional[str]: + try: + decoded = base64.b64decode(dx).decode() + token_list = json.loads(_xor_string(decoded, p)) + except Exception: + return None + + process_map: Dict[Any, Any] = {} + start_time = time.time() + result = "" + + def func_1(e: float, t: float) -> None: + process_map[e] = _xor_string(_turnstile_to_str(process_map[e]), _turnstile_to_str(process_map[t])) + + def func_2(e: float, t: Any) -> None: + process_map[e] = t + + def func_3(e: str) -> None: + nonlocal result + result = base64.b64encode(e.encode()).decode() + + def func_5(e: float, t: float) -> None: + current = process_map[e] + incoming = process_map[t] + if isinstance(current, (list, tuple)): + process_map[e] = list(current) + [incoming] + return + if isinstance(current, (str, float)) or isinstance(incoming, (str, float)): + process_map[e] = _turnstile_to_str(current) + _turnstile_to_str(incoming) + return + process_map[e] = "NaN" + + def func_6(e: float, t: float, n: float) -> None: + tv = process_map[t] + nv = process_map[n] + if isinstance(tv, str) and isinstance(nv, str): + value = f"{tv}.{nv}" + process_map[e] = "https://chatgpt.com/" if value == "window.document.location" else value + + def func_7(e: float, *args: float) -> None: + target = process_map[e] + values = [process_map[arg] for arg in args] + if isinstance(target, str) and target == "window.Reflect.set": + obj, key_name, val = values + obj.add(str(key_name), val) + elif callable(target): + target(*values) + + def func_8(e: float, t: float) -> None: + process_map[e] = process_map[t] + + def func_14(e: float, t: float) -> None: + process_map[e] = json.loads(process_map[t]) + + def func_15(e: float, t: float) -> None: + process_map[e] = json.dumps(process_map[t]) + + def func_17(e: float, t: float, *args: float) -> None: + call_args = [process_map[arg] for arg in args] + target = process_map[t] + if target == "window.performance.now": + elapsed_ns = time.time_ns() - int(start_time * 1e9) + process_map[e] = (elapsed_ns + random.random()) / 1e6 + elif target == "window.Object.create": + process_map[e] = OrderedMap() + elif target == "window.Object.keys": + if call_args and call_args[0] == "window.localStorage": + process_map[e] = [ + "STATSIG_LOCAL_STORAGE_INTERNAL_STORE_V4", + "STATSIG_LOCAL_STORAGE_STABLE_ID", + "client-correlated-secret", + "oai/apps/capExpiresAt", + "oai-did", + "STATSIG_LOCAL_STORAGE_LOGGING_REQUEST", + "UiState.isNavigationCollapsed.1", + ] + elif target == "window.Math.random": + process_map[e] = random.random() + elif callable(target): + process_map[e] = target(*call_args) + + def func_18(e: float) -> None: + process_map[e] = base64.b64decode(_turnstile_to_str(process_map[e])).decode() + + def func_19(e: float) -> None: + process_map[e] = base64.b64encode(_turnstile_to_str(process_map[e]).encode()).decode() + + def func_20(e: float, t: float, n: float, *args: float) -> None: + if process_map[e] == process_map[t]: + target = process_map[n] + if callable(target): + target(*[process_map[arg] for arg in args]) + + def func_21(*_: Any) -> None: + return + + def func_23(e: float, t: float, *args: float) -> None: + if process_map[e] is not None and callable(process_map[t]): + process_map[t](*args) + + def func_24(e: float, t: float, n: float) -> None: + tv = process_map[t] + nv = process_map[n] + if isinstance(tv, str) and isinstance(nv, str): + process_map[e] = f"{tv}.{nv}" + + process_map.update({ + 1: func_1, + 2: func_2, + 3: func_3, + 5: func_5, + 6: func_6, + 7: func_7, + 8: func_8, + 9: token_list, + 10: "window", + 14: func_14, + 15: func_15, + 16: p, + 17: func_17, + 18: func_18, + 19: func_19, + 20: func_20, + 21: func_21, + 23: func_23, + 24: func_24, + }) + + for token in token_list: + try: + fn = process_map.get(token[0]) + if callable(fn): + fn(*token[1:]) + except Exception: + continue + return result or None diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000000000000000000000000000000000000..4face99fcbc955f1cdb30e923dd2a2c5be65b2d7 --- /dev/null +++ b/uv.lock @@ -0,0 +1,904 @@ +version = 1 +revision = 2 +requires-python = ">=3.13" + +[[package]] +name = "annotated-doc" +version = "0.0.4" +source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/57/ba/046ceea27344560984e26a590f90bc7f4a75b06701f653222458922b558c/annotated_doc-0.0.4.tar.gz", hash = "sha256:fbcda96e87e9c92ad167c2e53839e57503ecfda18804ea28102353485033faa4" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/1e/d3/26bf1008eb3d2daa8ef4cacc7f3bfdc11818d111f7e2d0201bc6e3b49d45/annotated_doc-0.0.4-py3-none-any.whl", hash = "sha256:571ac1dc6991c450b25a9c2d84a3705e2ae7a53467b5d111c24fa8baabbed320" }, +] + +[[package]] +name = "annotated-types" +version = "0.7.0" +source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53" }, +] + +[[package]] +name = "anyio" +version = "4.13.0" +source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +dependencies = [ + { name = "idna" }, +] +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/19/14/2c5dd9f512b66549ae92767a9c7b330ae88e1932ca57876909410251fe13/anyio-4.13.0.tar.gz", hash = "sha256:334b70e641fd2221c1505b3890c69882fe4a2df910cba14d97019b90b24439dc" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/da/42/e921fccf5015463e32a3cf6ee7f980a6ed0f395ceeaa45060b61d86486c2/anyio-4.13.0-py3-none-any.whl", hash = "sha256:08b310f9e24a9594186fd75b4f73f4a4152069e3853f1ed8bfbf58369f4ad708" }, +] + +[[package]] +name = "certifi" +version = "2026.2.25" +source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/af/2d/7bf41579a8986e348fa033a31cdd0e4121114f6bce2457e8876010b092dd/certifi-2026.2.25.tar.gz", hash = "sha256:e887ab5cee78ea814d3472169153c2d12cd43b14bd03329a39a9c6e2e80bfba7" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/9a/3c/c17fb3ca2d9c3acff52e30b309f538586f9f5b9c9cf454f3845fc9af4881/certifi-2026.2.25-py3-none-any.whl", hash = "sha256:027692e4402ad994f1c42e52a4997a9763c646b73e4096e4d5d6db8af1d6f0fa" }, +] + +[[package]] +name = "cffi" +version = "2.0.0" +source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +dependencies = [ + { name = "pycparser", marker = "implementation_name != 'PyPy'" }, +] +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/eb/56/b1ba7935a17738ae8453301356628e8147c79dbb825bcbc73dc7401f9846/cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/4b/8d/a0a47a0c9e413a658623d014e91e74a50cdd2c423f7ccfd44086ef767f90/cffi-2.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:00bdf7acc5f795150faa6957054fbbca2439db2f775ce831222b66f192f03beb" }, + { url = "https://mirrors.aliyun.com/pypi/packages/4a/d2/a6c0296814556c68ee32009d9c2ad4f85f2707cdecfd7727951ec228005d/cffi-2.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:45d5e886156860dc35862657e1494b9bae8dfa63bf56796f2fb56e1679fc0bca" }, + { url = "https://mirrors.aliyun.com/pypi/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" }, + { url = "https://mirrors.aliyun.com/pypi/packages/a9/f5/a2c23eb03b61a0b8747f211eb716446c826ad66818ddc7810cc2cc19b3f2/cffi-2.0.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d48a880098c96020b02d5a1f7d9251308510ce8858940e6fa99ece33f610838b" }, + { url = "https://mirrors.aliyun.com/pypi/packages/f2/7f/e6647792fc5850d634695bc0e6ab4111ae88e89981d35ac269956605feba/cffi-2.0.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f93fd8e5c8c0a4aa1f424d6173f14a892044054871c771f8566e4008eaa359d2" }, + { url = "https://mirrors.aliyun.com/pypi/packages/cb/1e/a5a1bd6f1fb30f22573f76533de12a00bf274abcdc55c8edab639078abb6/cffi-2.0.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:dd4f05f54a52fb558f1ba9f528228066954fee3ebe629fc1660d874d040ae5a3" }, + { url = "https://mirrors.aliyun.com/pypi/packages/98/df/0a1755e750013a2081e863e7cd37e0cdd02664372c754e5560099eb7aa44/cffi-2.0.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c8d3b5532fc71b7a77c09192b4a5a200ea992702734a2e9279a37f2478236f26" }, + { url = "https://mirrors.aliyun.com/pypi/packages/50/e1/a969e687fcf9ea58e6e2a928ad5e2dd88cc12f6f0ab477e9971f2309b57c/cffi-2.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d9b29c1f0ae438d5ee9acb31cadee00a58c46cc9c0b2f9038c6b0b3470877a8c" }, + { url = "https://mirrors.aliyun.com/pypi/packages/36/54/0362578dd2c9e557a28ac77698ed67323ed5b9775ca9d3fe73fe191bb5d8/cffi-2.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6d50360be4546678fc1b79ffe7a66265e28667840010348dd69a314145807a1b" }, + { url = "https://mirrors.aliyun.com/pypi/packages/eb/6d/bf9bda840d5f1dfdbf0feca87fbdb64a918a69bca42cfa0ba7b137c48cb8/cffi-2.0.0-cp313-cp313-win32.whl", hash = "sha256:74a03b9698e198d47562765773b4a8309919089150a0bb17d829ad7b44b60d27" }, + { url = "https://mirrors.aliyun.com/pypi/packages/37/18/6519e1ee6f5a1e579e04b9ddb6f1676c17368a7aba48299c3759bbc3c8b3/cffi-2.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:19f705ada2530c1167abacb171925dd886168931e0a7b78f5bffcae5c6b5be75" }, + { url = "https://mirrors.aliyun.com/pypi/packages/cb/0e/02ceeec9a7d6ee63bb596121c2c8e9b3a9e150936f4fbef6ca1943e6137c/cffi-2.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:256f80b80ca3853f90c21b23ee78cd008713787b1b1e93eae9f3d6a7134abd91" }, + { url = "https://mirrors.aliyun.com/pypi/packages/92/c4/3ce07396253a83250ee98564f8d7e9789fab8e58858f35d07a9a2c78de9f/cffi-2.0.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:fc33c5141b55ed366cfaad382df24fe7dcbc686de5be719b207bb248e3053dc5" }, + { url = "https://mirrors.aliyun.com/pypi/packages/59/dd/27e9fa567a23931c838c6b02d0764611c62290062a6d4e8ff7863daf9730/cffi-2.0.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c654de545946e0db659b3400168c9ad31b5d29593291482c43e3564effbcee13" }, + { url = "https://mirrors.aliyun.com/pypi/packages/d6/43/0e822876f87ea8a4ef95442c3d766a06a51fc5298823f884ef87aaad168c/cffi-2.0.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:24b6f81f1983e6df8db3adc38562c83f7d4a0c36162885ec7f7b77c7dcbec97b" }, + { url = "https://mirrors.aliyun.com/pypi/packages/b4/89/76799151d9c2d2d1ead63c2429da9ea9d7aac304603de0c6e8764e6e8e70/cffi-2.0.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:12873ca6cb9b0f0d3a0da705d6086fe911591737a59f28b7936bdfed27c0d47c" }, + { url = "https://mirrors.aliyun.com/pypi/packages/bb/dd/3465b14bb9e24ee24cb88c9e3730f6de63111fffe513492bf8c808a3547e/cffi-2.0.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:d9b97165e8aed9272a6bb17c01e3cc5871a594a446ebedc996e2397a1c1ea8ef" }, + { url = "https://mirrors.aliyun.com/pypi/packages/47/d9/d83e293854571c877a92da46fdec39158f8d7e68da75bf73581225d28e90/cffi-2.0.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:afb8db5439b81cf9c9d0c80404b60c3cc9c3add93e114dcae767f1477cb53775" }, + { url = "https://mirrors.aliyun.com/pypi/packages/2b/0f/1f177e3683aead2bb00f7679a16451d302c436b5cbf2505f0ea8146ef59e/cffi-2.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:737fe7d37e1a1bffe70bd5754ea763a62a066dc5913ca57e957824b72a85e205" }, + { url = "https://mirrors.aliyun.com/pypi/packages/c6/0f/cafacebd4b040e3119dcb32fed8bdef8dfe94da653155f9d0b9dc660166e/cffi-2.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:38100abb9d1b1435bc4cc340bb4489635dc2f0da7456590877030c9b3d40b0c1" }, + { url = "https://mirrors.aliyun.com/pypi/packages/3e/aa/df335faa45b395396fcbc03de2dfcab242cd61a9900e914fe682a59170b1/cffi-2.0.0-cp314-cp314-win32.whl", hash = "sha256:087067fa8953339c723661eda6b54bc98c5625757ea62e95eb4898ad5e776e9f" }, + { url = "https://mirrors.aliyun.com/pypi/packages/bb/92/882c2d30831744296ce713f0feb4c1cd30f346ef747b530b5318715cc367/cffi-2.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:203a48d1fb583fc7d78a4c6655692963b860a417c0528492a6bc21f1aaefab25" }, + { url = "https://mirrors.aliyun.com/pypi/packages/9f/2c/98ece204b9d35a7366b5b2c6539c350313ca13932143e79dc133ba757104/cffi-2.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:dbd5c7a25a7cb98f5ca55d258b103a2054f859a46ae11aaf23134f9cc0d356ad" }, + { url = "https://mirrors.aliyun.com/pypi/packages/3e/61/c768e4d548bfa607abcda77423448df8c471f25dbe64fb2ef6d555eae006/cffi-2.0.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:9a67fc9e8eb39039280526379fb3a70023d77caec1852002b4da7e8b270c4dd9" }, + { url = "https://mirrors.aliyun.com/pypi/packages/2c/ea/5f76bce7cf6fcd0ab1a1058b5af899bfbef198bea4d5686da88471ea0336/cffi-2.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7a66c7204d8869299919db4d5069a82f1561581af12b11b3c9f48c584eb8743d" }, + { url = "https://mirrors.aliyun.com/pypi/packages/be/b4/c56878d0d1755cf9caa54ba71e5d049479c52f9e4afc230f06822162ab2f/cffi-2.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7cc09976e8b56f8cebd752f7113ad07752461f48a58cbba644139015ac24954c" }, + { url = "https://mirrors.aliyun.com/pypi/packages/e0/0d/eb704606dfe8033e7128df5e90fee946bbcb64a04fcdaa97321309004000/cffi-2.0.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:92b68146a71df78564e4ef48af17551a5ddd142e5190cdf2c5624d0c3ff5b2e8" }, + { url = "https://mirrors.aliyun.com/pypi/packages/d8/19/3c435d727b368ca475fb8742ab97c9cb13a0de600ce86f62eab7fa3eea60/cffi-2.0.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b1e74d11748e7e98e2f426ab176d4ed720a64412b6a15054378afdb71e0f37dc" }, + { url = "https://mirrors.aliyun.com/pypi/packages/d0/44/681604464ed9541673e486521497406fadcc15b5217c3e326b061696899a/cffi-2.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:28a3a209b96630bca57cce802da70c266eb08c6e97e5afd61a75611ee6c64592" }, + { url = "https://mirrors.aliyun.com/pypi/packages/25/8e/342a504ff018a2825d395d44d63a767dd8ebc927ebda557fecdaca3ac33a/cffi-2.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7553fb2090d71822f02c629afe6042c299edf91ba1bf94951165613553984512" }, + { url = "https://mirrors.aliyun.com/pypi/packages/e1/5e/b666bacbbc60fbf415ba9988324a132c9a7a0448a9a8f125074671c0f2c3/cffi-2.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6c6c373cfc5c83a975506110d17457138c8c63016b563cc9ed6e056a82f13ce4" }, + { url = "https://mirrors.aliyun.com/pypi/packages/a0/1d/ec1a60bd1a10daa292d3cd6bb0b359a81607154fb8165f3ec95fe003b85c/cffi-2.0.0-cp314-cp314t-win32.whl", hash = "sha256:1fc9ea04857caf665289b7a75923f2c6ed559b8298a1b8c49e59f7dd95c8481e" }, + { url = "https://mirrors.aliyun.com/pypi/packages/bf/41/4c1168c74fac325c0c8156f04b6749c8b6a8f405bbf91413ba088359f60d/cffi-2.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d68b6cef7827e8641e8ef16f4494edda8b36104d79773a334beaa1e3521430f6" }, + { url = "https://mirrors.aliyun.com/pypi/packages/ae/3a/dbeec9d1ee0844c679f6bb5d6ad4e9f198b1224f4e7a32825f47f6192b0c/cffi-2.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9" }, +] + +[[package]] +name = "charset-normalizer" +version = "3.4.7" +source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/e7/a1/67fe25fac3c7642725500a3f6cfe5821ad557c3abb11c9d20d12c7008d3e/charset_normalizer-3.4.7.tar.gz", hash = "sha256:ae89db9e5f98a11a4bf50407d4363e7b09b31e55bc117b4f7d80aab97ba009e5" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/c1/3b/66777e39d3ae1ddc77ee606be4ec6d8cbd4c801f65e5a1b6f2b11b8346dd/charset_normalizer-3.4.7-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:f496c9c3cc02230093d8330875c4c3cdfc3b73612a5fd921c65d39cbcef08063" }, + { url = "https://mirrors.aliyun.com/pypi/packages/2e/4e/b7f84e617b4854ade48a1b7915c8ccfadeba444d2a18c291f696e37f0d3b/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ea948db76d31190bf08bd371623927ee1339d5f2a0b4b1b4a4439a65298703c" }, + { url = "https://mirrors.aliyun.com/pypi/packages/c4/bb/ec73c0257c9e11b268f018f068f5d00aa0ef8c8b09f7753ebd5f2880e248/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a277ab8928b9f299723bc1a2dabb1265911b1a76341f90a510368ca44ad9ab66" }, + { url = "https://mirrors.aliyun.com/pypi/packages/85/fb/32d1f5033484494619f701e719429c69b766bfc4dbc61aa9e9c8c166528b/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3bec022aec2c514d9cf199522a802bd007cd588ab17ab2525f20f9c34d067c18" }, + { url = "https://mirrors.aliyun.com/pypi/packages/fa/07/330e3a0dda4c404d6da83b327270906e9654a24f6c546dc886a0eb0ffb23/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e044c39e41b92c845bc815e5ae4230804e8e7bc29e399b0437d64222d92809dd" }, + { url = "https://mirrors.aliyun.com/pypi/packages/e3/7c/fc890655786e423f02556e0216d4b8c6bcb6bdfa890160dc66bf52dee468/charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:f495a1652cf3fbab2eb0639776dad966c2fb874d79d87ca07f9d5f059b8bd215" }, + { url = "https://mirrors.aliyun.com/pypi/packages/d8/97/bfb18b3db2aed3b90cf54dc292ad79fdd5ad65c4eae454099475cbeadd0d/charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e712b419df8ba5e42b226c510472b37bd57b38e897d3eca5e8cfd410a29fa859" }, + { url = "https://mirrors.aliyun.com/pypi/packages/6f/a5/a581c13798546a7fd557c82614a5c65a13df2157e9ad6373166d2a3e645d/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:7804338df6fcc08105c7745f1502ba68d900f45fd770d5bdd5288ddccb8a42d8" }, + { url = "https://mirrors.aliyun.com/pypi/packages/8c/bf/b3ab5bcb478e4193d517644b0fb2bf5497fbceeaa7a1bc0f4d5b50953861/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:481551899c856c704d58119b5025793fa6730adda3571971af568f66d2424bb5" }, + { url = "https://mirrors.aliyun.com/pypi/packages/e7/4e/23efd79b65d314fa320ec6017b4b5834d5c12a58ba4610aa353af2e2f577/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:f59099f9b66f0d7145115e6f80dd8b1d847176df89b234a5a6b3f00437aa0832" }, + { url = "https://mirrors.aliyun.com/pypi/packages/b9/9f/1e1941bc3f0e01df116e68dc37a55c4d249df5e6fa77f008841aef68264f/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:f59ad4c0e8f6bba240a9bb85504faa1ab438237199d4cce5f622761507b8f6a6" }, + { url = "https://mirrors.aliyun.com/pypi/packages/80/0f/088cbb3020d44428964a6c97fe1edfb1b9550396bf6d278330281e8b709c/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:3dedcc22d73ec993f42055eff4fcfed9318d1eeb9a6606c55892a26964964e48" }, + { url = "https://mirrors.aliyun.com/pypi/packages/6a/9f/130394f9bbe06f4f63e22641d32fc9b202b7e251c9aef4db044324dac493/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:64f02c6841d7d83f832cd97ccf8eb8a906d06eb95d5276069175c696b024b60a" }, + { url = "https://mirrors.aliyun.com/pypi/packages/73/55/c469897448a06e49f8fa03f6caae97074fde823f432a98f979cc42b90e69/charset_normalizer-3.4.7-cp313-cp313-win32.whl", hash = "sha256:4042d5c8f957e15221d423ba781e85d553722fc4113f523f2feb7b188cc34c5e" }, + { url = "https://mirrors.aliyun.com/pypi/packages/5d/78/1b74c5bbb3f99b77a1715c91b3e0b5bdb6fe302d95ace4f5b1bec37b0167/charset_normalizer-3.4.7-cp313-cp313-win_amd64.whl", hash = "sha256:3946fa46a0cf3e4c8cb1cc52f56bb536310d34f25f01ca9b6c16afa767dab110" }, + { url = "https://mirrors.aliyun.com/pypi/packages/68/86/46bd42279d323deb8687c4a5a811fd548cb7d1de10cf6535d099877a9a9f/charset_normalizer-3.4.7-cp313-cp313-win_arm64.whl", hash = "sha256:80d04837f55fc81da168b98de4f4b797ef007fc8a79ab71c6ec9bc4dd662b15b" }, + { url = "https://mirrors.aliyun.com/pypi/packages/97/c8/c67cb8c70e19ef1960b97b22ed2a1567711de46c4ddf19799923adc836c2/charset_normalizer-3.4.7-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:c36c333c39be2dbca264d7803333c896ab8fa7d4d6f0ab7edb7dfd7aea6e98c0" }, + { url = "https://mirrors.aliyun.com/pypi/packages/99/85/c091fdee33f20de70d6c8b522743b6f831a2f1cd3ff86de4c6a827c48a76/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1c2aed2e5e41f24ea8ef1590b8e848a79b56f3a5564a65ceec43c9d692dc7d8a" }, + { url = "https://mirrors.aliyun.com/pypi/packages/87/1c/ab2ce611b984d2fd5d86a5a8a19c1ae26acac6bad967da4967562c75114d/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:54523e136b8948060c0fa0bc7b1b50c32c186f2fceee897a495406bb6e311d2b" }, + { url = "https://mirrors.aliyun.com/pypi/packages/a8/29/2b1d2cb00bf085f59d29eb773ce58ec2d325430f8c216804a0a5cd83cbca/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:715479b9a2802ecac752a3b0efa2b0b60285cf962ee38414211abdfccc233b41" }, + { url = "https://mirrors.aliyun.com/pypi/packages/47/5c/032c2d5a07fe4d4855fea851209cca2b6f03ebeb6d4e3afdb3358386a684/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bd6c2a1c7573c64738d716488d2cdd3c00e340e4835707d8fdb8dc1a66ef164e" }, + { url = "https://mirrors.aliyun.com/pypi/packages/2c/c2/356065d5a8b78ed04499cae5f339f091946a6a74f91e03476c33f0ab7100/charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:c45e9440fb78f8ddabcf714b68f936737a121355bf59f3907f4e17721b9d1aae" }, + { url = "https://mirrors.aliyun.com/pypi/packages/0c/cd/a32a84217ced5039f53b29f460962abb2d4420def55afabe45b1c3c7483d/charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3534e7dcbdcf757da6b85a0bbf5b6868786d5982dd959b065e65481644817a18" }, + { url = "https://mirrors.aliyun.com/pypi/packages/44/86/58e6f13ce26cc3b8f4a36b94a0f22ae2f00a72534520f4ae6857c4b81f89/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e8ac484bf18ce6975760921bb6148041faa8fef0547200386ea0b52b5d27bf7b" }, + { url = "https://mirrors.aliyun.com/pypi/packages/8f/fe/d17c32dc72e17e155e06883efa84514ca375f8a528ba2546bee73fc4df81/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:a5fe03b42827c13cdccd08e6c0247b6a6d4b5e3cdc53fd1749f5896adcdc2356" }, + { url = "https://mirrors.aliyun.com/pypi/packages/6a/29/f33daa50b06525a237451cdb6c69da366c381a3dadcd833fa5676bc468b3/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:2d6eb928e13016cea4f1f21d1e10c1cebd5a421bc57ddf5b1142ae3f86824fab" }, + { url = "https://mirrors.aliyun.com/pypi/packages/b6/6e/52c84015394a6a0bdcd435210a7e944c5f94ea1055f5cc5d56c5fe368e7b/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:e74327fb75de8986940def6e8dee4f127cc9752bee7355bb323cc5b2659b6d46" }, + { url = "https://mirrors.aliyun.com/pypi/packages/8c/d7/4353be581b373033fb9198bf1da3cf8f09c1082561e8e922aa7b39bf9fe8/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:d6038d37043bced98a66e68d3aa2b6a35505dc01328cd65217cefe82f25def44" }, + { url = "https://mirrors.aliyun.com/pypi/packages/30/45/99d18aa925bd1740098ccd3060e238e21115fffbfdcb8f3ece837d0ace6c/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7579e913a5339fb8fa133f6bbcfd8e6749696206cf05acdbdca71a1b436d8e72" }, + { url = "https://mirrors.aliyun.com/pypi/packages/5c/05/5ee478aa53f4bb7996482153d4bfe1b89e0f087f0ab6b294fcf92d595873/charset_normalizer-3.4.7-cp314-cp314-win32.whl", hash = "sha256:5b77459df20e08151cd6f8b9ef8ef1f961ef73d85c21a555c7eed5b79410ec10" }, + { url = "https://mirrors.aliyun.com/pypi/packages/48/77/72dcb0921b2ce86420b2d79d454c7022bf5be40202a2a07906b9f2a35c97/charset_normalizer-3.4.7-cp314-cp314-win_amd64.whl", hash = "sha256:92a0a01ead5e668468e952e4238cccd7c537364eb7d851ab144ab6627dbbe12f" }, + { url = "https://mirrors.aliyun.com/pypi/packages/c6/a3/c2369911cd72f02386e4e340770f6e158c7980267da16af8f668217abaa0/charset_normalizer-3.4.7-cp314-cp314-win_arm64.whl", hash = "sha256:67f6279d125ca0046a7fd386d01b311c6363844deac3e5b069b514ba3e63c246" }, + { url = "https://mirrors.aliyun.com/pypi/packages/94/09/7e8a7f73d24dba1f0035fbbf014d2c36828fc1bf9c88f84093e57d315935/charset_normalizer-3.4.7-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:effc3f449787117233702311a1b7d8f59cba9ced946ba727bdc329ec69028e24" }, + { url = "https://mirrors.aliyun.com/pypi/packages/8d/da/96975ddb11f8e977f706f45cddd8540fd8242f71ecdb5d18a80723dcf62c/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fbccdc05410c9ee21bbf16a35f4c1d16123dcdeb8a1d38f33654fa21d0234f79" }, + { url = "https://mirrors.aliyun.com/pypi/packages/e5/e8/1d63bf8ef2d388e95c64b2098f45f84758f6d102a087552da1485912637b/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:733784b6d6def852c814bce5f318d25da2ee65dd4839a0718641c696e09a2960" }, + { url = "https://mirrors.aliyun.com/pypi/packages/9b/40/e5ff04233e70da2681fa43969ad6f66ca5611d7e669be0246c4c7aaf6dc8/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a89c23ef8d2c6b27fd200a42aa4ac72786e7c60d40efdc76e6011260b6e949c4" }, + { url = "https://mirrors.aliyun.com/pypi/packages/be/c1/06c6c49d5a5450f76899992f1ee40b41d076aee9279b49cf9974d2f313d5/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6c114670c45346afedc0d947faf3c7f701051d2518b943679c8ff88befe14f8e" }, + { url = "https://mirrors.aliyun.com/pypi/packages/2b/9f/f2ff16fb050946169e3e1f82134d107e5d4ae72647ec8a1b1446c148480f/charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:a180c5e59792af262bf263b21a3c49353f25945d8d9f70628e73de370d55e1e1" }, + { url = "https://mirrors.aliyun.com/pypi/packages/69/d5/a527c0cd8d64d2eab7459784fb4169a0ac76e5a6fc5237337982fd61347e/charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3c9a494bc5ec77d43cea229c4f6db1e4d8fe7e1bbffa8b6f0f0032430ff8ab44" }, + { url = "https://mirrors.aliyun.com/pypi/packages/7e/80/8a7b8104a3e203074dc9aa2c613d4b726c0e136bad1cc734594b02867972/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8d828b6667a32a728a1ad1d93957cdf37489c57b97ae6c4de2860fa749b8fc1e" }, + { url = "https://mirrors.aliyun.com/pypi/packages/02/9a/b759b503d507f375b2b5c153e4d2ee0a75aa215b7f2489cf314f4541f2c0/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:cf1493cd8607bec4d8a7b9b004e699fcf8f9103a9284cc94962cb73d20f9d4a3" }, + { url = "https://mirrors.aliyun.com/pypi/packages/c2/4e/0f3f5d47b86bdb79256e7290b26ac847a2832d9a4033f7eb2cd4bcf4bb5b/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:0c96c3b819b5c3e9e165495db84d41914d6894d55181d2d108cc1a69bfc9cce0" }, + { url = "https://mirrors.aliyun.com/pypi/packages/96/23/bce28734eb3ed2c91dcf93abeb8a5cf393a7b2749725030bb630e554fdd8/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:752a45dc4a6934060b3b0dab47e04edc3326575f82be64bc4fc293914566503e" }, + { url = "https://mirrors.aliyun.com/pypi/packages/2c/6f/6e897c6984cc4d41af319b077f2f600fc8214eb2fe2d6bcb79141b882400/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:8778f0c7a52e56f75d12dae53ae320fae900a8b9b4164b981b9c5ce059cd1fcb" }, + { url = "https://mirrors.aliyun.com/pypi/packages/76/22/ef7bd0fe480a0ae9b656189ec00744b60933f68b4f42a7bb06589f6f576a/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ce3412fbe1e31eb81ea42f4169ed94861c56e643189e1e75f0041f3fe7020abe" }, + { url = "https://mirrors.aliyun.com/pypi/packages/c5/a7/0e0ab3e0b5bc1219bd80a6a0d4d72ca74d9250cb2382b7c699c147e06017/charset_normalizer-3.4.7-cp314-cp314t-win32.whl", hash = "sha256:c03a41a8784091e67a39648f70c5f97b5b6a37f216896d44d2cdcb82615339a0" }, + { url = "https://mirrors.aliyun.com/pypi/packages/7a/1d/29d32e0fb40864b1f878c7f5a0b343ae676c6e2b271a2d55cc3a152391da/charset_normalizer-3.4.7-cp314-cp314t-win_amd64.whl", hash = "sha256:03853ed82eeebbce3c2abfdbc98c96dc205f32a79627688ac9a27370ea61a49c" }, + { url = "https://mirrors.aliyun.com/pypi/packages/de/32/d92444ad05c7a6e41fb2036749777c163baf7a0301a040cb672d6b2b1ae9/charset_normalizer-3.4.7-cp314-cp314t-win_arm64.whl", hash = "sha256:c35abb8bfff0185efac5878da64c45dafd2b37fb0383add1be155a763c1f083d" }, + { url = "https://mirrors.aliyun.com/pypi/packages/db/8f/61959034484a4a7c527811f4721e75d02d653a35afb0b6054474d8185d4c/charset_normalizer-3.4.7-py3-none-any.whl", hash = "sha256:3dce51d0f5e7951f8bb4900c257dad282f49190fdbebecd4ba99bcc41fef404d" }, +] + +[[package]] +name = "chatgpt2api" +version = "0.1.0" +source = { virtual = "." } +dependencies = [ + { name = "curl-cffi" }, + { name = "fastapi" }, + { name = "gitpython" }, + { name = "pillow" }, + { name = "psycopg2-binary" }, + { name = "pybase64" }, + { name = "python-multipart" }, + { name = "sqlalchemy" }, + { name = "tiktoken" }, + { name = "uvicorn" }, +] + +[package.dev-dependencies] +dev = [ + { name = "httpx" }, +] + +[package.metadata] +requires-dist = [ + { name = "curl-cffi", specifier = ">=0.15.0" }, + { name = "fastapi", specifier = ">=0.136.0" }, + { name = "gitpython", specifier = ">=3.1.0" }, + { name = "pillow", specifier = ">=12.2.0" }, + { name = "psycopg2-binary", specifier = ">=2.9.0" }, + { name = "pybase64", specifier = ">=1.4.3" }, + { name = "python-multipart", specifier = ">=0.0.26" }, + { name = "sqlalchemy", specifier = ">=2.0.0" }, + { name = "tiktoken", specifier = ">=0.12.0" }, + { name = "uvicorn", specifier = ">=0.44.0" }, +] + +[package.metadata.requires-dev] +dev = [{ name = "httpx", specifier = ">=0.28.1" }] + +[[package]] +name = "click" +version = "8.3.2" +source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/57/75/31212c6bf2503fdf920d87fee5d7a86a2e3bcf444984126f13d8e4016804/click-8.3.2.tar.gz", hash = "sha256:14162b8b3b3550a7d479eafa77dfd3c38d9dc8951f6f69c78913a8f9a7540fd5" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/e4/20/71885d8b97d4f3dde17b1fdb92dbd4908b00541c5a3379787137285f602e/click-8.3.2-py3-none-any.whl", hash = "sha256:1924d2c27c5653561cd2cae4548d1406039cb79b858b747cfea24924bbc1616d" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6" }, +] + +[[package]] +name = "curl-cffi" +version = "0.15.0" +source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +dependencies = [ + { name = "certifi" }, + { name = "cffi" }, + { name = "rich" }, +] +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/48/5b/89fcfebd3e5e85134147ac99e9f2b2271165fd4d71984fc65da5f17819b7/curl_cffi-0.15.0.tar.gz", hash = "sha256:ea0c67652bf6893d34ee0f82c944f37e488f6147e9421bef1771cc6545b02ded" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/5e/42/54ddd442c795f30ce5dd4e49f87ce77505958d3777cd96a91567a3975d2a/curl_cffi-0.15.0-cp310-abi3-macosx_10_9_x86_64.whl", hash = "sha256:bda66404010e9ed743b1b83c20c86f24fe21a9a6873e17479d6e67e29d8ded28" }, + { url = "https://mirrors.aliyun.com/pypi/packages/83/2d/3915e238579b3c5a92cead5c79130c3b8d20caaba7616cc4d894650e1d6b/curl_cffi-0.15.0-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:a25620d9bf989c9c029a7d1642999c4c265abb0bad811deb2f77b0b5b2b12e5b" }, + { url = "https://mirrors.aliyun.com/pypi/packages/2a/b3/9d2f1057749a1b07ba1989db3c1503ce8bed998310bae9aea2c43aa64f20/curl_cffi-0.15.0-cp310-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:582e570aa2586b96ed47cf4a17586b9a3c462cbe43f780487c3dc245c6ef1527" }, + { url = "https://mirrors.aliyun.com/pypi/packages/b5/1d/6d10dded5ce3fd8157e558ebd97d09e551b77a62cdc1c31e93d0a633cee5/curl_cffi-0.15.0-cp310-abi3-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:838e48212447d9c81364b04707a5c861daf08f8320f9ecb3406a8919d1d5c3b3" }, + { url = "https://mirrors.aliyun.com/pypi/packages/5c/12/c70b835487ace3b9ba1502631912e3440082b8ae3a162f60b59cb0b6444d/curl_cffi-0.15.0-cp310-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:2b6c847d86283b07ae69bb72c82eb8a59242277142aa35b89850f89e792a02fc" }, + { url = "https://mirrors.aliyun.com/pypi/packages/ea/0d/78edcc4f71934225db99df68197a107386d59080742fc7bf6bb4d007924f/curl_cffi-0.15.0-cp310-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:9e5e69eee735f659287e2c84444319d68a1fa68dd37abf228943a4074864283a" }, + { url = "https://mirrors.aliyun.com/pypi/packages/5b/84/1e101c1acb1ea2f0b4992f5c3024f596d8e21db0d53540b9d583f673c4e7/curl_cffi-0.15.0-cp310-abi3-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:aa1323950224db24f4c510d010b3affa02196ca853fb424191fa917a513d3f4b" }, + { url = "https://mirrors.aliyun.com/pypi/packages/28/42/8ef236b22a6c23d096c85a1dc507efe37bfdfc7a2f8a4b34efb590197369/curl_cffi-0.15.0-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:41f80170ba844009273b2660da1964ec31e99e5719d16b3422ada87177e32e13" }, + { url = "https://mirrors.aliyun.com/pypi/packages/1d/01/56aeb055d962da87a1be0d74c6c644e251c7e88129b5471dc44ac724e678/curl_cffi-0.15.0-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:1977e1e12cfb5c11352cbb74acef1bed24eb7d226dab61ca57c168c21acd4d61" }, + { url = "https://mirrors.aliyun.com/pypi/packages/d8/8c/2abf99a38d6340d66cf0557e0c750ef3f8883dfc5d450087e01c85861343/curl_cffi-0.15.0-cp310-abi3-win_amd64.whl", hash = "sha256:5a0c1896a0d5a5ac1eb89cd24b008d2b718dd1df6fd2f75451b59ca66e49e572" }, + { url = "https://mirrors.aliyun.com/pypi/packages/3d/39/dfd54f2240d3a9b96d77bacc62b97813b35e2aa8ecf5cd5013c683f1ba96/curl_cffi-0.15.0-cp310-abi3-win_arm64.whl", hash = "sha256:a6d57f8389273a3a1f94370473c74897467bcc36af0a17336989780c507fa43d" }, + { url = "https://mirrors.aliyun.com/pypi/packages/19/6a/c24df8a4fc22fa84070dcd94abeba43c15e08cc09e35869565c0bad196fd/curl_cffi-0.15.0-cp313-abi3-android_24_arm64_v8a.whl", hash = "sha256:4682dc38d4336e0eb0b185374db90a760efde63cbea994b4e63f3521d44c4c92" }, + { url = "https://mirrors.aliyun.com/pypi/packages/11/56/132225cb3491d07cc6adcce5fe395e059bde87c68cff1ef87a31c88c7819/curl_cffi-0.15.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:967ad7355bd8e9586f8c2d02eaa99953747549e7ea4a9b25cd53353e6b67fe6d" }, + { url = "https://mirrors.aliyun.com/pypi/packages/07/8f/f4f83cd303bef7e8f1749512e5dd157e7e5d08b0a36c8211f9640a2757bf/curl_cffi-0.15.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7e63539d0d839d0a8c5eacf86229bc68c57803547f35e0db7ee0986328b478c3" }, + { url = "https://mirrors.aliyun.com/pypi/packages/e8/5c/643d65c7fc9acd742876aa55c2d7823c438cb7665810acd2e66c9976c4d9/curl_cffi-0.15.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:08c799b89740b9bc49c09fbc3d5907f13ac1f845ca52620507ef9466d4639dd5" }, + { url = "https://mirrors.aliyun.com/pypi/packages/7f/0b/9b8037113c93f4c5323096163471fa7c35c7676c3f608eeaf1287cd99d58/curl_cffi-0.15.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7b7a92767a888ee90147e18964b396d8435ff42737030d6fb00824ffd6094805" }, + { url = "https://mirrors.aliyun.com/pypi/packages/5f/96/fff2fcbd924ef4042e0d67379f751a8a4e3186a91e75e35a4cf218b306ee/curl_cffi-0.15.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:829cc357061ecb99cc2d406301f609a039e05665322f5c025ec67c38b0dc49ce" }, + { url = "https://mirrors.aliyun.com/pypi/packages/53/1b/304b253a45ab28691c8c5e8cca1e6cbb9cf8e46dfceae4648dd536f75e73/curl_cffi-0.15.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:408d6f14e346841cd889c2e0962832bb235ba3b6749ebf609f347f747da5e60f" }, + { url = "https://mirrors.aliyun.com/pypi/packages/5a/ff/4723d92f08259c707a974aba27a08d0a822b9555e35ca581bf18d055a364/curl_cffi-0.15.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b624c7ce087bfda967a013ed0a64702a525444e5b6e97d23534d567ccc6525aa" }, + { url = "https://mirrors.aliyun.com/pypi/packages/59/8c/36bbe06d66fa2b765e4a07199f643a59a9cd1a754207a96335402a9520f4/curl_cffi-0.15.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0b6c0543b993996670e9e4b78e305a2d60809d5681903ffb5568e21a387434d3" }, +] + +[[package]] +name = "fastapi" +version = "0.136.0" +source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +dependencies = [ + { name = "annotated-doc" }, + { name = "pydantic" }, + { name = "starlette" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/4e/d9/e66315807e41e69e7f6a1b42a162dada2f249c5f06ad3f1a95f84ab336ef/fastapi-0.136.0.tar.gz", hash = "sha256:cf08e067cc66e106e102d9ba659463abfac245200752f8a5b7b1e813de4ff73e" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/26/a3/0bd5f0cdb0bbc92650e8dc457e9250358411ee5d1b65e42b6632387daf81/fastapi-0.136.0-py3-none-any.whl", hash = "sha256:8793d44ec7378e2be07f8a013cf7f7aa47d6327d0dfe9804862688ec4541a6b4" }, +] + +[[package]] +name = "gitdb" +version = "4.0.12" +source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +dependencies = [ + { name = "smmap" }, +] +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/72/94/63b0fc47eb32792c7ba1fe1b694daec9a63620db1e313033d18140c2320a/gitdb-4.0.12.tar.gz", hash = "sha256:5ef71f855d191a3326fcfbc0d5da835f26b13fbcba60c32c21091c349ffdb571" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/a0/61/5c78b91c3143ed5c14207f463aecfc8f9dbb5092fb2869baf37c273b2705/gitdb-4.0.12-py3-none-any.whl", hash = "sha256:67073e15955400952c6565cc3e707c554a4eea2e428946f7a4c162fab9bd9bcf" }, +] + +[[package]] +name = "gitpython" +version = "3.1.47" +source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +dependencies = [ + { name = "gitdb" }, +] +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/c1/bd/50db468e9b1310529a19fce651b3b0e753b5c07954d486cba31bbee9a5d5/gitpython-3.1.47.tar.gz", hash = "sha256:dba27f922bd2b42cb54c87a8ab3cb6beb6bf07f3d564e21ac848913a05a8a3cd" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/f2/c5/a1bc0996af85757903cf2bf444a7824e68e0035ce63fb41d6f76f9def68b/gitpython-3.1.47-py3-none-any.whl", hash = "sha256:489f590edfd6d20571b2c0e72c6a6ac6915ee8b8cd04572330e3842207a78905" }, +] + +[[package]] +name = "greenlet" +version = "3.4.0" +source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/86/94/a5935717b307d7c71fe877b52b884c6af707d2d2090db118a03fbd799369/greenlet-3.4.0.tar.gz", hash = "sha256:f50a96b64dafd6169e595a5c56c9146ef80333e67d4476a65a9c55f400fc22ff" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/7a/75/7e9cd1126a1e1f0cd67b0eda02e5221b28488d352684704a78ed505bd719/greenlet-3.4.0-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:43748988b097f9c6f09364f260741aa73c80747f63389824435c7a50bfdfd5c1" }, + { url = "https://mirrors.aliyun.com/pypi/packages/9d/c4/3e2df392e5cb199527c4d9dbcaa75c14edcc394b45040f0189f649631e3c/greenlet-3.4.0-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5566e4e2cd7a880e8c27618e3eab20f3494452d12fd5129edef7b2f7aa9a36d1" }, + { url = "https://mirrors.aliyun.com/pypi/packages/da/af/750cdfda1d1bd30a6c28080245be8d0346e669a98fdbae7f4102aa95fff3/greenlet-3.4.0-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1054c5a3c78e2ab599d452f23f7adafef55062a783a8e241d24f3b633ba6ff82" }, + { url = "https://mirrors.aliyun.com/pypi/packages/e0/93/c8c508d68ba93232784bbc1b5474d92371f2897dfc6bc281b419f2e0d492/greenlet-3.4.0-cp313-cp313-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:98eedd1803353daf1cd9ef23eef23eda5a4d22f99b1f998d273a8b78b70dd47f" }, + { url = "https://mirrors.aliyun.com/pypi/packages/54/78/0cbc693622cd54ebe25207efbb3a0eb07c2639cb8594f6e3aaaa0bb077a8/greenlet-3.4.0-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f82cb6cddc27dd81c96b1506f4aa7def15070c3b2a67d4e46fd19016aacce6cf" }, + { url = "https://mirrors.aliyun.com/pypi/packages/7f/46/cfaaa0ade435a60550fd83d07dfd5c41f873a01da17ede5c4cade0b9bab8/greenlet-3.4.0-cp313-cp313-manylinux_2_39_riscv64.whl", hash = "sha256:b7857e2202aae67bc5725e0c1f6403c20a8ff46094ece015e7d474f5f7020b55" }, + { url = "https://mirrors.aliyun.com/pypi/packages/ba/c0/8966767de01343c1ff47e8b855dc78e7d1a8ed2b7b9c83576a57e289f81d/greenlet-3.4.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:227a46251ecba4ff46ae742bc5ce95c91d5aceb4b02f885487aff269c127a729" }, + { url = "https://mirrors.aliyun.com/pypi/packages/b8/38/bcdc71ba05e9a5fda87f63ffc2abcd1f15693b659346df994a48c968003d/greenlet-3.4.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:5b99e87be7eba788dd5b75ba1cde5639edffdec5f91fe0d734a249535ec3408c" }, + { url = "https://mirrors.aliyun.com/pypi/packages/a1/c2/19b664b7173b9e4ef5f77e8cef9f14c20ec7fce7920dc1ccd7afd955d093/greenlet-3.4.0-cp313-cp313-win_amd64.whl", hash = "sha256:849f8bc17acd6295fcb5de8e46d55cc0e52381c56eaf50a2afd258e97bc65940" }, + { url = "https://mirrors.aliyun.com/pypi/packages/9b/96/795619651d39c7fbd809a522f881aa6f0ead504cc8201c3a5b789dfaef99/greenlet-3.4.0-cp313-cp313-win_arm64.whl", hash = "sha256:9390ad88b652b1903814eaabd629ca184db15e0eeb6fe8a390bbf8b9106ae15a" }, + { url = "https://mirrors.aliyun.com/pypi/packages/78/02/bde66806e8f169cf90b14d02c500c44cdbe02c8e224c9c67bafd1b8cadd1/greenlet-3.4.0-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:10a07aca6babdd18c16a3f4f8880acfffc2b88dfe431ad6aa5f5740759d7d75e" }, + { url = "https://mirrors.aliyun.com/pypi/packages/05/1f/39da1c336a87d47c58352fb8a78541ce63d63ae57c5b9dae1fe02801bbc2/greenlet-3.4.0-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:076e21040b3a917d3ce4ad68fb5c3c6b32f1405616c4a57aa83120979649bd3d" }, + { url = "https://mirrors.aliyun.com/pypi/packages/d3/6c/90ee29a4ee27af7aa2e2ec408799eeb69ee3fcc5abcecac6ddd07a5cd0f2/greenlet-3.4.0-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e82689eea4a237e530bb5cb41b180ef81fa2160e1f89422a67be7d90da67f615" }, + { url = "https://mirrors.aliyun.com/pypi/packages/d2/4a/74078d3936712cff6d3c91a930016f476ce4198d84e224fe6d81d3e02880/greenlet-3.4.0-cp314-cp314-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:06c2d3b89e0c62ba50bd7adf491b14f39da9e7e701647cb7b9ff4c99bee04b19" }, + { url = "https://mirrors.aliyun.com/pypi/packages/07/49/d4cad6e5381a50947bb973d2f6cf6592621451b09368b8c20d9b8af49c5b/greenlet-3.4.0-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4df3b0b2289ec686d3c821a5fee44259c05cfe824dd5e6e12c8e5f5df23085cf" }, + { url = "https://mirrors.aliyun.com/pypi/packages/79/3e/df8a83ab894751bc31e1106fdfaa80ca9753222f106b04de93faaa55feb7/greenlet-3.4.0-cp314-cp314-manylinux_2_39_riscv64.whl", hash = "sha256:070b8bac2ff3b4d9e0ff36a0d19e42103331d9737e8504747cd1e659f76297bd" }, + { url = "https://mirrors.aliyun.com/pypi/packages/37/31/d1edd54f424761b5d47718822f506b435b6aab2f3f93b465441143ea5119/greenlet-3.4.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:8bff29d586ea415688f4cec96a591fcc3bf762d046a796cdadc1fdb6e7f2d5bf" }, + { url = "https://mirrors.aliyun.com/pypi/packages/b0/c6/6d3f9cdcb21c4e12a79cb332579f1c6aa1af78eb68059c5a957c7812d95e/greenlet-3.4.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:8a569c2fb840c53c13a2b8967c63621fafbd1a0e015b9c82f408c33d626a2fda" }, + { url = "https://mirrors.aliyun.com/pypi/packages/63/45/c1ca4a1ad975de4727e52d3ffe641ae23e1d7a8ffaa8ff7a0477e1827b92/greenlet-3.4.0-cp314-cp314-win_amd64.whl", hash = "sha256:207ba5b97ea8b0b60eb43ffcacf26969dd83726095161d676aac03ff913ee50d" }, + { url = "https://mirrors.aliyun.com/pypi/packages/71/c4/6f621023364d7e85a4769c014c8982f98053246d142420e0328980933ceb/greenlet-3.4.0-cp314-cp314-win_arm64.whl", hash = "sha256:f8296d4e2b92af34ebde81085a01690f26a51eb9ac09a0fcadb331eb36dbc802" }, + { url = "https://mirrors.aliyun.com/pypi/packages/d4/8f/18d72b629783f5e8d045a76f5325c1e938e659a9e4da79c7dcd10169a48d/greenlet-3.4.0-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:d70012e51df2dbbccfaf63a40aaf9b40c8bed37c3e3a38751c926301ce538ece" }, + { url = "https://mirrors.aliyun.com/pypi/packages/9e/ad/5fa86ec46769c4153820d58a04062285b3b9e10ba3d461ee257b68dcbf53/greenlet-3.4.0-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a58bec0751f43068cd40cff31bb3ca02ad6000b3a51ca81367af4eb5abc480c8" }, + { url = "https://mirrors.aliyun.com/pypi/packages/43/f0/4e8174ca0e87ae748c409f055a1ba161038c43cc0a5a6f1433a26ac2e5bf/greenlet-3.4.0-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:05fa0803561028f4b2e3b490ee41216a842eaee11aed004cc343a996d9523aa2" }, + { url = "https://mirrors.aliyun.com/pypi/packages/ef/92/466b0d9afd44b8af623139a3599d651c7564fa4152f25f117e1ee5949ffb/greenlet-3.4.0-cp314-cp314t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c4cd56a9eb7a6444edbc19062f7b6fbc8f287c663b946e3171d899693b1c19fa" }, + { url = "https://mirrors.aliyun.com/pypi/packages/19/da/991cf7cd33662e2df92a1274b7eb4d61769294d38a1bba8a45f31364845e/greenlet-3.4.0-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e60d38719cb80b3ab5e85f9f1aed4960acfde09868af6762ccb27b260d68f4ed" }, + { url = "https://mirrors.aliyun.com/pypi/packages/0d/14/3395a7ef3e260de0325152ddfe19dffb3e49fe10873b94654352b53ad48e/greenlet-3.4.0-cp314-cp314t-manylinux_2_39_riscv64.whl", hash = "sha256:1f85f204c4d54134ae850d401fa435c89cd667d5ce9dc567571776b45941af72" }, + { url = "https://mirrors.aliyun.com/pypi/packages/36/c5/6c2c708e14db3d9caea4b459d8464f58c32047451142fe2cfd90e7458f41/greenlet-3.4.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7f50c804733b43eded05ae694691c9aa68bca7d0a867d67d4a3f514742a2d53f" }, + { url = "https://mirrors.aliyun.com/pypi/packages/7a/4c/50c5fed19378e11a29fabab1f6be39ea95358f4a0a07e115a51ca93385d8/greenlet-3.4.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:2d4f0635dc4aa638cda4b2f5a07ae9a2cff9280327b581a3fcb6f317b4fbc38a" }, + { url = "https://mirrors.aliyun.com/pypi/packages/db/72/85ae954d734703ab48e622c59d4ce35d77ce840c265814af9c078cacc7aa/greenlet-3.4.0-cp314-cp314t-win_amd64.whl", hash = "sha256:1a4a48f24681300c640f143ba7c404270e1ebbbcf34331d7104a4ff40f8ea705" }, +] + +[[package]] +name = "h11" +version = "0.16.0" +source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86" }, +] + +[[package]] +name = "httpcore" +version = "1.0.9" +source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +dependencies = [ + { name = "certifi" }, + { name = "h11" }, +] +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55" }, +] + +[[package]] +name = "httpx" +version = "0.28.1" +source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +dependencies = [ + { name = "anyio" }, + { name = "certifi" }, + { name = "httpcore" }, + { name = "idna" }, +] +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad" }, +] + +[[package]] +name = "idna" +version = "3.11" +source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea" }, +] + +[[package]] +name = "markdown-it-py" +version = "4.0.0" +source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +dependencies = [ + { name = "mdurl" }, +] +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/5b/f5/4ec618ed16cc4f8fb3b701563655a69816155e79e24a17b651541804721d/markdown_it_py-4.0.0.tar.gz", hash = "sha256:cb0a2b4aa34f932c007117b194e945bd74e0ec24133ceb5bac59009cda1cb9f3" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/94/54/e7d793b573f298e1c9013b8c4dade17d481164aa517d1d7148619c2cedbf/markdown_it_py-4.0.0-py3-none-any.whl", hash = "sha256:87327c59b172c5011896038353a81343b6754500a08cd7a4973bb48c6d578147" }, +] + +[[package]] +name = "mdurl" +version = "0.1.2" +source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8" }, +] + +[[package]] +name = "pillow" +version = "12.2.0" +source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/8c/21/c2bcdd5906101a30244eaffc1b6e6ce71a31bd0742a01eb89e660ebfac2d/pillow-12.2.0.tar.gz", hash = "sha256:a830b1a40919539d07806aa58e1b114df53ddd43213d9c8b75847eee6c0182b5" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/4a/01/53d10cf0dbad820a8db274d259a37ba50b88b24768ddccec07355382d5ad/pillow-12.2.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:8297651f5b5679c19968abefd6bb84d95fe30ef712eb1b2d9b2d31ca61267f4c" }, + { url = "https://mirrors.aliyun.com/pypi/packages/0f/98/f3a6657ecb698c937f6c76ee564882945f29b79bad496abcba0e84659ec5/pillow-12.2.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:50d8520da2a6ce0af445fa6d648c4273c3eeefbc32d7ce049f22e8b5c3daecc2" }, + { url = "https://mirrors.aliyun.com/pypi/packages/69/bc/8986948f05e3ea490b8442ea1c1d4d990b24a7e43d8a51b2c7d8b1dced36/pillow-12.2.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:766cef22385fa1091258ad7e6216792b156dc16d8d3fa607e7545b2b72061f1c" }, + { url = "https://mirrors.aliyun.com/pypi/packages/34/46/6c717baadcd62bc8ed51d238d521ab651eaa74838291bda1f86fe1f864c9/pillow-12.2.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5d2fd0fa6b5d9d1de415060363433f28da8b1526c1c129020435e186794b3795" }, + { url = "https://mirrors.aliyun.com/pypi/packages/71/43/905a14a8b17fdb1ccb58d282454490662d2cb89a6bfec26af6d3520da5ec/pillow-12.2.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:56b25336f502b6ed02e889f4ece894a72612fe885889a6e8c4c80239ff6e5f5f" }, + { url = "https://mirrors.aliyun.com/pypi/packages/73/dd/42107efcb777b16fa0393317eac58f5b5cf30e8392e266e76e51cff28c3d/pillow-12.2.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f1c943e96e85df3d3478f7b691f229887e143f81fedab9b20205349ab04d73ed" }, + { url = "https://mirrors.aliyun.com/pypi/packages/a8/68/b93e09e5e8549019e61acf49f65b1a8530765a7f812c77a7461bca7e4494/pillow-12.2.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:03f6fab9219220f041c74aeaa2939ff0062bd5c364ba9ce037197f4c6d498cd9" }, + { url = "https://mirrors.aliyun.com/pypi/packages/4b/6e/3ccb54ce8ec4ddd1accd2d89004308b7b0b21c4ac3d20fa70af4760a4330/pillow-12.2.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5cdfebd752ec52bf5bb4e35d9c64b40826bc5b40a13df7c3cda20a2c03a0f5ed" }, + { url = "https://mirrors.aliyun.com/pypi/packages/67/ee/21d4e8536afd1a328f01b359b4d3997b291ffd35a237c877b331c1c3b71c/pillow-12.2.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:eedf4b74eda2b5a4b2b2fb4c006d6295df3bf29e459e198c90ea48e130dc75c3" }, + { url = "https://mirrors.aliyun.com/pypi/packages/78/5f/e9f86ab0146464e8c133fe85df987ed9e77e08b29d8d35f9f9f4d6f917ba/pillow-12.2.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:00a2865911330191c0b818c59103b58a5e697cae67042366970a6b6f1b20b7f9" }, + { url = "https://mirrors.aliyun.com/pypi/packages/ed/1e/409007f56a2fdce61584fd3acbc2bbc259857d555196cedcadc68c015c82/pillow-12.2.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1e1757442ed87f4912397c6d35a0db6a7b52592156014706f17658ff58bbf795" }, + { url = "https://mirrors.aliyun.com/pypi/packages/23/c4/7349421080b12fb35414607b8871e9534546c128a11965fd4a7002ccfbee/pillow-12.2.0-cp313-cp313-win32.whl", hash = "sha256:144748b3af2d1b358d41286056d0003f47cb339b8c43a9ea42f5fea4d8c66b6e" }, + { url = "https://mirrors.aliyun.com/pypi/packages/3f/82/8a3739a5e470b3c6cbb1d21d315800d8e16bff503d1f16b03a4ec3212786/pillow-12.2.0-cp313-cp313-win_amd64.whl", hash = "sha256:390ede346628ccc626e5730107cde16c42d3836b89662a115a921f28440e6a3b" }, + { url = "https://mirrors.aliyun.com/pypi/packages/c3/25/f968f618a062574294592f668218f8af564830ccebdd1fa6200f598e65c5/pillow-12.2.0-cp313-cp313-win_arm64.whl", hash = "sha256:8023abc91fba39036dbce14a7d6535632f99c0b857807cbbbf21ecc9f4717f06" }, + { url = "https://mirrors.aliyun.com/pypi/packages/4d/a4/b342930964e3cb4dce5038ae34b0eab4653334995336cd486c5a8c25a00c/pillow-12.2.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:042db20a421b9bafecc4b84a8b6e444686bd9d836c7fd24542db3e7df7baad9b" }, + { url = "https://mirrors.aliyun.com/pypi/packages/9f/de/23198e0a65a9cf06123f5435a5d95cea62a635697f8f03d134d3f3a96151/pillow-12.2.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:dd025009355c926a84a612fecf58bb315a3f6814b17ead51a8e48d3823d9087f" }, + { url = "https://mirrors.aliyun.com/pypi/packages/01/a6/1265e977f17d93ea37aa28aa81bad4fa597933879fac2520d24e021c8da3/pillow-12.2.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:88ddbc66737e277852913bd1e07c150cc7bb124539f94c4e2df5344494e0a612" }, + { url = "https://mirrors.aliyun.com/pypi/packages/3c/83/5982eb4a285967baa70340320be9f88e57665a387e3a53a7f0db8231a0cd/pillow-12.2.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d362d1878f00c142b7e1a16e6e5e780f02be8195123f164edf7eddd911eefe7c" }, + { url = "https://mirrors.aliyun.com/pypi/packages/4e/48/6ffc514adce69f6050d0753b1a18fd920fce8cac87620d5a31231b04bfc5/pillow-12.2.0-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2c727a6d53cb0018aadd8018c2b938376af27914a68a492f59dfcaca650d5eea" }, + { url = "https://mirrors.aliyun.com/pypi/packages/36/a3/f9a77144231fb8d40ee27107b4463e205fa4677e2ca2548e14da5cf18dce/pillow-12.2.0-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:efd8c21c98c5cc60653bcb311bef2ce0401642b7ce9d09e03a7da87c878289d4" }, + { url = "https://mirrors.aliyun.com/pypi/packages/c1/fc/ac4ee3041e7d5a565e1c4fd72a113f03b6394cc72ab7089d27608f8aaccb/pillow-12.2.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9f08483a632889536b8139663db60f6724bfcb443c96f1b18855860d7d5c0fd4" }, + { url = "https://mirrors.aliyun.com/pypi/packages/c0/a8/27fb307055087f3668f6d0a8ccb636e7431d56ed0750e07a60547b1e083e/pillow-12.2.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:dac8d77255a37e81a2efcbd1fc05f1c15ee82200e6c240d7e127e25e365c39ea" }, + { url = "https://mirrors.aliyun.com/pypi/packages/ad/4b/926ab182c07fccae9fcb120043464e1ff1564775ec8864f21a0ebce6ac25/pillow-12.2.0-cp313-cp313t-win32.whl", hash = "sha256:ee3120ae9dff32f121610bb08e4313be87e03efeadfc6c0d18f89127e24d0c24" }, + { url = "https://mirrors.aliyun.com/pypi/packages/c2/c4/f9e476451a098181b30050cc4c9a3556b64c02cf6497ea421ac047e89e4b/pillow-12.2.0-cp313-cp313t-win_amd64.whl", hash = "sha256:325ca0528c6788d2a6c3d40e3568639398137346c3d6e66bb61db96b96511c98" }, + { url = "https://mirrors.aliyun.com/pypi/packages/00/a4/285f12aeacbe2d6dc36c407dfbbe9e96d4a80b0fb710a337f6d2ad978c75/pillow-12.2.0-cp313-cp313t-win_arm64.whl", hash = "sha256:2e5a76d03a6c6dcef67edabda7a52494afa4035021a79c8558e14af25313d453" }, + { url = "https://mirrors.aliyun.com/pypi/packages/bf/98/4595daa2365416a86cb0d495248a393dfc84e96d62ad080c8546256cb9c0/pillow-12.2.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:3adc9215e8be0448ed6e814966ecf3d9952f0ea40eb14e89a102b87f450660d8" }, + { url = "https://mirrors.aliyun.com/pypi/packages/0b/79/40184d464cf89f6663e18dfcf7ca21aae2491fff1a16127681bf1fa9b8cf/pillow-12.2.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:6a9adfc6d24b10f89588096364cc726174118c62130c817c2837c60cf08a392b" }, + { url = "https://mirrors.aliyun.com/pypi/packages/b0/63/703f86fd4c422a9cf722833670f4f71418fb116b2853ff7da722ea43f184/pillow-12.2.0-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:6a6e67ea2e6feda684ed370f9a1c52e7a243631c025ba42149a2cc5934dec295" }, + { url = "https://mirrors.aliyun.com/pypi/packages/71/e0/fb22f797187d0be2270f83500aab851536101b254bfa1eae10795709d283/pillow-12.2.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:2bb4a8d594eacdfc59d9e5ad972aa8afdd48d584ffd5f13a937a664c3e7db0ed" }, + { url = "https://mirrors.aliyun.com/pypi/packages/ba/8c/1a9e46228571de18f8e28f16fabdfc20212a5d019f3e3303452b3f0a580d/pillow-12.2.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:80b2da48193b2f33ed0c32c38140f9d3186583ce7d516526d462645fd98660ae" }, + { url = "https://mirrors.aliyun.com/pypi/packages/70/62/98f6b7f0c88b9addd0e87c217ded307b36be024d4ff8869a812b241d1345/pillow-12.2.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:22db17c68434de69d8ecfc2fe821569195c0c373b25cccb9cbdacf2c6e53c601" }, + { url = "https://mirrors.aliyun.com/pypi/packages/5e/03/688747d2e91cfbe0e64f316cd2e8005698f76ada3130d0194664174fa5de/pillow-12.2.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7b14cc0106cd9aecda615dd6903840a058b4700fcb817687d0ee4fc8b6e389be" }, + { url = "https://mirrors.aliyun.com/pypi/packages/f6/35/577e22b936fcdd66537329b33af0b4ccfefaeabd8aec04b266528cddb33c/pillow-12.2.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8cbeb542b2ebc6fcdacabf8aca8c1a97c9b3ad3927d46b8723f9d4f033288a0f" }, + { url = "https://mirrors.aliyun.com/pypi/packages/11/8d/d2532ad2a603ca2b93ad9f5135732124e57811d0168155852f37fbce2458/pillow-12.2.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4bfd07bc812fbd20395212969e41931001fd59eb55a60658b0e5710872e95286" }, + { url = "https://mirrors.aliyun.com/pypi/packages/5e/26/d325f9f56c7e039034897e7380e9cc202b1e368bfd04d4cbe6a441f02885/pillow-12.2.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:9aba9a17b623ef750a4d11b742cbafffeb48a869821252b30ee21b5e91392c50" }, + { url = "https://mirrors.aliyun.com/pypi/packages/5f/f7/769d5632ffb0988f1c5e7660b3e731e30f7f8ec4318e94d0a5d674eb65a4/pillow-12.2.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:deede7c263feb25dba4e82ea23058a235dcc2fe1f6021025dc71f2b618e26104" }, + { url = "https://mirrors.aliyun.com/pypi/packages/6a/7a/c253e3c645cd47f1aceea6a8bacdba9991bf45bb7dfe927f7c893e89c93c/pillow-12.2.0-cp314-cp314-win32.whl", hash = "sha256:632ff19b2778e43162304d50da0181ce24ac5bb8180122cbe1bf4673428328c7" }, + { url = "https://mirrors.aliyun.com/pypi/packages/cd/8b/601e6566b957ca50e28725cb6c355c59c2c8609751efbecd980db44e0349/pillow-12.2.0-cp314-cp314-win_amd64.whl", hash = "sha256:4e6c62e9d237e9b65fac06857d511e90d8461a32adcc1b9065ea0c0fa3a28150" }, + { url = "https://mirrors.aliyun.com/pypi/packages/d6/94/220e46c73065c3e2951bb91c11a1fb636c8c9ad427ac3ce7d7f3359b9b2f/pillow-12.2.0-cp314-cp314-win_arm64.whl", hash = "sha256:b1c1fbd8a5a1af3412a0810d060a78b5136ec0836c8a4ef9aa11807f2a22f4e1" }, + { url = "https://mirrors.aliyun.com/pypi/packages/b6/ab/1b426a3974cb0e7da5c29ccff4807871d48110933a57207b5a676cccc155/pillow-12.2.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:57850958fe9c751670e49b2cecf6294acc99e562531f4bd317fa5ddee2068463" }, + { url = "https://mirrors.aliyun.com/pypi/packages/19/1e/dce46f371be2438eecfee2a1960ee2a243bbe5e961890146d2dee1ff0f12/pillow-12.2.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:d5d38f1411c0ed9f97bcb49b7bd59b6b7c314e0e27420e34d99d844b9ce3b6f3" }, + { url = "https://mirrors.aliyun.com/pypi/packages/55/c3/7fbecf70adb3a0c33b77a300dc52e424dc22ad8cdc06557a2e49523b703d/pillow-12.2.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5c0a9f29ca8e79f09de89293f82fc9b0270bb4af1d58bc98f540cc4aedf03166" }, + { url = "https://mirrors.aliyun.com/pypi/packages/1c/3c/7fbc17cfb7e4fe0ef1642e0abc17fc6c94c9f7a16be41498e12e2ba60408/pillow-12.2.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:1610dd6c61621ae1cf811bef44d77e149ce3f7b95afe66a4512f8c59f25d9ebe" }, + { url = "https://mirrors.aliyun.com/pypi/packages/ff/c3/a8ae14d6defd2e448493ff512fae903b1e9bd40b72efb6ec55ce0048c8ce/pillow-12.2.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0a34329707af4f73cf1782a36cd2289c0368880654a2c11f027bcee9052d35dd" }, + { url = "https://mirrors.aliyun.com/pypi/packages/6e/32/2880fb3a074847ac159d8f902cb43278a61e85f681661e7419e6596803ed/pillow-12.2.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8e9c4f5b3c546fa3458a29ab22646c1c6c787ea8f5ef51300e5a60300736905e" }, + { url = "https://mirrors.aliyun.com/pypi/packages/46/87/495cc9c30e0129501643f24d320076f4cc54f718341df18cc70ec94c44e1/pillow-12.2.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:fb043ee2f06b41473269765c2feae53fc2e2fbf96e5e22ca94fb5ad677856f06" }, + { url = "https://mirrors.aliyun.com/pypi/packages/18/53/773f5edca692009d883a72211b60fdaf8871cbef075eaa9d577f0a2f989e/pillow-12.2.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:f278f034eb75b4e8a13a54a876cc4a5ab39173d2cdd93a638e1b467fc545ac43" }, + { url = "https://mirrors.aliyun.com/pypi/packages/c9/e4/4b64a97d71b2a83158134abbb2f5bd3f8a2ea691361282f010998f339ec7/pillow-12.2.0-cp314-cp314t-win32.whl", hash = "sha256:6bb77b2dcb06b20f9f4b4a8454caa581cd4dd0643a08bacf821216a16d9c8354" }, + { url = "https://mirrors.aliyun.com/pypi/packages/ba/13/306d275efd3a3453f72114b7431c877d10b1154014c1ebbedd067770d629/pillow-12.2.0-cp314-cp314t-win_amd64.whl", hash = "sha256:6562ace0d3fb5f20ed7290f1f929cae41b25ae29528f2af1722966a0a02e2aa1" }, + { url = "https://mirrors.aliyun.com/pypi/packages/ff/6e/cf826fae916b8658848d7b9f38d88da6396895c676e8086fc0988073aaf8/pillow-12.2.0-cp314-cp314t-win_arm64.whl", hash = "sha256:aa88ccfe4e32d362816319ed727a004423aab09c5cea43c01a4b435643fa34eb" }, +] + +[[package]] +name = "psycopg2-binary" +version = "2.9.12" +source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/2a/60/a3624f79acea344c16fbef3a94d28b89a8042ddfb8f3e4ca83f538671409/psycopg2_binary-2.9.12.tar.gz", hash = "sha256:5ac9444edc768c02a6b6a591f070b8aae28ff3a99be57560ac996001580f294c" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/91/bb/4608c96f970f6e0c56572e87027ef4404f709382a3503e9934526d7ba051/psycopg2_binary-2.9.12-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:7c729a73c7b1b84de3582f73cdd27d905121dc2c531f3d9a3c32a3011033b965" }, + { url = "https://mirrors.aliyun.com/pypi/packages/5e/af/48f76af9d50d61cf390f8cd657b503168b089e2e9298e48465d029fcc713/psycopg2_binary-2.9.12-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:4413d0caef93c5cf50b96863df4c2efe8c269bf2267df353225595e7e15e8df7" }, + { url = "https://mirrors.aliyun.com/pypi/packages/7a/df/aba0f99397cd811d32e06fc0cc781f1f3ce98bc0e729cb423925085d781a/psycopg2_binary-2.9.12-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:4dfcf8e45ebb0c663be34a3442f65e17311f3367089cd4e5e3a3e8e62c978777" }, + { url = "https://mirrors.aliyun.com/pypi/packages/95/9c/eaa74021ac4e4d5c2f83d82fc6615a63f4fe6c94dc4e94c3990427053f67/psycopg2_binary-2.9.12-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c41321a14dd74aceb6a9a643b9253a334521babfa763fa873e33d89cfa122fb5" }, + { url = "https://mirrors.aliyun.com/pypi/packages/35/ed/c25deff98bd26187ba48b3b250a3ffc3037c46c5b89362534a15d200e0db/psycopg2_binary-2.9.12-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:83946ba43979ebfdc99a3cd0ee775c89f221df026984ba19d46133d8d75d3cd9" }, + { url = "https://mirrors.aliyun.com/pypi/packages/9a/81/8d0e21ca77373c6c9589e5c4528f6e8f0c08c62cafc76fb0bddb7a2cee22/psycopg2_binary-2.9.12-cp313-cp313-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:411e85815652d13560fbe731878daa5d92378c4995a22302071890ec3397d019" }, + { url = "https://mirrors.aliyun.com/pypi/packages/00/fc/f481e2435bd8f742d0123309174aae4165160ad3ef17c1b99c3622c241d2/psycopg2_binary-2.9.12-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:1c8ad4c08e00f7679559eaed7aff1edfffc60c086b976f93972f686384a95e2c" }, + { url = "https://mirrors.aliyun.com/pypi/packages/53/79/b9f46466bdbe9f239c96cde8be33c1aace4842f06013b47b730dc9759187/psycopg2_binary-2.9.12-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:00814e40fa23c2b37ef0a1e3c749d89982c73a9cb5046137f0752a22d432e82f" }, + { url = "https://mirrors.aliyun.com/pypi/packages/3f/19/7dc003b32fe35024df89b658104f7c8538a8b2dcbde7a4e746ce929742e7/psycopg2_binary-2.9.12-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:98062447aebc20ed20add1f547a364fd0ef8933640d5372ff1873f8deb9b61be" }, + { url = "https://mirrors.aliyun.com/pypi/packages/91/58/2dbd7db5c604d45f4950d988506aae672a14126ec22998ced5021cbb76bb/psycopg2_binary-2.9.12-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:66a7685d7e548f10fb4ce32fb01a7b7f4aa702134de92a292c7bd9e0d3dbd290" }, + { url = "https://mirrors.aliyun.com/pypi/packages/42/ee/dee8dcaad07f735824de3d6563bc67119fa6c28257b17977a8d624f02fab/psycopg2_binary-2.9.12-cp313-cp313-win_amd64.whl", hash = "sha256:b6937f5fe4e180aeee87de907a2fa982ded6f7f15d7218f78a083e4e1d68f2a0" }, + { url = "https://mirrors.aliyun.com/pypi/packages/13/1b/708c0dca874acfad6d65314271859899a79007686f3a1f74e82a2ed4b645/psycopg2_binary-2.9.12-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:6f3b3de8a74ef8db215f22edffb19e32dc6fa41340456de7ec99efdc8a7b3ec2" }, + { url = "https://mirrors.aliyun.com/pypi/packages/d6/39/ddbea9d4b4de6aca9431b6ed253f530f8a02d3b8f9bcfd0dbfe2b3de6fe4/psycopg2_binary-2.9.12-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1006fb62f0f0bc5ce256a832356c6262e91be43f5e4eb15b5eaf38079464caf2" }, + { url = "https://mirrors.aliyun.com/pypi/packages/bf/a0/bc2fef74b106fa345567122a0659e6d94512ed7dc0131ec44c9e5aba3725/psycopg2_binary-2.9.12-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:840066105706cd2eb29b9a1c2329620056582a4bf3e8169dec5c447042d0869f" }, + { url = "https://mirrors.aliyun.com/pypi/packages/57/d7/d4e3b2005d3de607ca4fbb0e8742e248056e52184a6b94ebda3c1c2c329b/psycopg2_binary-2.9.12-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:863f5d12241ebe1c76a72a04c2113b6dc905f90b9cef0e9be0efd994affd9354" }, + { url = "https://mirrors.aliyun.com/pypi/packages/2e/42/c9853f8db3967fe08bcde11f53d53b85d351750cae726ce001cb68afa9c1/psycopg2_binary-2.9.12-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a99eaab34a9010f1a086b126de467466620a750634d114d20455f3a824aae033" }, + { url = "https://mirrors.aliyun.com/pypi/packages/eb/fd/b82b5601a97630308bef079f545ffec481bbbc795c2ba5ec416a01d03f60/psycopg2_binary-2.9.12-cp314-cp314-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ffdd7dc5463ccd61845ac37b7012d0f35a1548df9febe14f8dd549be4a0bc81e" }, + { url = "https://mirrors.aliyun.com/pypi/packages/62/8c/32ca69b0389ef25dd22937bf9e8fbe2ce27aea20b05ded48c4ce4cb42475/psycopg2_binary-2.9.12-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:54a0dfecab1b48731f934e06139dfe11e24219fb6d0ceb32177cf0375f14c7b5" }, + { url = "https://mirrors.aliyun.com/pypi/packages/c4/29/96992a2b59e3b9d730fcf9612d0a387305025dc867a9fc490a9e496e074e/psycopg2_binary-2.9.12-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:96937c9c5d891f772430f418a7a8b4691a90c3e6b93cf72b5bd7cad8cbca32a5" }, + { url = "https://mirrors.aliyun.com/pypi/packages/56/ad/44b06659949b243ae10112cd3b20a197f9bf3e81d5651379b9eb889bfaad/psycopg2_binary-2.9.12-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:77b348775efd4cdab410ec6609d81ccecd1139c90265fa583a7255c8064bc03d" }, + { url = "https://mirrors.aliyun.com/pypi/packages/1d/f2/10a1bcebadb6aa55e280e1f58975c36a7b560ea525184c7aa4064c466633/psycopg2_binary-2.9.12-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:527e6342b3e44c2f0544f6b8e927d60de7f163f5723b8f1dfa7d2a84298738cd" }, + { url = "https://mirrors.aliyun.com/pypi/packages/20/be/b732c8418ffa5bcfda002890f5dc4c869fc17db66ff11f53b17cfe44afc0/psycopg2_binary-2.9.12-cp314-cp314-win_amd64.whl", hash = "sha256:f12ae41fcafadb39b2785e64a40f9db05d6de2ac114077457e0e7c597f3af980" }, +] + +[[package]] +name = "pybase64" +version = "1.4.3" +source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/aa/b8/4ed5c7ad5ec15b08d35cc79ace6145d5c1ae426e46435f4987379439dfea/pybase64-1.4.3.tar.gz", hash = "sha256:c2ed274c9e0ba9c8f9c4083cfe265e66dd679126cd9c2027965d807352f3f053" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/3a/50/b7170cb2c631944388fe2519507fe3835a4054a6a12a43f43781dae82be1/pybase64-1.4.3-cp313-cp313-android_21_arm64_v8a.whl", hash = "sha256:ea4b785b0607d11950b66ce7c328f452614aefc9c6d3c9c28bae795dc7f072e1" }, + { url = "https://mirrors.aliyun.com/pypi/packages/48/8b/69f50578e49c25e0a26e3ee72c39884ff56363344b79fc3967f5af420ed6/pybase64-1.4.3-cp313-cp313-android_21_x86_64.whl", hash = "sha256:6a10b6330188c3026a8b9c10e6b9b3f2e445779cf16a4c453d51a072241c65a2" }, + { url = "https://mirrors.aliyun.com/pypi/packages/5c/8d/20b68f11adfc4c22230e034b65c71392e3e338b413bf713c8945bd2ccfb3/pybase64-1.4.3-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:27fdff227a0c0e182e0ba37a99109645188978b920dfb20d8b9c17eeee370d0d" }, + { url = "https://mirrors.aliyun.com/pypi/packages/f7/79/b1b550ac6bff51a4880bf6e089008b2e1ca16f2c98db5e039a08ac3ad157/pybase64-1.4.3-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:2a8204f1fdfec5aa4184249b51296c0de95445869920c88123978304aad42df1" }, + { url = "https://mirrors.aliyun.com/pypi/packages/82/70/b5d7c5932bf64ee1ec5da859fbac981930b6a55d432a603986c7f509c838/pybase64-1.4.3-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:874fc2a3777de6baf6aa921a7aa73b3be98295794bea31bd80568a963be30767" }, + { url = "https://mirrors.aliyun.com/pypi/packages/56/fe/e66fe373bce717c6858427670736d54297938dad61c5907517ab4106bd90/pybase64-1.4.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:2dc64a94a9d936b8e3449c66afabbaa521d3cc1a563d6bbaaa6ffa4535222e4b" }, + { url = "https://mirrors.aliyun.com/pypi/packages/80/a9/b806ed1dcc7aed2ea3dd4952286319e6f3a8b48615c8118f453948e01999/pybase64-1.4.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e48f86de1c145116ccf369a6e11720ce696c2ec02d285f440dfb57ceaa0a6cb4" }, + { url = "https://mirrors.aliyun.com/pypi/packages/1c/c9/24b3b905cf75e23a9a4deaf203b35ffcb9f473ac0e6d8257f91a05dfce62/pybase64-1.4.3-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:1d45c8fe8fe82b65c36b227bb4a2cf623d9ada16bed602ce2d3e18c35285b72a" }, + { url = "https://mirrors.aliyun.com/pypi/packages/f8/cd/d15b0c3e25e5859fab0416dc5b96d34d6bd2603c1c96a07bb2202b68ab92/pybase64-1.4.3-cp313-cp313-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:ad70c26ba091d8f5167e9d4e1e86a0483a5414805cdb598a813db635bd3be8b8" }, + { url = "https://mirrors.aliyun.com/pypi/packages/0d/31/4ca953cc3dcde2b3711d6bfd70a6f4ad2ca95a483c9698076ba605f1520f/pybase64-1.4.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:e98310b7c43145221e7194ac9fa7fffc84763c87bfc5e2f59f9f92363475bdc1" }, + { url = "https://mirrors.aliyun.com/pypi/packages/60/55/e7f7bdcd0fd66e61dda08db158ffda5c89a306bbdaaf5a062fbe4e48f4a1/pybase64-1.4.3-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.whl", hash = "sha256:398685a76034e91485a28aeebcb49e64cd663212fd697b2497ac6dfc1df5e671" }, + { url = "https://mirrors.aliyun.com/pypi/packages/cb/65/b592c7f921e51ca1aca3af5b0d201a98666d0a36b930ebb67e7c2ed27395/pybase64-1.4.3-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:7e46400a6461187ccb52ed75b0045d937529e801a53a9cd770b350509f9e4d50" }, + { url = "https://mirrors.aliyun.com/pypi/packages/23/95/1613d2fb82dbb1548595ad4179f04e9a8451bfa18635efce18b631eabe3f/pybase64-1.4.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:1b62b9f2f291d94f5e0b76ab499790b7dcc78a009d4ceea0b0428770267484b6" }, + { url = "https://mirrors.aliyun.com/pypi/packages/9d/73/40431f37f7d1b3eab4673e7946ff1e8f5d6bd425ec257e834dae8a6fc7b0/pybase64-1.4.3-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:f30ceb5fa4327809dede614be586efcbc55404406d71e1f902a6fdcf322b93b2" }, + { url = "https://mirrors.aliyun.com/pypi/packages/a7/84/f6368bcaf9f743732e002a9858646fd7a54f428490d427dd6847c5cfe89e/pybase64-1.4.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:0d5f18ed53dfa1d4cf8b39ee542fdda8e66d365940e11f1710989b3cf4a2ed66" }, + { url = "https://mirrors.aliyun.com/pypi/packages/43/75/359532f9adb49c6b546cafc65c46ed75e2ccc220d514ba81c686fbd83965/pybase64-1.4.3-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:119d31aa4b58b85a8ebd12b63c07681a138c08dfc2fe5383459d42238665d3eb" }, + { url = "https://mirrors.aliyun.com/pypi/packages/92/6c/ade2ba244c3f33ed920a7ed572ad772eb0b5f14480b72d629d0c9e739a40/pybase64-1.4.3-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:3cf0218b0e2f7988cf7d738a73b6a1d14f3be6ce249d7c0f606e768366df2cce" }, + { url = "https://mirrors.aliyun.com/pypi/packages/a0/51/b345139cd236be382f2d4d4453c21ee6299e14d2f759b668e23080f8663f/pybase64-1.4.3-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:12f4ee5e988bc5c0c1106b0d8fc37fb0508f12dab76bac1b098cb500d148da9d" }, + { url = "https://mirrors.aliyun.com/pypi/packages/1a/b8/9f84bdc4f1c4f0052489396403c04be2f9266a66b70c776001eaf0d78c1f/pybase64-1.4.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:937826bc7b6b95b594a45180e81dd4d99bd4dd4814a443170e399163f7ff3fb6" }, + { url = "https://mirrors.aliyun.com/pypi/packages/d0/c7/be63b617d284de46578a366da77ede39c8f8e815ed0d82c7c2acca560fab/pybase64-1.4.3-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:88995d1460971ef80b13e3e007afbe4b27c62db0508bc7250a2ab0a0b4b91362" }, + { url = "https://mirrors.aliyun.com/pypi/packages/5e/96/f252c8f9abd6ded3ef1ccd3cdbb8393a33798007f761b23df8de1a2480e6/pybase64-1.4.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:72326fe163385ed3e1e806dd579d47fde5d8a59e51297a60fc4e6cbc1b4fc4ed" }, + { url = "https://mirrors.aliyun.com/pypi/packages/af/51/0f5714af7aeef96e30f968e4371d75ad60558aaed3579d7c6c8f1c43c18a/pybase64-1.4.3-cp313-cp313-win32.whl", hash = "sha256:b1623730c7892cf5ed0d6355e375416be6ef8d53ab9b284f50890443175c0ac3" }, + { url = "https://mirrors.aliyun.com/pypi/packages/b6/ad/0cea830a654eb08563fb8214150ef57546ece1cc421c09035f0e6b0b5ea9/pybase64-1.4.3-cp313-cp313-win_amd64.whl", hash = "sha256:8369887590f1646a5182ca2fb29252509da7ae31d4923dbb55d3e09da8cc4749" }, + { url = "https://mirrors.aliyun.com/pypi/packages/b4/0d/eec2a8214989c751bc7b4cad1860eb2c6abf466e76b77508c0f488c96a37/pybase64-1.4.3-cp313-cp313-win_arm64.whl", hash = "sha256:860b86bca71e5f0237e2ab8b2d9c4c56681f3513b1bf3e2117290c1963488390" }, + { url = "https://mirrors.aliyun.com/pypi/packages/db/c9/e23463c1a2913686803ef76b1a5ae7e6fac868249a66e48253d17ad7232c/pybase64-1.4.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:eb51db4a9c93215135dccd1895dca078e8785c357fabd983c9f9a769f08989a9" }, + { url = "https://mirrors.aliyun.com/pypi/packages/71/83/343f446b4b7a7579bf6937d2d013d82f1a63057cf05558e391ab6039d7db/pybase64-1.4.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:a03ef3f529d85fd46b89971dfb00c634d53598d20ad8908fb7482955c710329d" }, + { url = "https://mirrors.aliyun.com/pypi/packages/46/fc/cb64964c3b29b432f54d1bce5e7691d693e33bbf780555151969ffd95178/pybase64-1.4.3-cp313-cp313t-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:2e745f2ce760c6cf04d8a72198ef892015ddb89f6ceba489e383518ecbdb13ab" }, + { url = "https://mirrors.aliyun.com/pypi/packages/0a/b7/fab2240da6f4e1ad46f71fa56ec577613cf5df9dce2d5b4cfaa4edd0e365/pybase64-1.4.3-cp313-cp313t-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:6fac217cd9de8581a854b0ac734c50fd1fa4b8d912396c1fc2fce7c230efe3a7" }, + { url = "https://mirrors.aliyun.com/pypi/packages/91/3b/3e2f2b6e68e3d83ddb9fa799f3548fb7449765daec9bbd005a9fbe296d7f/pybase64-1.4.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:da1ee8fa04b283873de2d6e8fa5653e827f55b86bdf1a929c5367aaeb8d26f8a" }, + { url = "https://mirrors.aliyun.com/pypi/packages/6b/08/476ac5914c3b32e0274a2524fc74f01cbf4f4af4513d054e41574eb018f6/pybase64-1.4.3-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.whl", hash = "sha256:b0bf8e884ee822ca7b1448eeb97fa131628fe0ff42f60cae9962789bd562727f" }, + { url = "https://mirrors.aliyun.com/pypi/packages/f1/b8/618a92915330cc9cba7880299b546a1d9dab1a21fd6c0292ee44a4fe608c/pybase64-1.4.3-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:1bf749300382a6fd1f4f255b183146ef58f8e9cb2f44a077b3a9200dfb473a77" }, + { url = "https://mirrors.aliyun.com/pypi/packages/a5/52/af9d8d051652c3051862c442ec3861259c5cdb3fc69774bc701470bd2a59/pybase64-1.4.3-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:153a0e42329b92337664cfc356f2065248e6c9a1bd651bbcd6dcaf15145d3f06" }, + { url = "https://mirrors.aliyun.com/pypi/packages/e4/51/5381a7adf1f381bd184d33203692d3c57cf8ae9f250f380c3fecbdbe554b/pybase64-1.4.3-cp313-cp313t-manylinux_2_31_riscv64.whl", hash = "sha256:86ee56ac7f2184ca10217ed1c655c1a060273e233e692e9086da29d1ae1768db" }, + { url = "https://mirrors.aliyun.com/pypi/packages/e0/f0/578ee4ffce5818017de4fdf544e066c225bc435e73eb4793cde28a689d0b/pybase64-1.4.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:0e71a4db76726bf830b47477e7d830a75c01b2e9b01842e787a0836b0ba741e3" }, + { url = "https://mirrors.aliyun.com/pypi/packages/b9/ad/8ae94814bf20159ea06310b742433e53d5820aa564c9fdf65bf2d79f8799/pybase64-1.4.3-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:2ba7799ec88540acd9861b10551d24656ca3c2888ecf4dba2ee0a71544a8923f" }, + { url = "https://mirrors.aliyun.com/pypi/packages/d1/31/6438cfcc3d3f0fa84d229fa125c243d5094e72628e525dfefadf3bcc6761/pybase64-1.4.3-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:2860299e4c74315f5951f0cf3e72ba0f201c3356c8a68f95a3ab4e620baf44e9" }, + { url = "https://mirrors.aliyun.com/pypi/packages/a3/0d/2bbc9e9c3fc12ba8a6e261482f03a544aca524f92eae0b4908c0a10ba481/pybase64-1.4.3-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:bb06015db9151f0c66c10aae8e3603adab6b6cd7d1f7335a858161d92fc29618" }, + { url = "https://mirrors.aliyun.com/pypi/packages/2c/0b/34d491e7f49c1dbdb322ea8da6adecda7c7cd70b6644557c6e4ca5c6f7c7/pybase64-1.4.3-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:242512a070817272865d37c8909059f43003b81da31f616bb0c391ceadffe067" }, + { url = "https://mirrors.aliyun.com/pypi/packages/ce/17/c21d0cde2a6c766923ae388fc1f78291e1564b0d38c814b5ea8a0e5e081c/pybase64-1.4.3-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:5d8277554a12d3e3eed6180ebda62786bf9fc8d7bb1ee00244258f4a87ca8d20" }, + { url = "https://mirrors.aliyun.com/pypi/packages/92/b2/eaa67038916a48de12b16f4c384bcc1b84b7ec731b23613cb05f27673294/pybase64-1.4.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:f40b7ddd698fc1e13a4b64fbe405e4e0e1279e8197e37050e24154655f5f7c4e" }, + { url = "https://mirrors.aliyun.com/pypi/packages/42/10/abb7757c330bb869ebb95dab0c57edf5961ffbd6c095c8209cbbf75d117d/pybase64-1.4.3-cp313-cp313t-win32.whl", hash = "sha256:46d75c9387f354c5172582a9eaae153b53a53afeb9c19fcf764ea7038be3bd8b" }, + { url = "https://mirrors.aliyun.com/pypi/packages/63/a0/2d4e5a59188e9e6aed0903d580541aaea72dcbbab7bf50fb8b83b490b6c3/pybase64-1.4.3-cp313-cp313t-win_amd64.whl", hash = "sha256:d7344625591d281bec54e85cbfdab9e970f6219cac1570f2aa140b8c942ccb81" }, + { url = "https://mirrors.aliyun.com/pypi/packages/1f/05/95b902e8f567b4d4b41df768ccc438af618f8d111e54deaf57d2df46bd76/pybase64-1.4.3-cp313-cp313t-win_arm64.whl", hash = "sha256:28a3c60c55138e0028313f2eccd321fec3c4a0be75e57a8d3eb883730b1b0880" }, + { url = "https://mirrors.aliyun.com/pypi/packages/e4/80/4bd3dff423e5a91f667ca41982dc0b79495b90ec0c0f5d59aca513e50f8c/pybase64-1.4.3-cp314-cp314-android_24_arm64_v8a.whl", hash = "sha256:015bb586a1ea1467f69d57427abe587469392215f59db14f1f5c39b52fdafaf5" }, + { url = "https://mirrors.aliyun.com/pypi/packages/45/60/a94d94cc1e3057f602e0b483c9ebdaef40911d84a232647a2fe593ab77bb/pybase64-1.4.3-cp314-cp314-android_24_x86_64.whl", hash = "sha256:d101e3a516f837c3dcc0e5a0b7db09582ebf99ed670865223123fb2e5839c6c0" }, + { url = "https://mirrors.aliyun.com/pypi/packages/e3/71/cf62b261d431857e8e054537a5c3c24caafa331de30daede7b2c6c558501/pybase64-1.4.3-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:8f183ac925a48046abe047360fe3a1b28327afb35309892132fe1915d62fb282" }, + { url = "https://mirrors.aliyun.com/pypi/packages/24/3e/d12f92a3c1f7c6ab5d53c155bff9f1084ba997a37a39a4f781ccba9455f3/pybase64-1.4.3-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:30bf3558e24dcce4da5248dcf6d73792adfcf4f504246967e9db155be4c439ad" }, + { url = "https://mirrors.aliyun.com/pypi/packages/9b/3d/9c27440031fea0d05146f8b70a460feb95d8b4e3d9ca8f45c972efb4c3d3/pybase64-1.4.3-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:a674b419de318d2ce54387dd62646731efa32b4b590907800f0bd40675c1771d" }, + { url = "https://mirrors.aliyun.com/pypi/packages/4b/d4/6c0e0cf0efd53c254173fbcd84a3d8fcbf5e0f66622473da425becec32a5/pybase64-1.4.3-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:720104fd7303d07bac302be0ff8f7f9f126f2f45c1edb4f48fdb0ff267e69fe1" }, + { url = "https://mirrors.aliyun.com/pypi/packages/50/eb/27cb0b610d5cd70f5ad0d66c14ad21c04b8db930f7139818e8fbdc14df4d/pybase64-1.4.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:83f1067f73fa5afbc3efc0565cecc6ed53260eccddef2ebe43a8ce2b99ea0e0a" }, + { url = "https://mirrors.aliyun.com/pypi/packages/db/26/b136a4b65e5c94ff06217f7726478df3f31ab1c777c2c02cf698e748183f/pybase64-1.4.3-cp314-cp314-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:b51204d349a4b208287a8aa5b5422be3baa88abf6cc8ff97ccbda34919bbc857" }, + { url = "https://mirrors.aliyun.com/pypi/packages/68/6d/84ce50e7ee1ae79984d689e05a9937b2460d4efa1e5b202b46762fb9036c/pybase64-1.4.3-cp314-cp314-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:30f2fd53efecbdde4bdca73a872a68dcb0d1bf8a4560c70a3e7746df973e1ef3" }, + { url = "https://mirrors.aliyun.com/pypi/packages/e3/57/6743e420416c3ff1b004041c85eb0ebd9c50e9cf05624664bfa1dc8b5625/pybase64-1.4.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:0932b0c5cfa617091fd74f17d24549ce5de3628791998c94ba57be808078eeaf" }, + { url = "https://mirrors.aliyun.com/pypi/packages/3b/68/733324e28068a89119af2921ce548e1c607cc5c17d354690fc51c302e326/pybase64-1.4.3-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.whl", hash = "sha256:acb61f5ab72bec808eb0d4ce8b87ec9f38d7d750cb89b1371c35eb8052a29f11" }, + { url = "https://mirrors.aliyun.com/pypi/packages/b5/9e/f3f4aa8cfe3357a3cdb0535b78eb032b671519d3ecc08c58c4c6b72b5a91/pybase64-1.4.3-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:2bc2d5bc15168f5c04c53bdfe5a1e543b2155f456ed1e16d7edce9ce73842021" }, + { url = "https://mirrors.aliyun.com/pypi/packages/aa/d1/53286038e1f0df1cf58abcf4a4a91b0f74ab44539c2547b6c31001ddd054/pybase64-1.4.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:8a7bc3cd23880bdca59758bcdd6f4ef0674f2393782763910a7466fab35ccb98" }, + { url = "https://mirrors.aliyun.com/pypi/packages/00/9a/5cc6ce95db2383d27ff4d790b8f8b46704d360d701ab77c4f655bcfaa6a7/pybase64-1.4.3-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:ad15acf618880d99792d71e3905b0e2508e6e331b76a1b34212fa0f11e01ad28" }, + { url = "https://mirrors.aliyun.com/pypi/packages/64/e7/c3c1d09c3d7ae79e3aa1358c6d912d6b85f29281e47aa94fc0122a415a2f/pybase64-1.4.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:448158d417139cb4851200e5fee62677ae51f56a865d50cda9e0d61bda91b116" }, + { url = "https://mirrors.aliyun.com/pypi/packages/db/d5/0baa08e3d8119b15b588c39f0d39fd10472f0372e3c54ca44649cbefa256/pybase64-1.4.3-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:9058c49b5a2f3e691b9db21d37eb349e62540f9f5fc4beabf8cbe3c732bead86" }, + { url = "https://mirrors.aliyun.com/pypi/packages/00/87/fc6f11474a1de7e27cd2acbb8d0d7508bda3efa73dfe91c63f968728b2a3/pybase64-1.4.3-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:ce561724f6522907a66303aca27dce252d363fcd85884972d348f4403ba3011a" }, + { url = "https://mirrors.aliyun.com/pypi/packages/69/9d/7fb5566f669ac18b40aa5fc1c438e24df52b843c1bdc5da47d46d4c1c630/pybase64-1.4.3-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:63316560a94ac449fe86cb8b9e0a13714c659417e92e26a5cbf085cd0a0c838d" }, + { url = "https://mirrors.aliyun.com/pypi/packages/de/cc/ceb949232dbbd3ec4ee0190d1df4361296beceee9840390a63df8bc31784/pybase64-1.4.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:7ecd796f2ac0be7b73e7e4e232b8c16422014de3295d43e71d2b19fd4a4f5368" }, + { url = "https://mirrors.aliyun.com/pypi/packages/a7/69/659f3c8e6a5d7b753b9c42a4bd9c42892a0f10044e9c7351a4148d413a33/pybase64-1.4.3-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:d01e102a12fb2e1ed3dc11611c2818448626637857ec3994a9cf4809dfd23477" }, + { url = "https://mirrors.aliyun.com/pypi/packages/85/2c/29c9e6c9c82b72025f9676f9e82eb1fd2339ad038cbcbf8b9e2ac02798fc/pybase64-1.4.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ebff797a93c2345f22183f454fd8607a34d75eca5a3a4a969c1c75b304cee39d" }, + { url = "https://mirrors.aliyun.com/pypi/packages/b9/84/5a3dce8d7a0040a5c0c14f0fe1311cd8db872913fa04438071b26b0dac04/pybase64-1.4.3-cp314-cp314-win32.whl", hash = "sha256:28b2a1bb0828c0595dc1ea3336305cd97ff85b01c00d81cfce4f92a95fb88f56" }, + { url = "https://mirrors.aliyun.com/pypi/packages/57/bc/ce7427c12384adee115b347b287f8f3cf65860b824d74fe2c43e37e81c1f/pybase64-1.4.3-cp314-cp314-win_amd64.whl", hash = "sha256:33338d3888700ff68c3dedfcd49f99bfc3b887570206130926791e26b316b029" }, + { url = "https://mirrors.aliyun.com/pypi/packages/9a/1b/2b8ffbe9a96eef7e3f6a5a7be75995eebfb6faaedc85b6da6b233e50c778/pybase64-1.4.3-cp314-cp314-win_arm64.whl", hash = "sha256:62725669feb5acb186458da2f9353e88ae28ef66bb9c4c8d1568b12a790dfa94" }, + { url = "https://mirrors.aliyun.com/pypi/packages/ac/d8/6824c2e6fb45b8fa4e7d92e3c6805432d5edc7b855e3e8e1eedaaf6efb7c/pybase64-1.4.3-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:153fe29be038948d9372c3e77ae7d1cab44e4ba7d9aaf6f064dbeea36e45b092" }, + { url = "https://mirrors.aliyun.com/pypi/packages/ea/e5/10d2b3a4ad3a4850be2704a2f70cd9c0cf55725c8885679872d3bc846c67/pybase64-1.4.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:f7fe3decaa7c4a9e162327ec7bd81ce183d2b16f23c6d53b606649c6e0203e9e" }, + { url = "https://mirrors.aliyun.com/pypi/packages/43/04/8b15c34d3c2282f1c1b0850f1113a249401b618a382646a895170bc9b5e7/pybase64-1.4.3-cp314-cp314t-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:a5ae04ea114c86eb1da1f6e18d75f19e3b5ae39cb1d8d3cd87c29751a6a22780" }, + { url = "https://mirrors.aliyun.com/pypi/packages/42/00/f34b4d11278f8fdc68bc38f694a91492aa318f7c6f1bd7396197ac0f8b12/pybase64-1.4.3-cp314-cp314t-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:1755b3dce3a2a5c7d17ff6d4115e8bee4a1d5aeae74469db02e47c8f477147da" }, + { url = "https://mirrors.aliyun.com/pypi/packages/bb/5d/71747d4ad7fe16df4c4c852bdbdeb1f2cf35677b48d7c34d3011a7a6ad3a/pybase64-1.4.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:fb852f900e27ffc4ec1896817535a0fa19610ef8875a096b59f21d0aa42ff172" }, + { url = "https://mirrors.aliyun.com/pypi/packages/49/b1/d1e82bd58805bb5a3a662864800bab83a83a36ba56e7e3b1706c708002a5/pybase64-1.4.3-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.whl", hash = "sha256:9cf21ea8c70c61eddab3421fbfce061fac4f2fb21f7031383005a1efdb13d0b9" }, + { url = "https://mirrors.aliyun.com/pypi/packages/15/67/16c609b7a13d1d9fc87eca12ba2dce5e67f949eeaab61a41bddff843cbb0/pybase64-1.4.3-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:afff11b331fdc27692fc75e85ae083340a35105cea1a3c4552139e2f0e0d174f" }, + { url = "https://mirrors.aliyun.com/pypi/packages/3c/11/37bc724e42960f0106c2d33dc957dcec8f760c91a908cc6c0df7718bc1a8/pybase64-1.4.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:d9a5143df542c1ce5c1f423874b948c4d689b3f05ec571f8792286197a39ba02" }, + { url = "https://mirrors.aliyun.com/pypi/packages/6e/66/b2b962a6a480dd5dae3029becf03ea1a650d326e39bf1c44ea3db78bb010/pybase64-1.4.3-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:d62e9861019ad63624b4a7914dff155af1cc5d6d79df3be14edcaedb5fdad6f9" }, + { url = "https://mirrors.aliyun.com/pypi/packages/2b/15/9b6d711035e29b18b2e1c03d47f41396d803d06ef15b6c97f45b75f73f04/pybase64-1.4.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:84cfd4d92668ef5766cc42a9c9474b88960ac2b860767e6e7be255c6fddbd34a" }, + { url = "https://mirrors.aliyun.com/pypi/packages/b4/21/e2901381ed0df62e2308380f30d9c4d87d6b74e33a84faed3478d33a7197/pybase64-1.4.3-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:60fc025437f9a7c2cc45e0c19ed68ed08ba672be2c5575fd9d98bdd8f01dd61f" }, + { url = "https://mirrors.aliyun.com/pypi/packages/c4/16/3d788388a178a0407aa814b976fe61bfa4af6760d9aac566e59da6e4a8b4/pybase64-1.4.3-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:edc8446196f04b71d3af76c0bd1fe0a45066ac5bffecca88adb9626ee28c266f" }, + { url = "https://mirrors.aliyun.com/pypi/packages/a6/63/c15b1f8bd47ea48a5a2d52a4ec61f037062932ea6434ab916107b58e861e/pybase64-1.4.3-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:e99f6fa6509c037794da57f906ade271f52276c956d00f748e5b118462021d48" }, + { url = "https://mirrors.aliyun.com/pypi/packages/bd/b8/f544a2e37c778d59208966d4ef19742a0be37c12fc8149ff34483c176616/pybase64-1.4.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:d94020ef09f624d841aa9a3a6029df8cf65d60d7a6d5c8687579fa68bd679b65" }, + { url = "https://mirrors.aliyun.com/pypi/packages/03/99/1fae8a3b7ac181e36f6e7864a62d42d5b1f4fa7edf408c6711e28fba6b4d/pybase64-1.4.3-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:f64ce70d89942a23602dee910dec9b48e5edf94351e1b378186b74fcc00d7f66" }, + { url = "https://mirrors.aliyun.com/pypi/packages/9d/9e/cd4c727742345ad8384569a4466f1a1428f4e5cc94d9c2ab2f53d30be3fe/pybase64-1.4.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:8ea99f56e45c469818b9781903be86ba4153769f007ba0655fa3b46dc332803d" }, + { url = "https://mirrors.aliyun.com/pypi/packages/28/86/a236ecfc5b494e1e922da149689f690abc84248c7c1358f5605b8c9fdd60/pybase64-1.4.3-cp314-cp314t-win32.whl", hash = "sha256:343b1901103cc72362fd1f842524e3bb24978e31aea7ff11e033af7f373f66ab" }, + { url = "https://mirrors.aliyun.com/pypi/packages/56/ce/ca8675f8d1352e245eb012bfc75429ee9cf1f21c3256b98d9a329d44bf0f/pybase64-1.4.3-cp314-cp314t-win_amd64.whl", hash = "sha256:57aff6f7f9dea6705afac9d706432049642de5b01080d3718acc23af87c5af76" }, + { url = "https://mirrors.aliyun.com/pypi/packages/3b/30/4a675864877397179b09b720ee5fcb1cf772cf7bebc831989aff0a5f79c1/pybase64-1.4.3-cp314-cp314t-win_arm64.whl", hash = "sha256:e906aa08d4331e799400829e0f5e4177e76a3281e8a4bc82ba114c6b30e405c9" }, +] + +[[package]] +name = "pycparser" +version = "3.0" +source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992" }, +] + +[[package]] +name = "pydantic" +version = "2.13.2" +source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +dependencies = [ + { name = "annotated-types" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/09/e5/06d23afac9973109d1e3c8ad38e1547a12e860610e327c05ee686827dc37/pydantic-2.13.2.tar.gz", hash = "sha256:b418196607e61081c3226dcd4f0672f2a194828abb9109e9cfb84026564df2d1" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/77/ca/b45c378e6e8d0b90577288b533e04e95b7afd61bb1d51b6c263176435489/pydantic-2.13.2-py3-none-any.whl", hash = "sha256:a525087f4c03d7e7456a3de89b64cd693d2229933bb1068b9af6befd5563694e" }, +] + +[[package]] +name = "pydantic-core" +version = "2.46.2" +source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/43/bb/4742f05b739b2478459bb16fa8470549518c802e06ddcf3f106c5081315e/pydantic_core-2.46.2.tar.gz", hash = "sha256:37bb079f9ee3f1a519392b73fda2a96379b31f2013c6b467fe693e7f2987f596" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/07/2b/662e48254479a2d3450ba24b1e25061108b64339794232f503990c519144/pydantic_core-2.46.2-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:d26e9eea3715008a09a74585fe9becd0c67fbb145dc4df9756d597d7230a652c" }, + { url = "https://mirrors.aliyun.com/pypi/packages/73/ab/bafd7c7503757ccc8ec4d1911e106fe474c629443648c51a88f08b0fe91a/pydantic_core-2.46.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:48b36e3235140510dc7861f0cd58b714b1cdd3d48f75e10ce52e69866b746f10" }, + { url = "https://mirrors.aliyun.com/pypi/packages/92/cc/7549c2d57ba2e9a42caa5861a2d398dbe31c02c6aca783253ace59ce84f8/pydantic_core-2.46.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:36b1f99dc451f1a3981f236151465bcf995bbe712d0727c9f7b236fe228a8133" }, + { url = "https://mirrors.aliyun.com/pypi/packages/18/50/7ed4a8a0d478a4dca8f0134a5efa7193f03cc8520dd4c9509339fb2e5002/pydantic_core-2.46.2-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8641c8d535c2d95b45c2e19b646ecd23ebba35d461e0ae48a3498277006250ab" }, + { url = "https://mirrors.aliyun.com/pypi/packages/dc/16/bb35b193741c0298ddc5f5e4234269efdc0c65e2bcd198aa0de9b68845e4/pydantic_core-2.46.2-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:20fb194788a0a50993e87013e693494ba183a2af5b44e99cf060bbae10912b11" }, + { url = "https://mirrors.aliyun.com/pypi/packages/91/a5/98f4b637149185addea19e1785ea20c373cca31b202f589111d8209d9873/pydantic_core-2.46.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9262d11d0cd11ee3303a95156939402bed6cedfe5ed0e331b95a283a4da6eb8b" }, + { url = "https://mirrors.aliyun.com/pypi/packages/36/90/93a5d21990b152da7b7507b7fddb0b935f6a0984d57ac3ec45a6e17777a2/pydantic_core-2.46.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ac204542736aa295fa25f713b7fad6fc50b46ab7764d16087575c85f085174f3" }, + { url = "https://mirrors.aliyun.com/pypi/packages/14/22/b8b1ffdddf08b4e84380bcb67f41dbbf4c171377c1d36fc6290794bb2094/pydantic_core-2.46.2-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:9a7c43a0584742dface3ca0daf6f719d46c1ac2f87cf080050f9ae052c75e1b2" }, + { url = "https://mirrors.aliyun.com/pypi/packages/c6/26/e60d72b4e2d0ce1fa811044a974412ac1c567fe067d97b3e6b290530786e/pydantic_core-2.46.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fd05e1edb6a90ad446fa268ab09e59202766b837597b714b2492db11ee87fab9" }, + { url = "https://mirrors.aliyun.com/pypi/packages/35/32/36bec7584a1eefb17dec4dfa1c946d3fe4440f466c5705b8adfda69c9a9f/pydantic_core-2.46.2-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:91155b110788b5501abc7ea954f1d08606219e4e28e3c73a94124307c06efb80" }, + { url = "https://mirrors.aliyun.com/pypi/packages/fc/d6/1a5689d873620efd67d6b163db0c444c056adb0849b5bc33e2b9f09665a6/pydantic_core-2.46.2-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:e4e2c72a529fa03ff228be1d2b76944013f428220b764e03cc50ada67e17a42c" }, + { url = "https://mirrors.aliyun.com/pypi/packages/3e/8e/675104802abe8ef502b072050ee5f2e915251aa1a3af87e1015ce31ec42d/pydantic_core-2.46.2-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:56291ec1a11c3499890c99a8fd9053b47e60fe837a77ec72c0671b1b8b3dce24" }, + { url = "https://mirrors.aliyun.com/pypi/packages/8d/bc/86c5dde4fa6e24467680eef5047da3c1a19be0a527d0d8e14aa76b39307c/pydantic_core-2.46.2-cp313-cp313-win32.whl", hash = "sha256:b50f9c5f826ddca1246f055148df939f5f3f2d0d96db73de28e2233f22210d4c" }, + { url = "https://mirrors.aliyun.com/pypi/packages/2a/97/2537e8c1282b2c4eb062580c0d7a4339e10b072b803d1ee0b7f1f0a5c22c/pydantic_core-2.46.2-cp313-cp313-win_amd64.whl", hash = "sha256:251a57788823230ca8cbc99e6245d1a2ed6e180ec4864f251c94182c580c7f2e" }, + { url = "https://mirrors.aliyun.com/pypi/packages/da/aa/2ee75798706f9dbc4e76dbe59e41a396c5c311e3d6223b9cf6a5fa7780be/pydantic_core-2.46.2-cp313-cp313-win_arm64.whl", hash = "sha256:315d32d1a71494d6b4e1e14a9fa7a4329597b4c4340088ad7e1a9dafbeed92a9" }, + { url = "https://mirrors.aliyun.com/pypi/packages/d0/96/a50ccb6b539ae780f73cea74905468777680e30c6c3bdf714b9d4c116ea0/pydantic_core-2.46.2-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:4f59b45f3ef8650c0c736a57f59031d47ed9df4c0a64e83796849d7d14863a2d" }, + { url = "https://mirrors.aliyun.com/pypi/packages/34/5f/fdead7b3afa822ab6e5a18ee0ecffd54937de1877c01ed13a342e0fb3f07/pydantic_core-2.46.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:3a075a29ebef752784a91532a1a85be6b234ccffec0a9d7978a92696387c3da6" }, + { url = "https://mirrors.aliyun.com/pypi/packages/95/e0/1c5d547e550cdab1bec737492aa08865337af6fe7fc9b96f7f45f17d9519/pydantic_core-2.46.2-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0d12d786e30c04a9d307c5d7080bf720d9bac7f1668191d8e37633a9562749e2" }, + { url = "https://mirrors.aliyun.com/pypi/packages/0e/cb/665ce629e218c8228302cb94beff4f6531082a2c87d3ecc3d5e63a26f392/pydantic_core-2.46.2-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:0d5e6d6343b0b5dcacb3503b5de90022968da8ed0ab9ab39d3eda71c20cbf84e" }, + { url = "https://mirrors.aliyun.com/pypi/packages/77/e9/6cb2cf60f54c1472bbdfce19d957553b43dbba79d1d7b2930a195c594785/pydantic_core-2.46.2-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:233eebac0999b6b9ba76eb56f3ec8fce13164aa16b6d2225a36a79e0f95b5973" }, + { url = "https://mirrors.aliyun.com/pypi/packages/0d/2a/93e018dd5571f781ebaeda8c0cf65398489d5bee9b1f484df0b6149b43b9/pydantic_core-2.46.2-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9cc0eee720dd2f14f3b7c349469402b99ad81a174ab49d3533974529e9d93992" }, + { url = "https://mirrors.aliyun.com/pypi/packages/5e/4f/49e57ca55c770c93d9bb046666a54949b42e3c9099a0c5fe94557873fe30/pydantic_core-2.46.2-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:83ee76bf2c9910513dbc19e7d82367131fa7508dedd6186a462393071cc11059" }, + { url = "https://mirrors.aliyun.com/pypi/packages/c6/b0/6e46b5cd3332af665f794b8cdeea206618a8630bd9e7bcc36864518fce81/pydantic_core-2.46.2-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:d61db38eb4ee5192f0c261b7f2d38e420b554df8912245e3546aee5c45e2fd78" }, + { url = "https://mirrors.aliyun.com/pypi/packages/06/d1/40850c81585be443a2abfdf7f795f8fae831baf8e2f9b2133c8246ac671c/pydantic_core-2.46.2-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:8f09a713d17bcd55da8ab02ebd9110c5246a49c44182af213b5212800af8bc83" }, + { url = "https://mirrors.aliyun.com/pypi/packages/04/af/8493d7dfa03ebb7866909e577c6aa65ea0de7377b86023cc51d0c8e11db3/pydantic_core-2.46.2-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:30cacc5fb696e64b8ef6fd31d9549d394dd7d52760db072eecb98e37e3af1677" }, + { url = "https://mirrors.aliyun.com/pypi/packages/72/5b/1f6a344c4ffdf284da41c6067b82d5ebcbd11ce1b515ae4b662d4adb6f61/pydantic_core-2.46.2-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:7ccfb105fcfe91a22bbb5563ad3dc124bc1aa75bfd2e53a780ab05f78cdf6108" }, + { url = "https://mirrors.aliyun.com/pypi/packages/25/ff/9a694126c12d6d2f48a0cafa6f8eef88ef0d8825600e18d03ff2e896c3b2/pydantic_core-2.46.2-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:13ffef637dc8370c249e5b26bd18e9a80a4fca3d809618c44e18ec834a7ca7a8" }, + { url = "https://mirrors.aliyun.com/pypi/packages/51/c8/3a35c763d68a9cb2675eb10ef242cf66c5d4701b28ae12e688d67d2c180e/pydantic_core-2.46.2-cp314-cp314-win32.whl", hash = "sha256:1b0ab6d756ca2704a938e6c31b53f290c2f9c10d3914235410302a149de1a83e" }, + { url = "https://mirrors.aliyun.com/pypi/packages/1a/6a/f2726a780365f7dfd89d62036f984f7acb99978c60c5e1fa7c0cb898ed11/pydantic_core-2.46.2-cp314-cp314-win_amd64.whl", hash = "sha256:99ebade8c9ada4df975372d8dd25883daa0e379a05f1cd0c99aa0c04368d01a6" }, + { url = "https://mirrors.aliyun.com/pypi/packages/e1/79/76baacb9feba3d7c399b245ca1a29c74ea0db04ea693811374827eec2290/pydantic_core-2.46.2-cp314-cp314-win_arm64.whl", hash = "sha256:de87422197cf7f83db91d89c86a21660d749b3cd76cd8a45d115b8e675670f02" }, + { url = "https://mirrors.aliyun.com/pypi/packages/f1/3b/77c26938f817668d9ad9bab1a905cb23f11d9a3d4bf724d429b3e55a8eaf/pydantic_core-2.46.2-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:236f22b4a206b5b61db955396b7cf9e2e1ff77f372efe9570128ccfcd6a525eb" }, + { url = "https://mirrors.aliyun.com/pypi/packages/fe/de/42c13f590e3c260966aa49bcdb1674774f975467c49abd51191e502bea28/pydantic_core-2.46.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c2012f64d2cd7cca50f49f22445aa5a88691ac2b4498ee0a9a977f8ca4f7289f" }, + { url = "https://mirrors.aliyun.com/pypi/packages/4e/84/ebe3ebb3e2d8db656937cfa6f97f544cb7132f2307a4a7dfdcd0ea102a12/pydantic_core-2.46.2-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d07d6c63106d3a9c9a333e2636f9c82c703b1a9e3b079299e58747964e4fdb72" }, + { url = "https://mirrors.aliyun.com/pypi/packages/b9/15/0bf51ca6709477cd4ef86148b6d7844f3308f029eac361dd0383f1e17b1a/pydantic_core-2.46.2-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c326a2b4b85e959d9a1fc3a11f32f84611b6ec07c053e1828a860edf8d068208" }, + { url = "https://mirrors.aliyun.com/pypi/packages/02/ae/b7b5af9b79db036d9e61a44c481c17a213dc8fc4b8b71fe6875a72fc778b/pydantic_core-2.46.2-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ac8a65e798f2462552c00d2e013d532c94d646729dda98458beaf51f9ec7b120" }, + { url = "https://mirrors.aliyun.com/pypi/packages/a6/ae/ecef7477b5a03d4a499708f7e75d2836452ebb70b776c2d64612b334f57a/pydantic_core-2.46.2-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5a3c2bc1cc8164bedbc160b7bb1e8cc1e8b9c27f69ae4f9ae2b976cdae02b2dd" }, + { url = "https://mirrors.aliyun.com/pypi/packages/db/e4/2f9d82faa47af6c39fc3f120145fd915971e1e0cb6b55b494fad9fdf8275/pydantic_core-2.46.2-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e69aa5e10b7e8b1bb4a6888650fd12fcbf11d396ca11d4a44de1450875702830" }, + { url = "https://mirrors.aliyun.com/pypi/packages/f1/9c/677cf10873fbd0b116575ab7b97c90482b21564f8a8040beb18edef7a577/pydantic_core-2.46.2-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:4e6df5c3301e65fb42bc5338bf9a1027a02b0a31dc7f54c33775229af474daf0" }, + { url = "https://mirrors.aliyun.com/pypi/packages/d6/53/6a06183544daba51c059123a2064a99039df25f115a06bdb26f2ea177038/pydantic_core-2.46.2-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2c2f6e32548ac8d559b47944effcf8ae4d81c161f6b6c885edc53bc08b8f192d" }, + { url = "https://mirrors.aliyun.com/pypi/packages/57/6f/10fcdd9e3eca66fc828eef0f6f5850f2dd3bca2c59e6e041fb8bc3da39be/pydantic_core-2.46.2-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:b089a81c58e6ea0485562bbbbbca4f65c0549521606d5ef27fba217aac9b665a" }, + { url = "https://mirrors.aliyun.com/pypi/packages/29/83/92d3fd0e0156cad2e3cb5c26de73794af78ac9fa0c22ab666e566dd67061/pydantic_core-2.46.2-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:7f700a6d6f64112ae9193709b84303bbab84424ad4b47d0253301aabce9dfc70" }, + { url = "https://mirrors.aliyun.com/pypi/packages/97/f1/facffdb970981068219582e499b8d0871ed163ffcc6b347de5c412669e4c/pydantic_core-2.46.2-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:67db6814beaa5fefe91101ec7eb9efda613795767be96f7cf58b1ca8c9ca9972" }, + { url = "https://mirrors.aliyun.com/pypi/packages/8b/a1/b8160b2f22b2199467bc68581a4ed380643c16b348a27d6165c6c242d694/pydantic_core-2.46.2-cp314-cp314t-win32.whl", hash = "sha256:32fbc7447be8e3be99bf7869f7066308f16be55b61f9882c2cefc7931f5c7664" }, + { url = "https://mirrors.aliyun.com/pypi/packages/0d/90/db89acabe5b150e11d1b59fe3d947dda2ef6abbfef5c82f056ff63802f5d/pydantic_core-2.46.2-cp314-cp314t-win_amd64.whl", hash = "sha256:b317a2b97019c0b95ce99f4f901ae383f40132da6706cdf1731066a73394c25c" }, + { url = "https://mirrors.aliyun.com/pypi/packages/97/32/e19b83ceb07a3f1bb21798407790bbc9a31740158fd132b94139cb84e16c/pydantic_core-2.46.2-cp314-cp314t-win_arm64.whl", hash = "sha256:7dcb9d40930dfad7ab6b20bcc6ca9d2b030b0f347a0cd9909b54bd53ead521b1" }, +] + +[[package]] +name = "pygments" +version = "2.20.0" +source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176" }, +] + +[[package]] +name = "python-multipart" +version = "0.0.26" +source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/88/71/b145a380824a960ebd60e1014256dbb7d2253f2316ff2d73dfd8928ec2c3/python_multipart-0.0.26.tar.gz", hash = "sha256:08fadc45918cd615e26846437f50c5d6d23304da32c341f289a617127b081f17" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/9a/22/f1925cdda983ab66fc8ec6ec8014b959262747e58bdca26a4e3d1da29d56/python_multipart-0.0.26-py3-none-any.whl", hash = "sha256:c0b169f8c4484c13b0dcf2ef0ec3a4adb255c4b7d18d8e420477d2b1dd03f185" }, +] + +[[package]] +name = "regex" +version = "2026.4.4" +source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/cb/0e/3a246dbf05666918bd3664d9d787f84a9108f6f43cc953a077e4a7dfdb7e/regex-2026.4.4.tar.gz", hash = "sha256:e08270659717f6973523ce3afbafa53515c4dc5dcad637dc215b6fd50f689423" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/9d/83/c4373bc5f31f2cf4b66f9b7c31005bd87fe66f0dce17701f7db4ee79ee29/regex-2026.4.4-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:62f5519042c101762509b1d717b45a69c0139d60414b3c604b81328c01bd1943" }, + { url = "https://mirrors.aliyun.com/pypi/packages/46/f8/fe62afbcc3cf4ad4ac9adeaafd98aa747869ae12d3e8e2ac293d0593c435/regex-2026.4.4-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:3790ba9fb5dd76715a7afe34dbe603ba03f8820764b1dc929dd08106214ed031" }, + { url = "https://mirrors.aliyun.com/pypi/packages/5a/92/4712b9fe6a33d232eeb1c189484b80c6c4b8422b90e766e1195d6e758207/regex-2026.4.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:8fae3c6e795d7678963f2170152b0d892cf6aee9ee8afc8c45e6be38d5107fe7" }, + { url = "https://mirrors.aliyun.com/pypi/packages/88/2c/f83b93f85e01168f1070f045a42d4c937b69fdb8dd7ae82d307253f7e36e/regex-2026.4.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:298c3ec2d53225b3bf91142eb9691025bab610e0c0c51592dde149db679b3d17" }, + { url = "https://mirrors.aliyun.com/pypi/packages/df/55/61a2e17bf0c4dc57e11caf8dd11771280d8aaa361785f9e3bc40d653f4a7/regex-2026.4.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e9638791082eaf5b3ac112c587518ee78e083a11c4b28012d8fe2a0f536dfb17" }, + { url = "https://mirrors.aliyun.com/pypi/packages/45/32/1ac8ed1b5a346b5993a3d256abe0a0f03b0b73c8cc88d928537368ac65b6/regex-2026.4.4-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ae3e764bd4c5ff55035dc82a8d49acceb42a5298edf6eb2fc4d328ee5dd7afae" }, + { url = "https://mirrors.aliyun.com/pypi/packages/26/47/2ee5c613ab546f0eddebf9905d23e07beb933416b1246c2d8791d01979b4/regex-2026.4.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ffa81f81b80047ba89a3c69ae6a0f78d06f4a42ce5126b0eb2a0a10ad44e0b2e" }, + { url = "https://mirrors.aliyun.com/pypi/packages/75/cd/41dacd129ca9fd20bd7d02f83e0fad83e034ac8a084ec369c90f55ef37e2/regex-2026.4.4-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f56ebf9d70305307a707911b88469213630aba821e77de7d603f9d2f0730687d" }, + { url = "https://mirrors.aliyun.com/pypi/packages/89/6d/5af0b588174cb5f46041fa7dd64d3fd5cd2fe51f18766703d1edc387f324/regex-2026.4.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:773d1dfd652bbffb09336abf890bfd64785c7463716bf766d0eb3bc19c8b7f27" }, + { url = "https://mirrors.aliyun.com/pypi/packages/b7/3b/f5a72b7045bd59575fc33bf1345f156fcfd5a8484aea6ad84b12c5a82114/regex-2026.4.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:d51d20befd5275d092cdffba57ded05f3c436317ee56466c8928ac32d960edaf" }, + { url = "https://mirrors.aliyun.com/pypi/packages/39/a4/72a317003d6fcd7a573584a85f59f525dfe8f67e355ca74eb6b53d66a5e2/regex-2026.4.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:0a51cdb3c1e9161154f976cb2bef9894bc063ac82f31b733087ffb8e880137d0" }, + { url = "https://mirrors.aliyun.com/pypi/packages/25/1e/5672e16f34dbbcb2560cc7e6a2fbb26dfa8b270711e730101da4423d3973/regex-2026.4.4-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:ae5266a82596114e41fb5302140e9630204c1b5f325c770bec654b95dd54b0aa" }, + { url = "https://mirrors.aliyun.com/pypi/packages/f7/0d/c813f0af7c6cc7ed7b9558bac2e5120b60ad0fa48f813e4d4bd55446f214/regex-2026.4.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:c882cd92ec68585e9c1cf36c447ec846c0d94edd706fe59e0c198e65822fd23b" }, + { url = "https://mirrors.aliyun.com/pypi/packages/ea/6d/a344608d1adbd2a95090ddd906cec09a11be0e6517e878d02a5123e0917f/regex-2026.4.4-cp313-cp313-win32.whl", hash = "sha256:05568c4fbf3cb4fa9e28e3af198c40d3237cf6041608a9022285fe567ec3ad62" }, + { url = "https://mirrors.aliyun.com/pypi/packages/31/07/54049f89b46235ca6f45cd6c88668a7050e77d4a15555e47dd40fde75263/regex-2026.4.4-cp313-cp313-win_amd64.whl", hash = "sha256:3384df51ed52db0bea967e21458ab0a414f67cdddfd94401688274e55147bb81" }, + { url = "https://mirrors.aliyun.com/pypi/packages/0e/21/61366a8e20f4d43fb597708cac7f0e2baadb491ecc9549b4980b2be27d16/regex-2026.4.4-cp313-cp313-win_arm64.whl", hash = "sha256:acd38177bd2c8e69a411d6521760806042e244d0ef94e2dd03ecdaa8a3c99427" }, + { url = "https://mirrors.aliyun.com/pypi/packages/f1/1e/3a2b9672433bef02f5d39aa1143ca2c08f311c1d041c464a42be9ae648dc/regex-2026.4.4-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:f94a11a9d05afcfcfa640e096319720a19cc0c9f7768e1a61fceee6a3afc6c7c" }, + { url = "https://mirrors.aliyun.com/pypi/packages/4e/4b/c132a4f4fe18ad3340d89fcb56235132b69559136036b845be3c073142ed/regex-2026.4.4-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:36bcb9d6d1307ab629edc553775baada2aefa5c50ccc0215fbfd2afcfff43141" }, + { url = "https://mirrors.aliyun.com/pypi/packages/f4/5f/eaa38092ce7a023656280f2341dbbd4ad5f05d780a70abba7bb4f4bea54c/regex-2026.4.4-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:261c015b3e2ed0919157046d768774ecde57f03d8fa4ba78d29793447f70e717" }, + { url = "https://mirrors.aliyun.com/pypi/packages/5f/f6/dd38146af1392dac33db7074ab331cec23cced3759167735c42c5460a243/regex-2026.4.4-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c228cf65b4a54583763645dcd73819b3b381ca8b4bb1b349dee1c135f4112c07" }, + { url = "https://mirrors.aliyun.com/pypi/packages/7a/f0/dc54c2e69f5eeec50601054998ec3690d5344277e782bd717e49867c1d29/regex-2026.4.4-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:dd2630faeb6876fb0c287f664d93ddce4d50cd46c6e88e60378c05c9047e08ca" }, + { url = "https://mirrors.aliyun.com/pypi/packages/a1/af/cb16bd5dc61621e27df919a4449bbb7e5a1034c34d307e0a706e9cc0f3e3/regex-2026.4.4-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:6a50ab11b7779b849472337191f3a043e27e17f71555f98d0092fa6d73364520" }, + { url = "https://mirrors.aliyun.com/pypi/packages/5c/71/8b260897f22996b666edd9402861668f45a2ca259f665ac029e6104a2d7d/regex-2026.4.4-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0734f63afe785138549fbe822a8cfeaccd1bae814c5057cc0ed5b9f2de4fc883" }, + { url = "https://mirrors.aliyun.com/pypi/packages/1c/60/775f7f72a510ef238254906c2f3d737fc80b16ca85f07d20e318d2eea894/regex-2026.4.4-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c4ee50606cb1967db7e523224e05f32089101945f859928e65657a2cbb3d278b" }, + { url = "https://mirrors.aliyun.com/pypi/packages/58/42/34d289b3627c03cf381e44da534a0021664188fa49ba41513da0b4ec6776/regex-2026.4.4-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:6c1818f37be3ca02dcb76d63f2c7aaba4b0dc171b579796c6fbe00148dfec6b1" }, + { url = "https://mirrors.aliyun.com/pypi/packages/fc/20/f6ecf319b382a8f1ab529e898b222c3f30600fcede7834733c26279e7465/regex-2026.4.4-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:f5bfc2741d150d0be3e4a0401a5c22b06e60acb9aa4daa46d9e79a6dcd0f135b" }, + { url = "https://mirrors.aliyun.com/pypi/packages/92/6a/9f16d3609d549bd96d7a0b2aee1625d7512ba6a03efc01652149ef88e74d/regex-2026.4.4-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:504ffa8a03609a087cad81277a629b6ce884b51a24bd388a7980ad61748618ff" }, + { url = "https://mirrors.aliyun.com/pypi/packages/fa/f6/aa9768bc96a4c361ac96419fbaf2dcdc33970bb813df3ba9b09d5d7b6d96/regex-2026.4.4-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:70aadc6ff12e4b444586e57fc30771f86253f9f0045b29016b9605b4be5f7dfb" }, + { url = "https://mirrors.aliyun.com/pypi/packages/4d/b4/c671db3556be2473ae3e4bb7a297c518d281452871501221251ea4ecba57/regex-2026.4.4-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:f4f83781191007b6ef43b03debc35435f10cad9b96e16d147efe84a1d48bdde4" }, + { url = "https://mirrors.aliyun.com/pypi/packages/2a/5c/83e3b1d89fa4f6e5a1bc97b4abd4a9a97b3c1ac7854164f694f5f0ba98a0/regex-2026.4.4-cp313-cp313t-win32.whl", hash = "sha256:e014a797de43d1847df957c0a2a8e861d1c17547ee08467d1db2c370b7568baa" }, + { url = "https://mirrors.aliyun.com/pypi/packages/28/07/077c387121f42cdb4d92b1301133c0d93b5709d096d1669ab847dda9fe2e/regex-2026.4.4-cp313-cp313t-win_amd64.whl", hash = "sha256:b15b88b0d52b179712632832c1d6e58e5774f93717849a41096880442da41ab0" }, + { url = "https://mirrors.aliyun.com/pypi/packages/9d/22/ead4a4abc7c59a4d882662aa292ca02c8b617f30b6e163bc1728879e9353/regex-2026.4.4-cp313-cp313t-win_arm64.whl", hash = "sha256:586b89cdadf7d67bf86ae3342a4dcd2b8d70a832d90c18a0ae955105caf34dbe" }, + { url = "https://mirrors.aliyun.com/pypi/packages/f0/f5/ed97c2dc47b5fbd4b73c0d7d75f9ebc8eca139f2bbef476bba35f28c0a77/regex-2026.4.4-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:2da82d643fa698e5e5210e54af90181603d5853cf469f5eedf9bfc8f59b4b8c7" }, + { url = "https://mirrors.aliyun.com/pypi/packages/80/e9/de4828a7385ec166d673a5790ad06ac48cdaa98bc0960108dd4b9cc1aef7/regex-2026.4.4-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:54a1189ad9d9357760557c91103d5e421f0a2dabe68a5cdf9103d0dcf4e00752" }, + { url = "https://mirrors.aliyun.com/pypi/packages/b4/d6/5cfbfc97f3201a4d24b596a77957e092030dcc4205894bc035cedcfce62f/regex-2026.4.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:76d67d5afb1fe402d10a6403bae668d000441e2ab115191a804287d53b772951" }, + { url = "https://mirrors.aliyun.com/pypi/packages/8e/ac/f2212d9fd56fe897e36d0110ba30ba2d247bd6410c5bd98499c7e5a1e1f2/regex-2026.4.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e7cd3e4ee8d80447a83bbc9ab0c8459781fa77087f856c3e740d7763be0df27f" }, + { url = "https://mirrors.aliyun.com/pypi/packages/c9/e3/a016c12675fbac988a60c7e1c16e67823ff0bc016beb27bd7a001dbdabc6/regex-2026.4.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2e19e18c568d2866d8b6a6dfad823db86193503f90823a8f66689315ba28fbe8" }, + { url = "https://mirrors.aliyun.com/pypi/packages/af/a4/0b90ca4cf17adc3cb43de80ec71018c37c88ad64987e8d0d481a95ca60b5/regex-2026.4.4-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:7698a6f38730fd1385d390d1ed07bb13dce39aa616aca6a6d89bea178464b9a4" }, + { url = "https://mirrors.aliyun.com/pypi/packages/8e/3b/2b3dac0b82d41ab43aa87c6ecde63d71189d03fe8854b8ca455a315edac3/regex-2026.4.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:173a66f3651cdb761018078e2d9487f4cf971232c990035ec0eb1cdc6bf929a9" }, + { url = "https://mirrors.aliyun.com/pypi/packages/25/fe/5365eb7aa0e753c4b5957815c321519ecab033c279c60e1b1ae2367fa810/regex-2026.4.4-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fa7922bbb2cc84fa062d37723f199d4c0cd200245ce269c05db82d904db66b83" }, + { url = "https://mirrors.aliyun.com/pypi/packages/aa/b3/7fb0072156bba065e3b778a7bc7b0a6328212be5dd6a86fd207e0c4f2dab/regex-2026.4.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:59f67cd0a0acaf0e564c20bbd7f767286f23e91e2572c5703bf3e56ea7557edb" }, + { url = "https://mirrors.aliyun.com/pypi/packages/02/1a/9f83677eb699273e56e858f7bd95acdbee376d42f59e8bfca2fd80d79df3/regex-2026.4.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:475e50f3f73f73614f7cba5524d6de49dee269df00272a1b85e3d19f6d498465" }, + { url = "https://mirrors.aliyun.com/pypi/packages/3b/7a/93937507b61cfcff8b4c5857f1b452852b09f741daa9acae15c971d8554e/regex-2026.4.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:a1c0c7d67b64d85ac2e1879923bad2f08a08f3004055f2f406ef73c850114bd4" }, + { url = "https://mirrors.aliyun.com/pypi/packages/86/ea/81a7f968a351c6552b1670ead861e2a385be730ee28402233020c67f9e0f/regex-2026.4.4-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:1371c2ccbb744d66ee63631cc9ca12aa233d5749972626b68fe1a649dd98e566" }, + { url = "https://mirrors.aliyun.com/pypi/packages/4c/7e/323c18ce4b5b8f44517a36342961a0306e931e499febbd876bb149d900f0/regex-2026.4.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:59968142787042db793348a3f5b918cf24ced1f23247328530e063f89c128a95" }, + { url = "https://mirrors.aliyun.com/pypi/packages/c0/af/e7510f9b11b1913b0cd44eddb784b2d650b2af6515bfce4cffcc5bfd1d38/regex-2026.4.4-cp314-cp314-win32.whl", hash = "sha256:59efe72d37fd5a91e373e5146f187f921f365f4abc1249a5ab446a60f30dd5f8" }, + { url = "https://mirrors.aliyun.com/pypi/packages/9a/51/57dae534c915e2d3a21490e88836fa2ae79dde3b66255ecc0c0a155d2c10/regex-2026.4.4-cp314-cp314-win_amd64.whl", hash = "sha256:e0aab3ff447845049d676827d2ff714aab4f73f340e155b7de7458cf53baa5a4" }, + { url = "https://mirrors.aliyun.com/pypi/packages/0a/5e/abaf9f4c3792e34edb1434f06717fae2b07888d85cb5cec29f9204931bf8/regex-2026.4.4-cp314-cp314-win_arm64.whl", hash = "sha256:a7a5bb6aa0cf62208bb4fa079b0c756734f8ad0e333b425732e8609bd51ee22f" }, + { url = "https://mirrors.aliyun.com/pypi/packages/ff/06/35da85f9f217b9538b99cbb170738993bcc3b23784322decb77619f11502/regex-2026.4.4-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:97850d0638391bdc7d35dc1c1039974dcb921eaafa8cc935ae4d7f272b1d60b3" }, + { url = "https://mirrors.aliyun.com/pypi/packages/54/5b/1bc35f479eef8285c4baf88d8c002023efdeebb7b44a8735b36195486ae7/regex-2026.4.4-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:ee7337f88f2a580679f7bbfe69dc86c043954f9f9c541012f49abc554a962f2e" }, + { url = "https://mirrors.aliyun.com/pypi/packages/39/5b/f53b9ad17480b3ddd14c90da04bfb55ac6894b129e5dea87bcaf7d00e336/regex-2026.4.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7429f4e6192c11d659900c0648ba8776243bf396ab95558b8c51a345afeddde6" }, + { url = "https://mirrors.aliyun.com/pypi/packages/bb/56/52377f59f60a7c51aa4161eecf0b6032c20b461805aca051250da435ffc9/regex-2026.4.4-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dc4f10fbd5dd13dcf4265b4cc07d69ca70280742870c97ae10093e3d66000359" }, + { url = "https://mirrors.aliyun.com/pypi/packages/dd/63/8026310bf066f702a9c361f83a8c9658f3fe4edb349f9c1e5d5273b7c40c/regex-2026.4.4-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a152560af4f9742b96f3827090f866eeec5becd4765c8e0d3473d9d280e76a5a" }, + { url = "https://mirrors.aliyun.com/pypi/packages/20/9f/a514bbb00a466dbb506d43f187a04047f7be1505f10a9a15615ead5080ee/regex-2026.4.4-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:54170b3e95339f415d54651f97df3bff7434a663912f9358237941bbf9143f55" }, + { url = "https://mirrors.aliyun.com/pypi/packages/cb/6b/8399f68dd41a2030218839b9b18360d79b86d22b9fab5ef477c7f23ca67c/regex-2026.4.4-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:07f190d65f5a72dcb9cf7106bfc3d21e7a49dd2879eda2207b683f32165e4d99" }, + { url = "https://mirrors.aliyun.com/pypi/packages/1e/9c/103963f47c24339a483b05edd568594c2be486188f688c0170fd504b2948/regex-2026.4.4-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9a2741ce5a29d3c84b0b94261ba630ab459a1b847a0d6beca7d62d188175c790" }, + { url = "https://mirrors.aliyun.com/pypi/packages/fa/ee/7f6054c0dec0cee3463c304405e4ff42e27cff05bf36fcb34be549ab17bd/regex-2026.4.4-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:b26c30df3a28fd9793113dac7385a4deb7294a06c0f760dd2b008bd49a9139bc" }, + { url = "https://mirrors.aliyun.com/pypi/packages/30/c2/51d3d941cf6070dc00c3338ecf138615fc3cce0421c3df6abe97a08af61a/regex-2026.4.4-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:421439d1bee44b19f4583ccf42670ca464ffb90e9fdc38d37f39d1ddd1e44f1f" }, + { url = "https://mirrors.aliyun.com/pypi/packages/16/e8/76d50dcc122ac33927d939f350eebcfe3dbcbda96913e03433fc36de5e63/regex-2026.4.4-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:b40379b53ecbc747fd9bdf4a0ea14eb8188ca1bd0f54f78893a39024b28f4863" }, + { url = "https://mirrors.aliyun.com/pypi/packages/a5/6e/5f6bf75e20ea6873d05ba4ec78378c375cbe08cdec571c83fbb01606e563/regex-2026.4.4-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:08c55c13d2eef54f73eeadc33146fb0baaa49e7335eb1aff6ae1324bf0ddbe4a" }, + { url = "https://mirrors.aliyun.com/pypi/packages/0b/33/3c76d9962949e487ebba353a18e89399f292287204ac8f2f4cfc3a51c233/regex-2026.4.4-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:9776b85f510062f5a75ef112afe5f494ef1635607bf1cc220c1391e9ac2f5e81" }, + { url = "https://mirrors.aliyun.com/pypi/packages/19/eb/ef32dcd2cb69b69bc0c3e55205bce94a7def48d495358946bc42186dcccc/regex-2026.4.4-cp314-cp314t-win32.whl", hash = "sha256:385edaebde5db5be103577afc8699fea73a0e36a734ba24870be7ffa61119d74" }, + { url = "https://mirrors.aliyun.com/pypi/packages/a0/86/c291bf740945acbf35ed7dbebf8e2eea2f3f78041f6bd7cdab80cb274dc0/regex-2026.4.4-cp314-cp314t-win_amd64.whl", hash = "sha256:5d354b18839328927832e2fa5f7c95b7a3ccc39e7a681529e1685898e6436d45" }, + { url = "https://mirrors.aliyun.com/pypi/packages/d5/e7/ec846d560ae6a597115153c02ca6138a7877a1748b2072d9521c10a93e58/regex-2026.4.4-cp314-cp314t-win_arm64.whl", hash = "sha256:af0384cb01a33600c49505c27c6c57ab0b27bf84a74e28524c92ca897ebdac9d" }, +] + +[[package]] +name = "requests" +version = "2.33.1" +source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +dependencies = [ + { name = "certifi" }, + { name = "charset-normalizer" }, + { name = "idna" }, + { name = "urllib3" }, +] +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/5f/a4/98b9c7c6428a668bf7e42ebb7c79d576a1c3c1e3ae2d47e674b468388871/requests-2.33.1.tar.gz", hash = "sha256:18817f8c57c6263968bc123d237e3b8b08ac046f5456bd1e307ee8f4250d3517" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/d7/8e/7540e8a2036f79a125c1d2ebadf69ed7901608859186c856fa0388ef4197/requests-2.33.1-py3-none-any.whl", hash = "sha256:4e6d1ef462f3626a1f0a0a9c42dd93c63bad33f9f1c1937509b8c5c8718ab56a" }, +] + +[[package]] +name = "rich" +version = "15.0.0" +source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +dependencies = [ + { name = "markdown-it-py" }, + { name = "pygments" }, +] +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/c0/8f/0722ca900cc807c13a6a0c696dacf35430f72e0ec571c4275d2371fca3e9/rich-15.0.0.tar.gz", hash = "sha256:edd07a4824c6b40189fb7ac9bc4c52536e9780fbbfbddf6f1e2502c31b068c36" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/82/3b/64d4899d73f91ba49a8c18a8ff3f0ea8f1c1d75481760df8c68ef5235bf5/rich-15.0.0-py3-none-any.whl", hash = "sha256:33bd4ef74232fb73fe9279a257718407f169c09b78a87ad3d296f548e27de0bb" }, +] + +[[package]] +name = "smmap" +version = "5.0.3" +source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/1f/ea/49c993d6dfdd7338c9b1000a0f36817ed7ec84577ae2e52f890d1a4ff909/smmap-5.0.3.tar.gz", hash = "sha256:4d9debb8b99007ae47165abc08670bd74cb74b5227dda7f643eccc4e9eb5642c" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/c1/d4/59e74daffcb57a07668852eeeb6035af9f32cbfd7a1d2511f17d2fe6a738/smmap-5.0.3-py3-none-any.whl", hash = "sha256:c106e05d5a61449cf6ba9a1e650227ecfb141590d2a98412103ff35d89fc7b2f" }, +] + +[[package]] +name = "sqlalchemy" +version = "2.0.49" +source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +dependencies = [ + { name = "greenlet", marker = "platform_machine == 'AMD64' or platform_machine == 'WIN32' or platform_machine == 'aarch64' or platform_machine == 'amd64' or platform_machine == 'ppc64le' or platform_machine == 'win32' or platform_machine == 'x86_64'" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/09/45/461788f35e0364a8da7bda51a1fe1b09762d0c32f12f63727998d85a873b/sqlalchemy-2.0.49.tar.gz", hash = "sha256:d15950a57a210e36dd4cec1aac22787e2a4d57ba9318233e2ef8b2daf9ff2d5f" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/ae/81/81755f50eb2478eaf2049728491d4ea4f416c1eb013338682173259efa09/sqlalchemy-2.0.49-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:df2d441bacf97022e81ad047e1597552eb3f83ca8a8f1a1fdd43cd7fe3898120" }, + { url = "https://mirrors.aliyun.com/pypi/packages/a2/bc/3494270da80811d08bcfa247404292428c4fe16294932bce5593f215cad9/sqlalchemy-2.0.49-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8e20e511dc15265fb433571391ba313e10dd8ea7e509d51686a51313b4ac01a2" }, + { url = "https://mirrors.aliyun.com/pypi/packages/cd/f5/038741f5e747a5f6ea3e72487211579d8cbea5eb9827a9cbd61d0108c4bd/sqlalchemy-2.0.49-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:47604cb2159f8bbd5a1ab48a714557156320f20871ee64d550d8bf2683d980d3" }, + { url = "https://mirrors.aliyun.com/pypi/packages/88/50/a6af0ff9dc954b43a65ca9b5367334e45d99684c90a3d3413fc19a02d43c/sqlalchemy-2.0.49-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:22d8798819f86720bc646ab015baff5ea4c971d68121cb36e2ebc2ee43ead2b7" }, + { url = "https://mirrors.aliyun.com/pypi/packages/bc/d1/5f6bdad8de0bf546fc74370939621396515e0cdb9067402d6ba1b8afbe9a/sqlalchemy-2.0.49-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9b1c058c171b739e7c330760044803099c7fff11511e3ab3573e5327116a9c33" }, + { url = "https://mirrors.aliyun.com/pypi/packages/f7/30/ad62227b4a9819a5e1c6abff77c0f614fa7c9326e5a3bdbee90f7139382b/sqlalchemy-2.0.49-cp313-cp313-win32.whl", hash = "sha256:a143af2ea6672f2af3f44ed8f9cd020e9cc34c56f0e8db12019d5d9ecf41cb3b" }, + { url = "https://mirrors.aliyun.com/pypi/packages/17/3a/7215b1b7d6d49dc9a87211be44562077f5f04f9bb5a59552c1c8e2d98173/sqlalchemy-2.0.49-cp313-cp313-win_amd64.whl", hash = "sha256:12b04d1db2663b421fe072d638a138460a51d5a862403295671c4f3987fb9148" }, + { url = "https://mirrors.aliyun.com/pypi/packages/28/4b/52a0cb2687a9cd1648252bb257be5a1ba2c2ded20ba695c65756a55a15a4/sqlalchemy-2.0.49-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:24bd94bb301ec672d8f0623eba9226cc90d775d25a0c92b5f8e4965d7f3a1518" }, + { url = "https://mirrors.aliyun.com/pypi/packages/8c/d8/fda95459204877eed0458550d6c7c64c98cc50c2d8d618026737de9ed41a/sqlalchemy-2.0.49-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a51d3db74ba489266ef55c7a4534eb0b8db9a326553df481c11e5d7660c8364d" }, + { url = "https://mirrors.aliyun.com/pypi/packages/ff/0a/2aac8b78ac6487240cf7afef8f203ca783e8796002dc0cf65c4ee99ff8bb/sqlalchemy-2.0.49-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:55250fe61d6ebfd6934a272ee16ef1244e0f16b7af6cd18ab5b1fc9f08631db0" }, + { url = "https://mirrors.aliyun.com/pypi/packages/a5/3d/ce71cfa82c50a373fd2148b3c870be05027155ce791dc9a5dcf439790b8b/sqlalchemy-2.0.49-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:46796877b47034b559a593d7e4b549aba151dae73f9e78212a3478161c12ab08" }, + { url = "https://mirrors.aliyun.com/pypi/packages/d5/e8/0a9f5c1f7c6f9ca480319bf57c2d7423f08d31445974167a27d14483c948/sqlalchemy-2.0.49-cp313-cp313t-win32.whl", hash = "sha256:9c4969a86e41454f2858256c39bdfb966a20961e9b58bf8749b65abf447e9a8d" }, + { url = "https://mirrors.aliyun.com/pypi/packages/0e/51/fb5240729fbec73006e137c4f7a7918ffd583ab08921e6ff81a999d6517a/sqlalchemy-2.0.49-cp313-cp313t-win_amd64.whl", hash = "sha256:b9870d15ef00e4d0559ae10ee5bc71b654d1f20076dbe8bc7ed19b4c0625ceba" }, + { url = "https://mirrors.aliyun.com/pypi/packages/55/33/bf28f618c0a9597d14e0b9ee7d1e0622faff738d44fe986ee287cdf1b8d0/sqlalchemy-2.0.49-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:233088b4b99ebcbc5258c755a097aa52fbf90727a03a5a80781c4b9c54347a2e" }, + { url = "https://mirrors.aliyun.com/pypi/packages/d1/a7/5f476227576cb8644650eff68cc35fa837d3802b997465c96b8340ced1e2/sqlalchemy-2.0.49-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:57ca426a48eb2c682dae8204cd89ea8ab7031e2675120a47924fabc7caacbc2a" }, + { url = "https://mirrors.aliyun.com/pypi/packages/2e/84/efc7c0bf3a1c5eef81d397f6fddac855becdbb11cb38ff957888603014a7/sqlalchemy-2.0.49-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:685e93e9c8f399b0c96a624799820176312f5ceef958c0f88215af4013d29066" }, + { url = "https://mirrors.aliyun.com/pypi/packages/91/68/bb406fa4257099c67bd75f3f2261b129c63204b9155de0d450b37f004698/sqlalchemy-2.0.49-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:9e0400fa22f79acc334d9a6b185dc00a44a8e6578aa7e12d0ddcd8434152b187" }, + { url = "https://mirrors.aliyun.com/pypi/packages/67/84/acb56c00cca9f251f437cb49e718e14f7687505749ea9255d7bd8158a6df/sqlalchemy-2.0.49-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:a05977bffe9bffd2229f477fa75eabe3192b1b05f408961d1bebff8d1cd4d401" }, + { url = "https://mirrors.aliyun.com/pypi/packages/56/19/6a20ea25606d1efd7bd1862149bb2a22d1451c3f851d23d887969201633f/sqlalchemy-2.0.49-cp314-cp314-win32.whl", hash = "sha256:0f2fa354ba106eafff2c14b0cc51f22801d1e8b2e4149342023bd6f0955de5f5" }, + { url = "https://mirrors.aliyun.com/pypi/packages/cf/4f/8297e4ed88e80baa1f5aa3c484a0ee29ef3c69c7582f206c916973b75057/sqlalchemy-2.0.49-cp314-cp314-win_amd64.whl", hash = "sha256:77641d299179c37b89cf2343ca9972c88bb6eef0d5fc504a2f86afd15cd5adf5" }, + { url = "https://mirrors.aliyun.com/pypi/packages/1f/33/95e7216df810c706e0cd3655a778604bbd319ed4f43333127d465a46862d/sqlalchemy-2.0.49-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c1dc3368794d522f43914e03312202523cc89692f5389c32bea0233924f8d977" }, + { url = "https://mirrors.aliyun.com/pypi/packages/0c/a4/ed7b18d8ccf7f954a83af6bb73866f5bc6f5636f44c7731fbb741f72cc4f/sqlalchemy-2.0.49-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7c821c47ecfe05cc32140dcf8dc6fd5d21971c86dbd56eabfe5ba07a64910c01" }, + { url = "https://mirrors.aliyun.com/pypi/packages/73/a3/20faa869c7e21a827c4a2a42b41353a54b0f9f5e96df5087629c306df71e/sqlalchemy-2.0.49-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:9c04bff9a5335eb95c6ecf1c117576a0aa560def274876fd156cfe5510fccc61" }, + { url = "https://mirrors.aliyun.com/pypi/packages/b7/50/276b9a007aa0764304ad467eceb70b04822dc32092492ee5f322d559a4dc/sqlalchemy-2.0.49-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:7f605a456948c35260e7b2a39f8952a26f077fd25653c37740ed186b90aaa68a" }, + { url = "https://mirrors.aliyun.com/pypi/packages/e5/c3/c80fcdb41905a2df650c2a3e0337198b6848876e63d66fe9188ef9003d24/sqlalchemy-2.0.49-cp314-cp314t-win32.whl", hash = "sha256:6270d717b11c5476b0cbb21eedc8d4dbb7d1a956fd6c15a23e96f197a6193158" }, + { url = "https://mirrors.aliyun.com/pypi/packages/05/52/9f1a62feab6ed368aff068524ff414f26a6daebc7361861035ae00b05530/sqlalchemy-2.0.49-cp314-cp314t-win_amd64.whl", hash = "sha256:275424295f4256fd301744b8f335cff367825d270f155d522b30c7bf49903ee7" }, + { url = "https://mirrors.aliyun.com/pypi/packages/e5/30/8519fdde58a7bdf155b714359791ad1dc018b47d60269d5d160d311fdc36/sqlalchemy-2.0.49-py3-none-any.whl", hash = "sha256:ec44cfa7ef1a728e88ad41674de50f6db8cfdb3e2af84af86e0041aaf02d43d0" }, +] + +[[package]] +name = "starlette" +version = "1.0.0" +source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +dependencies = [ + { name = "anyio" }, +] +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/81/69/17425771797c36cded50b7fe44e850315d039f28b15901ab44839e70b593/starlette-1.0.0.tar.gz", hash = "sha256:6a4beaf1f81bb472fd19ea9b918b50dc3a77a6f2e190a12954b25e6ed5eea149" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/0b/c9/584bc9651441b4ba60cc4d557d8a547b5aff901af35bda3a4ee30c819b82/starlette-1.0.0-py3-none-any.whl", hash = "sha256:d3ec55e0bb321692d275455ddfd3df75fff145d009685eb40dc91fc66b03d38b" }, +] + +[[package]] +name = "tiktoken" +version = "0.12.0" +source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +dependencies = [ + { name = "regex" }, + { name = "requests" }, +] +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/7d/ab/4d017d0f76ec3171d469d80fc03dfbb4e48a4bcaddaa831b31d526f05edc/tiktoken-0.12.0.tar.gz", hash = "sha256:b18ba7ee2b093863978fcb14f74b3707cdc8d4d4d3836853ce7ec60772139931" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/00/61/441588ee21e6b5cdf59d6870f86beb9789e532ee9718c251b391b70c68d6/tiktoken-0.12.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:775c2c55de2310cc1bc9a3ad8826761cbdc87770e586fd7b6da7d4589e13dab3" }, + { url = "https://mirrors.aliyun.com/pypi/packages/1f/05/dcf94486d5c5c8d34496abe271ac76c5b785507c8eae71b3708f1ad9b45a/tiktoken-0.12.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a01b12f69052fbe4b080a2cfb867c4de12c704b56178edf1d1d7b273561db160" }, + { url = "https://mirrors.aliyun.com/pypi/packages/a0/70/5163fe5359b943f8db9946b62f19be2305de8c3d78a16f629d4165e2f40e/tiktoken-0.12.0-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:01d99484dc93b129cd0964f9d34eee953f2737301f18b3c7257bf368d7615baa" }, + { url = "https://mirrors.aliyun.com/pypi/packages/0c/da/c028aa0babf77315e1cef357d4d768800c5f8a6de04d0eac0f377cb619fa/tiktoken-0.12.0-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:4a1a4fcd021f022bfc81904a911d3df0f6543b9e7627b51411da75ff2fe7a1be" }, + { url = "https://mirrors.aliyun.com/pypi/packages/a0/5a/886b108b766aa53e295f7216b509be95eb7d60b166049ce2c58416b25f2a/tiktoken-0.12.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:981a81e39812d57031efdc9ec59fa32b2a5a5524d20d4776574c4b4bd2e9014a" }, + { url = "https://mirrors.aliyun.com/pypi/packages/f4/f8/4db272048397636ac7a078d22773dd2795b1becee7bc4922fe6207288d57/tiktoken-0.12.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9baf52f84a3f42eef3ff4e754a0db79a13a27921b457ca9832cf944c6be4f8f3" }, + { url = "https://mirrors.aliyun.com/pypi/packages/8e/32/45d02e2e0ea2be3a9ed22afc47d93741247e75018aac967b713b2941f8ea/tiktoken-0.12.0-cp313-cp313-win_amd64.whl", hash = "sha256:b8a0cd0c789a61f31bf44851defbd609e8dd1e2c8589c614cc1060940ef1f697" }, + { url = "https://mirrors.aliyun.com/pypi/packages/ce/76/994fc868f88e016e6d05b0da5ac24582a14c47893f4474c3e9744283f1d5/tiktoken-0.12.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:d5f89ea5680066b68bcb797ae85219c72916c922ef0fcdd3480c7d2315ffff16" }, + { url = "https://mirrors.aliyun.com/pypi/packages/f6/b8/57ef1456504c43a849821920d582a738a461b76a047f352f18c0b26c6516/tiktoken-0.12.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:b4e7ed1c6a7a8a60a3230965bdedba8cc58f68926b835e519341413370e0399a" }, + { url = "https://mirrors.aliyun.com/pypi/packages/72/90/13da56f664286ffbae9dbcfadcc625439142675845baa62715e49b87b68b/tiktoken-0.12.0-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:fc530a28591a2d74bce821d10b418b26a094bf33839e69042a6e86ddb7a7fb27" }, + { url = "https://mirrors.aliyun.com/pypi/packages/05/df/4f80030d44682235bdaecd7346c90f67ae87ec8f3df4a3442cb53834f7e4/tiktoken-0.12.0-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:06a9f4f49884139013b138920a4c393aa6556b2f8f536345f11819389c703ebb" }, + { url = "https://mirrors.aliyun.com/pypi/packages/22/1f/ae535223a8c4ef4c0c1192e3f9b82da660be9eb66b9279e95c99288e9dab/tiktoken-0.12.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:04f0e6a985d95913cabc96a741c5ffec525a2c72e9df086ff17ebe35985c800e" }, + { url = "https://mirrors.aliyun.com/pypi/packages/78/a7/f8ead382fce0243cb625c4f266e66c27f65ae65ee9e77f59ea1653b6d730/tiktoken-0.12.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:0ee8f9ae00c41770b5f9b0bb1235474768884ae157de3beb5439ca0fd70f3e25" }, + { url = "https://mirrors.aliyun.com/pypi/packages/93/e0/6cc82a562bc6365785a3ff0af27a2a092d57c47d7a81d9e2295d8c36f011/tiktoken-0.12.0-cp313-cp313t-win_amd64.whl", hash = "sha256:dc2dd125a62cb2b3d858484d6c614d136b5b848976794edfb63688d539b8b93f" }, + { url = "https://mirrors.aliyun.com/pypi/packages/72/05/3abc1db5d2c9aadc4d2c76fa5640134e475e58d9fbb82b5c535dc0de9b01/tiktoken-0.12.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:a90388128df3b3abeb2bfd1895b0681412a8d7dc644142519e6f0a97c2111646" }, + { url = "https://mirrors.aliyun.com/pypi/packages/e3/7b/50c2f060412202d6c95f32b20755c7a6273543b125c0985d6fa9465105af/tiktoken-0.12.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:da900aa0ad52247d8794e307d6446bd3cdea8e192769b56276695d34d2c9aa88" }, + { url = "https://mirrors.aliyun.com/pypi/packages/14/27/bf795595a2b897e271771cd31cb847d479073497344c637966bdf2853da1/tiktoken-0.12.0-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:285ba9d73ea0d6171e7f9407039a290ca77efcdb026be7769dccc01d2c8d7fff" }, + { url = "https://mirrors.aliyun.com/pypi/packages/f5/de/9341a6d7a8f1b448573bbf3425fa57669ac58258a667eb48a25dfe916d70/tiktoken-0.12.0-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:d186a5c60c6a0213f04a7a802264083dea1bbde92a2d4c7069e1a56630aef830" }, + { url = "https://mirrors.aliyun.com/pypi/packages/75/0d/881866647b8d1be4d67cb24e50d0c26f9f807f994aa1510cb9ba2fe5f612/tiktoken-0.12.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:604831189bd05480f2b885ecd2d1986dc7686f609de48208ebbbddeea071fc0b" }, + { url = "https://mirrors.aliyun.com/pypi/packages/b3/1e/b651ec3059474dab649b8d5b69f5c65cd8fcd8918568c1935bd4136c9392/tiktoken-0.12.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:8f317e8530bb3a222547b85a58583238c8f74fd7a7408305f9f63246d1a0958b" }, + { url = "https://mirrors.aliyun.com/pypi/packages/80/57/ce64fd16ac390fafde001268c364d559447ba09b509181b2808622420eec/tiktoken-0.12.0-cp314-cp314-win_amd64.whl", hash = "sha256:399c3dd672a6406719d84442299a490420b458c44d3ae65516302a99675888f3" }, + { url = "https://mirrors.aliyun.com/pypi/packages/ac/a4/72eed53e8976a099539cdd5eb36f241987212c29629d0a52c305173e0a68/tiktoken-0.12.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:c2c714c72bc00a38ca969dae79e8266ddec999c7ceccd603cc4f0d04ccd76365" }, + { url = "https://mirrors.aliyun.com/pypi/packages/e6/d7/0110b8f54c008466b19672c615f2168896b83706a6611ba6e47313dbc6e9/tiktoken-0.12.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:cbb9a3ba275165a2cb0f9a83f5d7025afe6b9d0ab01a22b50f0e74fee2ad253e" }, + { url = "https://mirrors.aliyun.com/pypi/packages/5f/77/4f268c41a3957c418b084dd576ea2fad2e95da0d8e1ab705372892c2ca22/tiktoken-0.12.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:dfdfaa5ffff8993a3af94d1125870b1d27aed7cb97aa7eb8c1cefdbc87dbee63" }, + { url = "https://mirrors.aliyun.com/pypi/packages/4e/2b/fc46c90fe5028bd094cd6ee25a7db321cb91d45dc87531e2bdbb26b4867a/tiktoken-0.12.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:584c3ad3d0c74f5269906eb8a659c8bfc6144a52895d9261cdaf90a0ae5f4de0" }, + { url = "https://mirrors.aliyun.com/pypi/packages/28/c0/3c7a39ff68022ddfd7d93f3337ad90389a342f761c4d71de99a3ccc57857/tiktoken-0.12.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:54c891b416a0e36b8e2045b12b33dd66fb34a4fe7965565f1b482da50da3e86a" }, + { url = "https://mirrors.aliyun.com/pypi/packages/ab/0d/c1ad6f4016a3968c048545f5d9b8ffebf577774b2ede3e2e352553b685fe/tiktoken-0.12.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5edb8743b88d5be814b1a8a8854494719080c28faaa1ccbef02e87354fe71ef0" }, + { url = "https://mirrors.aliyun.com/pypi/packages/af/df/c7891ef9d2712ad774777271d39fdef63941ffba0a9d59b7ad1fd2765e57/tiktoken-0.12.0-cp314-cp314t-win_amd64.whl", hash = "sha256:f61c0aea5565ac82e2ec50a05e02a6c44734e91b51c10510b084ea1b8e633a71" }, +] + +[[package]] +name = "typing-extensions" +version = "4.15.0" +source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548" }, +] + +[[package]] +name = "typing-inspection" +version = "0.4.2" +source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7" }, +] + +[[package]] +name = "urllib3" +version = "2.6.3" +source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/c7/24/5f1b3bdffd70275f6661c76461e25f024d5a38a46f04aaca912426a2b1d3/urllib3-2.6.3.tar.gz", hash = "sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/39/08/aaaad47bc4e9dc8c725e68f9d04865dbcb2052843ff09c97b08904852d84/urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4" }, +] + +[[package]] +name = "uvicorn" +version = "0.44.0" +source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +dependencies = [ + { name = "click" }, + { name = "h11" }, +] +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/5e/da/6eee1ff8b6cbeed47eeb5229749168e81eb4b7b999a1a15a7176e51410c9/uvicorn-0.44.0.tar.gz", hash = "sha256:6c942071b68f07e178264b9152f1f16dfac5da85880c4ce06366a96d70d4f31e" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/b7/23/a5bbd9600dd607411fa644c06ff4951bec3a4d82c4b852374024359c19c0/uvicorn-0.44.0-py3-none-any.whl", hash = "sha256:ce937c99a2cc70279556967274414c087888e8cec9f9c94644dfca11bd3ced89" }, +] diff --git a/web/.gitignore b/web/.gitignore new file mode 100644 index 0000000000000000000000000000000000000000..e19b9356c83a4a99d720e27a773d01761b70377e --- /dev/null +++ b/web/.gitignore @@ -0,0 +1,41 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules +/.pnp +.pnp.* +.yarn/* +!.yarn/patches +!.yarn/plugins +!.yarn/releases +!.yarn/versions +.idea +# testing +/coverage + +# next.js +/.next/ +/out/ + +# production +/build + +# misc +.DS_Store +*.pem + +# debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* +.pnpm-debug.log* + +# env files (can opt-in for committing if needed) +.env* + +# vercel +.vercel + +# typescript +*.tsbuildinfo +next-env.d.ts diff --git a/web/.npmrc b/web/.npmrc new file mode 100644 index 0000000000000000000000000000000000000000..b7425b9ee858205989c4bb03c6b447948e22c55f --- /dev/null +++ b/web/.npmrc @@ -0,0 +1 @@ +enable-pre-post-scripts=true \ No newline at end of file diff --git a/web/bun.lock b/web/bun.lock new file mode 100644 index 0000000000000000000000000000000000000000..157f472fc538511aeb323f2a7c80a217f146c435 --- /dev/null +++ b/web/bun.lock @@ -0,0 +1,1240 @@ +{ + "lockfileVersion": 1, + "configVersion": 0, + "workspaces": { + "": { + "name": "next-starter", + "dependencies": { + "@radix-ui/react-checkbox": "^1.3.3", + "@radix-ui/react-dialog": "^1.1.15", + "@radix-ui/react-popover": "^1.1.15", + "@radix-ui/react-select": "^2.2.6", + "@radix-ui/react-slot": "^1.2.3", + "axios": "^1.9.0", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "date-fns": "^4.1.0", + "immer": "^10.1.3", + "localforage": "^1.10.0", + "lucide-react": "^0.523.0", + "motion": "^12.38.0", + "next": "16.2.3", + "react": "19.2.5", + "react-day-picker": "^9.14.0", + "react-dom": "19.2.5", + "react-hook-form": "^7.62.0", + "react-medium-image-zoom": "^5.3.0", + "sonner": "^2.0.6", + "tailwind-merge": "^3.3.1", + "zustand": "^5.0.8", + }, + "devDependencies": { + "@eslint/eslintrc": "^3", + "@tailwindcss/postcss": "^4", + "@types/node": "^20", + "@types/react": "19.1.12", + "@types/react-dom": "19.1.9", + "@umijs/openapi": "^1.13.15", + "eslint": "^9", + "eslint-config-next": "16.2.3", + "eslint-config-prettier": "^10.1.8", + "prettier-plugin-organize-imports": "^4.2.0", + "prettier-plugin-tailwindcss": "^0.6.14", + "tailwindcss": "^4", + "tw-animate-css": "^1.3.4", + "typescript": "^5", + }, + }, + }, + "overrides": { + "@types/react": "19.1.12", + "@types/react-dom": "19.1.9", + }, + "packages": { + "@alloc/quick-lru": ["@alloc/quick-lru@5.2.0", "https://registry.npmmirror.com/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", {}, "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw=="], + + "@ampproject/remapping": ["@ampproject/remapping@2.3.0", "https://registry.npmmirror.com/@ampproject/remapping/-/remapping-2.3.0.tgz", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw=="], + + "@babel/code-frame": ["@babel/code-frame@7.29.0", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.28.5", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw=="], + + "@babel/compat-data": ["@babel/compat-data@7.29.0", "", {}, "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg=="], + + "@babel/core": ["@babel/core@7.29.0", "", { "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", "@babel/helper-compilation-targets": "^7.28.6", "@babel/helper-module-transforms": "^7.28.6", "@babel/helpers": "^7.28.6", "@babel/parser": "^7.29.0", "@babel/template": "^7.28.6", "@babel/traverse": "^7.29.0", "@babel/types": "^7.29.0", "@jridgewell/remapping": "^2.3.5", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", "json5": "^2.2.3", "semver": "^6.3.1" } }, "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA=="], + + "@babel/generator": ["@babel/generator@7.29.1", "", { "dependencies": { "@babel/parser": "^7.29.0", "@babel/types": "^7.29.0", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" } }, "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw=="], + + "@babel/helper-compilation-targets": ["@babel/helper-compilation-targets@7.28.6", "", { "dependencies": { "@babel/compat-data": "^7.28.6", "@babel/helper-validator-option": "^7.27.1", "browserslist": "^4.24.0", "lru-cache": "^5.1.1", "semver": "^6.3.1" } }, "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA=="], + + "@babel/helper-globals": ["@babel/helper-globals@7.28.0", "", {}, "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw=="], + + "@babel/helper-module-imports": ["@babel/helper-module-imports@7.28.6", "", { "dependencies": { "@babel/traverse": "^7.28.6", "@babel/types": "^7.28.6" } }, "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw=="], + + "@babel/helper-module-transforms": ["@babel/helper-module-transforms@7.28.6", "", { "dependencies": { "@babel/helper-module-imports": "^7.28.6", "@babel/helper-validator-identifier": "^7.28.5", "@babel/traverse": "^7.28.6" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA=="], + + "@babel/helper-string-parser": ["@babel/helper-string-parser@7.27.1", "", {}, "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA=="], + + "@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.28.5", "", {}, "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q=="], + + "@babel/helper-validator-option": ["@babel/helper-validator-option@7.27.1", "", {}, "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg=="], + + "@babel/helpers": ["@babel/helpers@7.29.2", "", { "dependencies": { "@babel/template": "^7.28.6", "@babel/types": "^7.29.0" } }, "sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw=="], + + "@babel/parser": ["@babel/parser@7.29.2", "", { "dependencies": { "@babel/types": "^7.29.0" }, "bin": { "parser": "bin/babel-parser.js" } }, "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA=="], + + "@babel/template": ["@babel/template@7.28.6", "", { "dependencies": { "@babel/code-frame": "^7.28.6", "@babel/parser": "^7.28.6", "@babel/types": "^7.28.6" } }, "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ=="], + + "@babel/traverse": ["@babel/traverse@7.29.0", "", { "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", "@babel/helper-globals": "^7.28.0", "@babel/parser": "^7.29.0", "@babel/template": "^7.28.6", "@babel/types": "^7.29.0", "debug": "^4.3.1" } }, "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA=="], + + "@babel/types": ["@babel/types@7.29.0", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A=="], + + "@date-fns/tz": ["@date-fns/tz@1.4.1", "https://registry.npmmirror.com/@date-fns/tz/-/tz-1.4.1.tgz", {}, "sha512-P5LUNhtbj6YfI3iJjw5EL9eUAG6OitD0W3fWQcpQjDRc/QIsL0tRNuO1PcDvPccWL1fSTXXdE1ds+l95DV/OFA=="], + + "@emnapi/core": ["@emnapi/core@1.4.3", "https://registry.npmmirror.com/@emnapi/core/-/core-1.4.3.tgz", { "dependencies": { "@emnapi/wasi-threads": "1.0.2", "tslib": "^2.4.0" } }, "sha512-4m62DuCE07lw01soJwPiBGC0nAww0Q+RY70VZ+n49yDIO13yyinhbWCeNnaob0lakDtWQzSdtNWzJeOJt2ma+g=="], + + "@emnapi/runtime": ["@emnapi/runtime@1.9.2", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-3U4+MIWHImeyu1wnmVygh5WlgfYDtyf0k8AbLhMFxOipihf6nrWC4syIm/SwEeec0mNSafiiNnMJwbza/Is6Lw=="], + + "@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.0.2", "https://registry.npmmirror.com/@emnapi/wasi-threads/-/wasi-threads-1.0.2.tgz", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-5n3nTJblwRi8LlXkJ9eBzu+kZR8Yxcc7ubakyQTFzPMtIhFpUBRbsnc2Dv88IZDIbCDlBiWrknhB4Lsz7mg6BA=="], + + "@eslint-community/eslint-utils": ["@eslint-community/eslint-utils@4.9.1", "", { "dependencies": { "eslint-visitor-keys": "^3.4.3" }, "peerDependencies": { "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" } }, "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ=="], + + "@eslint-community/regexpp": ["@eslint-community/regexpp@4.12.2", "", {}, "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew=="], + + "@eslint/config-array": ["@eslint/config-array@0.20.1", "https://registry.npmmirror.com/@eslint/config-array/-/config-array-0.20.1.tgz", { "dependencies": { "@eslint/object-schema": "^2.1.6", "debug": "^4.3.1", "minimatch": "^3.1.2" } }, "sha512-OL0RJzC/CBzli0DrrR31qzj6d6i6Mm3HByuhflhl4LOBiWxN+3i6/t/ZQQNii4tjksXi8r2CRW1wMpWA2ULUEw=="], + + "@eslint/config-helpers": ["@eslint/config-helpers@0.2.3", "https://registry.npmmirror.com/@eslint/config-helpers/-/config-helpers-0.2.3.tgz", {}, "sha512-u180qk2Um1le4yf0ruXH3PYFeEZeYC3p/4wCTKrr2U1CmGdzGi3KtY0nuPDH48UJxlKCC5RDzbcbh4X0XlqgHg=="], + + "@eslint/core": ["@eslint/core@0.14.0", "https://registry.npmmirror.com/@eslint/core/-/core-0.14.0.tgz", { "dependencies": { "@types/json-schema": "^7.0.15" } }, "sha512-qIbV0/JZr7iSDjqAc60IqbLdsj9GDt16xQtWD+B78d/HAlvysGdZZ6rpJHGAc2T0FQx1X6thsSPdnoiGKdNtdg=="], + + "@eslint/eslintrc": ["@eslint/eslintrc@3.3.1", "https://registry.npmmirror.com/@eslint/eslintrc/-/eslintrc-3.3.1.tgz", { "dependencies": { "ajv": "^6.12.4", "debug": "^4.3.2", "espree": "^10.0.1", "globals": "^14.0.0", "ignore": "^5.2.0", "import-fresh": "^3.2.1", "js-yaml": "^4.1.0", "minimatch": "^3.1.2", "strip-json-comments": "^3.1.1" } }, "sha512-gtF186CXhIl1p4pJNGZw8Yc6RlshoePRvE0X91oPGb3vZ8pM3qOS9W9NGPat9LziaBV7XrJWGylNQXkGcnM3IQ=="], + + "@eslint/js": ["@eslint/js@9.29.0", "https://registry.npmmirror.com/@eslint/js/-/js-9.29.0.tgz", {}, "sha512-3PIF4cBw/y+1u2EazflInpV+lYsSG0aByVIQzAgb1m1MhHFSbqTyNqtBKHgWf/9Ykud+DhILS9EGkmekVhbKoQ=="], + + "@eslint/object-schema": ["@eslint/object-schema@2.1.6", "https://registry.npmmirror.com/@eslint/object-schema/-/object-schema-2.1.6.tgz", {}, "sha512-RBMg5FRL0I0gs51M/guSAj5/e14VQ4tpZnQNWwuDT66P14I43ItmPfIZRhO9fUVIPOAQXU47atlywZ/czoqFPA=="], + + "@eslint/plugin-kit": ["@eslint/plugin-kit@0.3.3", "https://registry.npmmirror.com/@eslint/plugin-kit/-/plugin-kit-0.3.3.tgz", { "dependencies": { "@eslint/core": "^0.15.1", "levn": "^0.4.1" } }, "sha512-1+WqvgNMhmlAambTvT3KPtCl/Ibr68VldY2XY40SL1CE0ZXiakFR/cbTspaF5HsnpDMvcYYoJHfl4980NBjGag=="], + + "@exodus/schemasafe": ["@exodus/schemasafe@1.3.0", "https://registry.npmmirror.com/@exodus/schemasafe/-/schemasafe-1.3.0.tgz", {}, "sha512-5Aap/GaRupgNx/feGBwLLTVv8OQFfv3pq2lPRzPg9R+IOBnDgghTGW7l7EuVXOvg5cc/xSAlRW8rBrjIC3Nvqw=="], + + "@floating-ui/core": ["@floating-ui/core@1.7.5", "https://registry.npmmirror.com/@floating-ui/core/-/core-1.7.5.tgz", { "dependencies": { "@floating-ui/utils": "^0.2.11" } }, "sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ=="], + + "@floating-ui/dom": ["@floating-ui/dom@1.7.6", "https://registry.npmmirror.com/@floating-ui/dom/-/dom-1.7.6.tgz", { "dependencies": { "@floating-ui/core": "^1.7.5", "@floating-ui/utils": "^0.2.11" } }, "sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ=="], + + "@floating-ui/react-dom": ["@floating-ui/react-dom@2.1.8", "https://registry.npmmirror.com/@floating-ui/react-dom/-/react-dom-2.1.8.tgz", { "dependencies": { "@floating-ui/dom": "^1.7.6" }, "peerDependencies": { "react": ">=16.8.0", "react-dom": ">=16.8.0" } }, "sha512-cC52bHwM/n/CxS87FH0yWdngEZrjdtLW/qVruo68qg+prK7ZQ4YGdut2GyDVpoGeAYe/h899rVeOVm6Oi40k2A=="], + + "@floating-ui/utils": ["@floating-ui/utils@0.2.11", "https://registry.npmmirror.com/@floating-ui/utils/-/utils-0.2.11.tgz", {}, "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg=="], + + "@humanfs/core": ["@humanfs/core@0.19.1", "https://registry.npmmirror.com/@humanfs/core/-/core-0.19.1.tgz", {}, "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA=="], + + "@humanfs/node": ["@humanfs/node@0.16.6", "https://registry.npmmirror.com/@humanfs/node/-/node-0.16.6.tgz", { "dependencies": { "@humanfs/core": "^0.19.1", "@humanwhocodes/retry": "^0.3.0" } }, "sha512-YuI2ZHQL78Q5HbhDiBA1X4LmYdXCKCMQIfw0pw7piHJwyREFebJUvrQN4cMssyES6x+vfUbx1CIpaQUKYdQZOw=="], + + "@humanwhocodes/module-importer": ["@humanwhocodes/module-importer@1.0.1", "https://registry.npmmirror.com/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", {}, "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA=="], + + "@humanwhocodes/retry": ["@humanwhocodes/retry@0.4.3", "https://registry.npmmirror.com/@humanwhocodes/retry/-/retry-0.4.3.tgz", {}, "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ=="], + + "@img/colour": ["@img/colour@1.1.0", "", {}, "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ=="], + + "@img/sharp-darwin-arm64": ["@img/sharp-darwin-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-arm64": "1.2.4" }, "os": "darwin", "cpu": "arm64" }, "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w=="], + + "@img/sharp-darwin-x64": ["@img/sharp-darwin-x64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-x64": "1.2.4" }, "os": "darwin", "cpu": "x64" }, "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw=="], + + "@img/sharp-libvips-darwin-arm64": ["@img/sharp-libvips-darwin-arm64@1.2.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g=="], + + "@img/sharp-libvips-darwin-x64": ["@img/sharp-libvips-darwin-x64@1.2.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg=="], + + "@img/sharp-libvips-linux-arm": ["@img/sharp-libvips-linux-arm@1.2.4", "", { "os": "linux", "cpu": "arm" }, "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A=="], + + "@img/sharp-libvips-linux-arm64": ["@img/sharp-libvips-linux-arm64@1.2.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw=="], + + "@img/sharp-libvips-linux-ppc64": ["@img/sharp-libvips-linux-ppc64@1.2.4", "", { "os": "linux", "cpu": "ppc64" }, "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA=="], + + "@img/sharp-libvips-linux-riscv64": ["@img/sharp-libvips-linux-riscv64@1.2.4", "", { "os": "linux", "cpu": "none" }, "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA=="], + + "@img/sharp-libvips-linux-s390x": ["@img/sharp-libvips-linux-s390x@1.2.4", "", { "os": "linux", "cpu": "s390x" }, "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ=="], + + "@img/sharp-libvips-linux-x64": ["@img/sharp-libvips-linux-x64@1.2.4", "", { "os": "linux", "cpu": "x64" }, "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw=="], + + "@img/sharp-libvips-linuxmusl-arm64": ["@img/sharp-libvips-linuxmusl-arm64@1.2.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw=="], + + "@img/sharp-libvips-linuxmusl-x64": ["@img/sharp-libvips-linuxmusl-x64@1.2.4", "", { "os": "linux", "cpu": "x64" }, "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg=="], + + "@img/sharp-linux-arm": ["@img/sharp-linux-arm@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm": "1.2.4" }, "os": "linux", "cpu": "arm" }, "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw=="], + + "@img/sharp-linux-arm64": ["@img/sharp-linux-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm64": "1.2.4" }, "os": "linux", "cpu": "arm64" }, "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg=="], + + "@img/sharp-linux-ppc64": ["@img/sharp-linux-ppc64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-ppc64": "1.2.4" }, "os": "linux", "cpu": "ppc64" }, "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA=="], + + "@img/sharp-linux-riscv64": ["@img/sharp-linux-riscv64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-riscv64": "1.2.4" }, "os": "linux", "cpu": "none" }, "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw=="], + + "@img/sharp-linux-s390x": ["@img/sharp-linux-s390x@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-s390x": "1.2.4" }, "os": "linux", "cpu": "s390x" }, "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg=="], + + "@img/sharp-linux-x64": ["@img/sharp-linux-x64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-x64": "1.2.4" }, "os": "linux", "cpu": "x64" }, "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ=="], + + "@img/sharp-linuxmusl-arm64": ["@img/sharp-linuxmusl-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-arm64": "1.2.4" }, "os": "linux", "cpu": "arm64" }, "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg=="], + + "@img/sharp-linuxmusl-x64": ["@img/sharp-linuxmusl-x64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-x64": "1.2.4" }, "os": "linux", "cpu": "x64" }, "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q=="], + + "@img/sharp-wasm32": ["@img/sharp-wasm32@0.34.5", "", { "dependencies": { "@emnapi/runtime": "^1.7.0" }, "cpu": "none" }, "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw=="], + + "@img/sharp-win32-arm64": ["@img/sharp-win32-arm64@0.34.5", "", { "os": "win32", "cpu": "arm64" }, "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g=="], + + "@img/sharp-win32-ia32": ["@img/sharp-win32-ia32@0.34.5", "", { "os": "win32", "cpu": "ia32" }, "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg=="], + + "@img/sharp-win32-x64": ["@img/sharp-win32-x64@0.34.5", "", { "os": "win32", "cpu": "x64" }, "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw=="], + + "@isaacs/fs-minipass": ["@isaacs/fs-minipass@4.0.1", "https://registry.npmmirror.com/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz", { "dependencies": { "minipass": "^7.0.4" } }, "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w=="], + + "@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="], + + "@jridgewell/remapping": ["@jridgewell/remapping@2.3.5", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="], + + "@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "https://registry.npmmirror.com/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="], + + "@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.0", "https://registry.npmmirror.com/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.0.tgz", {}, "sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ=="], + + "@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="], + + "@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@0.2.11", "https://registry.npmmirror.com/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.11.tgz", { "dependencies": { "@emnapi/core": "^1.4.3", "@emnapi/runtime": "^1.4.3", "@tybys/wasm-util": "^0.9.0" } }, "sha512-9DPkXtvHydrcOsopiYpUgPHpmj0HWZKMUnL2dZqpvC42lsratuBG06V5ipyno0fUek5VlFsNQ+AcFATSrJXgMA=="], + + "@next/env": ["@next/env@16.2.3", "", {}, "sha512-ZWXyj4uNu4GCWQw9cjRxWlbD+33mcDszIo9iQxFnBX3Wmgq9ulaSJcl6VhuWx5pCWqqD+9W6Wfz7N0lM5lYPMA=="], + + "@next/eslint-plugin-next": ["@next/eslint-plugin-next@16.2.3", "", { "dependencies": { "fast-glob": "3.3.1" } }, "sha512-nE/b9mht28XJxjTwKs/yk7w4XTaU3t40UHVAky6cjiijdP/SEy3hGsnQMPxmXPTpC7W4/97okm6fngKnvCqVaA=="], + + "@next/swc-darwin-arm64": ["@next/swc-darwin-arm64@16.2.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-u37KDKTKQ+OQLvY+z7SNXixwo4Q2/IAJFDzU1fYe66IbCE51aDSAzkNDkWmLN0yjTUh4BKBd+hb69jYn6qqqSg=="], + + "@next/swc-darwin-x64": ["@next/swc-darwin-x64@16.2.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-gHjL/qy6Q6CG3176FWbAKyKh9IfntKZTB3RY/YOJdDFpHGsUDXVH38U4mMNpHVGXmeYW4wj22dMp1lTfmu/bTQ=="], + + "@next/swc-linux-arm64-gnu": ["@next/swc-linux-arm64-gnu@16.2.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-U6vtblPtU/P14Y/b/n9ZY0GOxbbIhTFuaFR7F4/uMBidCi2nSdaOFhA0Go81L61Zd6527+yvuX44T4ksnf8T+Q=="], + + "@next/swc-linux-arm64-musl": ["@next/swc-linux-arm64-musl@16.2.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-/YV0LgjHUmfhQpn9bVoGc4x4nan64pkhWR5wyEV8yCOfwwrH630KpvRg86olQHTwHIn1z59uh6JwKvHq1h4QEw=="], + + "@next/swc-linux-x64-gnu": ["@next/swc-linux-x64-gnu@16.2.3", "", { "os": "linux", "cpu": "x64" }, "sha512-/HiWEcp+WMZ7VajuiMEFGZ6cg0+aYZPqCJD3YJEfpVWQsKYSjXQG06vJP6F1rdA03COD9Fef4aODs3YxKx+RDQ=="], + + "@next/swc-linux-x64-musl": ["@next/swc-linux-x64-musl@16.2.3", "", { "os": "linux", "cpu": "x64" }, "sha512-Kt44hGJfZSefebhk/7nIdivoDr3Ugp5+oNz9VvF3GUtfxutucUIHfIO0ZYO8QlOPDQloUVQn4NVC/9JvHRk9hw=="], + + "@next/swc-win32-arm64-msvc": ["@next/swc-win32-arm64-msvc@16.2.3", "", { "os": "win32", "cpu": "arm64" }, "sha512-O2NZ9ie3Tq6xj5Z5CSwBT3+aWAMW2PIZ4egUi9MaWLkwaehgtB7YZjPm+UpcNpKOme0IQuqDcor7BsW6QBiQBw=="], + + "@next/swc-win32-x64-msvc": ["@next/swc-win32-x64-msvc@16.2.3", "", { "os": "win32", "cpu": "x64" }, "sha512-Ibm29/GgB/ab5n7XKqlStkm54qqZE8v2FnijUPBgrd67FWrac45o/RsNlaOWjme/B5UqeWt/8KM4aWBwA1D2Kw=="], + + "@nodelib/fs.scandir": ["@nodelib/fs.scandir@2.1.5", "https://registry.npmmirror.com/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", { "dependencies": { "@nodelib/fs.stat": "2.0.5", "run-parallel": "^1.1.9" } }, "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g=="], + + "@nodelib/fs.stat": ["@nodelib/fs.stat@2.0.5", "https://registry.npmmirror.com/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", {}, "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A=="], + + "@nodelib/fs.walk": ["@nodelib/fs.walk@1.2.8", "https://registry.npmmirror.com/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", { "dependencies": { "@nodelib/fs.scandir": "2.1.5", "fastq": "^1.6.0" } }, "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg=="], + + "@nolyfill/is-core-module": ["@nolyfill/is-core-module@1.0.39", "https://registry.npmmirror.com/@nolyfill/is-core-module/-/is-core-module-1.0.39.tgz", {}, "sha512-nn5ozdjYQpUCZlWGuxcJY/KpxkWQs4DcbMCmKojjyrYDEAGy4Ce19NN4v5MduafTwJlbKc99UA8YhSVqq9yPZA=="], + + "@radix-ui/number": ["@radix-ui/number@1.1.1", "https://registry.npmmirror.com/@radix-ui/number/-/number-1.1.1.tgz", {}, "sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g=="], + + "@radix-ui/primitive": ["@radix-ui/primitive@1.1.3", "https://registry.npmmirror.com/@radix-ui/primitive/-/primitive-1.1.3.tgz", {}, "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg=="], + + "@radix-ui/react-arrow": ["@radix-ui/react-arrow@1.1.7", "https://registry.npmmirror.com/@radix-ui/react-arrow/-/react-arrow-1.1.7.tgz", { "dependencies": { "@radix-ui/react-primitive": "2.1.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-F+M1tLhO+mlQaOWspE8Wstg+z6PwxwRd8oQ8IXceWz92kfAmalTRf0EjrouQeo7QssEPfCn05B4Ihs1K9WQ/7w=="], + + "@radix-ui/react-checkbox": ["@radix-ui/react-checkbox@1.3.3", "https://registry.npmmirror.com/@radix-ui/react-checkbox/-/react-checkbox-1.3.3.tgz", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-previous": "1.1.1", "@radix-ui/react-use-size": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-wBbpv+NQftHDdG86Qc0pIyXk5IR3tM8Vd0nWLKDcX8nNn4nXFOFwsKuqw2okA/1D/mpaAkmuyndrPJTYDNZtFw=="], + + "@radix-ui/react-collection": ["@radix-ui/react-collection@1.1.7", "https://registry.npmmirror.com/@radix-ui/react-collection/-/react-collection-1.1.7.tgz", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-Fh9rGN0MoI4ZFUNyfFVNU4y9LUz93u9/0K+yLgA2bwRojxM8JU1DyvvMBabnZPBgMWREAJvU2jjVzq+LrFUglw=="], + + "@radix-ui/react-compose-refs": ["@radix-ui/react-compose-refs@1.1.2", "https://registry.npmmirror.com/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.2.tgz", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg=="], + + "@radix-ui/react-context": ["@radix-ui/react-context@1.1.2", "https://registry.npmmirror.com/@radix-ui/react-context/-/react-context-1.1.2.tgz", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA=="], + + "@radix-ui/react-dialog": ["@radix-ui/react-dialog@1.1.15", "https://registry.npmmirror.com/@radix-ui/react-dialog/-/react-dialog-1.1.15.tgz", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-dismissable-layer": "1.1.11", "@radix-ui/react-focus-guards": "1.1.3", "@radix-ui/react-focus-scope": "1.1.7", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-use-controllable-state": "1.2.2", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.6.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-TCglVRtzlffRNxRMEyR36DGBLJpeusFcgMVD9PZEzAKnUs1lKCgX5u9BmC2Yg+LL9MgZDugFFs1Vl+Jp4t/PGw=="], + + "@radix-ui/react-direction": ["@radix-ui/react-direction@1.1.1", "https://registry.npmmirror.com/@radix-ui/react-direction/-/react-direction-1.1.1.tgz", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-1UEWRX6jnOA2y4H5WczZ44gOOjTEmlqv1uNW4GAJEO5+bauCBhv8snY65Iw5/VOS/ghKN9gr2KjnLKxrsvoMVw=="], + + "@radix-ui/react-dismissable-layer": ["@radix-ui/react-dismissable-layer@1.1.11", "https://registry.npmmirror.com/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.11.tgz", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-escape-keydown": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-Nqcp+t5cTB8BinFkZgXiMJniQH0PsUt2k51FUhbdfeKvc4ACcG2uQniY/8+h1Yv6Kza4Q7lD7PQV0z0oicE0Mg=="], + + "@radix-ui/react-focus-guards": ["@radix-ui/react-focus-guards@1.1.3", "https://registry.npmmirror.com/@radix-ui/react-focus-guards/-/react-focus-guards-1.1.3.tgz", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-0rFg/Rj2Q62NCm62jZw0QX7a3sz6QCQU0LpZdNrJX8byRGaGVTqbrW9jAoIAHyMQqsNpeZ81YgSizOt5WXq0Pw=="], + + "@radix-ui/react-focus-scope": ["@radix-ui/react-focus-scope@1.1.7", "https://registry.npmmirror.com/@radix-ui/react-focus-scope/-/react-focus-scope-1.1.7.tgz", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-t2ODlkXBQyn7jkl6TNaw/MtVEVvIGelJDCG41Okq/KwUsJBwQ4XVZsHAVUkK4mBv3ewiAS3PGuUWuY2BoK4ZUw=="], + + "@radix-ui/react-id": ["@radix-ui/react-id@1.1.1", "https://registry.npmmirror.com/@radix-ui/react-id/-/react-id-1.1.1.tgz", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg=="], + + "@radix-ui/react-popover": ["@radix-ui/react-popover@1.1.15", "https://registry.npmmirror.com/@radix-ui/react-popover/-/react-popover-1.1.15.tgz", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-dismissable-layer": "1.1.11", "@radix-ui/react-focus-guards": "1.1.3", "@radix-ui/react-focus-scope": "1.1.7", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-popper": "1.2.8", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-use-controllable-state": "1.2.2", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.6.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-kr0X2+6Yy/vJzLYJUPCZEc8SfQcf+1COFoAqauJm74umQhta9M7lNJHP7QQS3vkvcGLQUbWpMzwrXYwrYztHKA=="], + + "@radix-ui/react-popper": ["@radix-ui/react-popper@1.2.8", "https://registry.npmmirror.com/@radix-ui/react-popper/-/react-popper-1.2.8.tgz", { "dependencies": { "@floating-ui/react-dom": "^2.0.0", "@radix-ui/react-arrow": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-layout-effect": "1.1.1", "@radix-ui/react-use-rect": "1.1.1", "@radix-ui/react-use-size": "1.1.1", "@radix-ui/rect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-0NJQ4LFFUuWkE7Oxf0htBKS6zLkkjBH+hM1uk7Ng705ReR8m/uelduy1DBo0PyBXPKVnBA6YBlU94MBGXrSBCw=="], + + "@radix-ui/react-portal": ["@radix-ui/react-portal@1.1.9", "https://registry.npmmirror.com/@radix-ui/react-portal/-/react-portal-1.1.9.tgz", { "dependencies": { "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ=="], + + "@radix-ui/react-presence": ["@radix-ui/react-presence@1.1.5", "https://registry.npmmirror.com/@radix-ui/react-presence/-/react-presence-1.1.5.tgz", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-/jfEwNDdQVBCNvjkGit4h6pMOzq8bHkopq458dPt2lMjx+eBQUohZNG9A7DtO/O5ukSbxuaNGXMjHicgwy6rQQ=="], + + "@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "https://registry.npmmirror.com/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="], + + "@radix-ui/react-select": ["@radix-ui/react-select@2.2.6", "https://registry.npmmirror.com/@radix-ui/react-select/-/react-select-2.2.6.tgz", { "dependencies": { "@radix-ui/number": "1.1.1", "@radix-ui/primitive": "1.1.3", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-dismissable-layer": "1.1.11", "@radix-ui/react-focus-guards": "1.1.3", "@radix-ui/react-focus-scope": "1.1.7", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-popper": "1.2.8", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-layout-effect": "1.1.1", "@radix-ui/react-use-previous": "1.1.1", "@radix-ui/react-visually-hidden": "1.2.3", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.6.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-I30RydO+bnn2PQztvo25tswPH+wFBjehVGtmagkU78yMdwTwVf12wnAOF+AeP8S2N8xD+5UPbGhkUfPyvT+mwQ=="], + + "@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "https://registry.npmmirror.com/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], + + "@radix-ui/react-use-callback-ref": ["@radix-ui/react-use-callback-ref@1.1.1", "https://registry.npmmirror.com/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.1.tgz", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg=="], + + "@radix-ui/react-use-controllable-state": ["@radix-ui/react-use-controllable-state@1.2.2", "https://registry.npmmirror.com/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.2.2.tgz", { "dependencies": { "@radix-ui/react-use-effect-event": "0.0.2", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg=="], + + "@radix-ui/react-use-effect-event": ["@radix-ui/react-use-effect-event@0.0.2", "https://registry.npmmirror.com/@radix-ui/react-use-effect-event/-/react-use-effect-event-0.0.2.tgz", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Qp8WbZOBe+blgpuUT+lw2xheLP8q0oatc9UpmiemEICxGvFLYmHm9QowVZGHtJlGbS6A6yJ3iViad/2cVjnOiA=="], + + "@radix-ui/react-use-escape-keydown": ["@radix-ui/react-use-escape-keydown@1.1.1", "https://registry.npmmirror.com/@radix-ui/react-use-escape-keydown/-/react-use-escape-keydown-1.1.1.tgz", { "dependencies": { "@radix-ui/react-use-callback-ref": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Il0+boE7w/XebUHyBjroE+DbByORGR9KKmITzbR7MyQ4akpORYP/ZmbhAr0DG7RmmBqoOnZdy2QlvajJ2QA59g=="], + + "@radix-ui/react-use-layout-effect": ["@radix-ui/react-use-layout-effect@1.1.1", "https://registry.npmmirror.com/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.1.tgz", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ=="], + + "@radix-ui/react-use-previous": ["@radix-ui/react-use-previous@1.1.1", "https://registry.npmmirror.com/@radix-ui/react-use-previous/-/react-use-previous-1.1.1.tgz", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-2dHfToCj/pzca2Ck724OZ5L0EVrr3eHRNsG/b3xQJLA2hZpVCS99bLAX+hm1IHXDEnzU6by5z/5MIY794/a8NQ=="], + + "@radix-ui/react-use-rect": ["@radix-ui/react-use-rect@1.1.1", "https://registry.npmmirror.com/@radix-ui/react-use-rect/-/react-use-rect-1.1.1.tgz", { "dependencies": { "@radix-ui/rect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-QTYuDesS0VtuHNNvMh+CjlKJ4LJickCMUAqjlE3+j8w+RlRpwyX3apEQKGFzbZGdo7XNG1tXa+bQqIE7HIXT2w=="], + + "@radix-ui/react-use-size": ["@radix-ui/react-use-size@1.1.1", "https://registry.npmmirror.com/@radix-ui/react-use-size/-/react-use-size-1.1.1.tgz", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-ewrXRDTAqAXlkl6t/fkXWNAhFX9I+CkKlw6zjEwk86RSPKwZr3xpBRso655aqYafwtnbpHLj6toFzmd6xdVptQ=="], + + "@radix-ui/react-visually-hidden": ["@radix-ui/react-visually-hidden@1.2.3", "https://registry.npmmirror.com/@radix-ui/react-visually-hidden/-/react-visually-hidden-1.2.3.tgz", { "dependencies": { "@radix-ui/react-primitive": "2.1.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-pzJq12tEaaIhqjbzpCuv/OypJY/BPavOofm+dbab+MHLajy277+1lLm6JFcGgF5eskJ6mquGirhXY2GD/8u8Ug=="], + + "@radix-ui/rect": ["@radix-ui/rect@1.1.1", "https://registry.npmmirror.com/@radix-ui/rect/-/rect-1.1.1.tgz", {}, "sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw=="], + + "@rtsao/scc": ["@rtsao/scc@1.1.0", "https://registry.npmmirror.com/@rtsao/scc/-/scc-1.1.0.tgz", {}, "sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g=="], + + "@swc/helpers": ["@swc/helpers@0.5.15", "https://registry.npmmirror.com/@swc/helpers/-/helpers-0.5.15.tgz", { "dependencies": { "tslib": "^2.8.0" } }, "sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g=="], + + "@tabby_ai/hijri-converter": ["@tabby_ai/hijri-converter@1.0.5", "https://registry.npmmirror.com/@tabby_ai/hijri-converter/-/hijri-converter-1.0.5.tgz", {}, "sha512-r5bClKrcIusDoo049dSL8CawnHR6mRdDwhlQuIgZRNty68q0x8k3Lf1BtPAMxRf/GgnHBnIO4ujd3+GQdLWzxQ=="], + + "@tailwindcss/node": ["@tailwindcss/node@4.1.10", "https://registry.npmmirror.com/@tailwindcss/node/-/node-4.1.10.tgz", { "dependencies": { "@ampproject/remapping": "^2.3.0", "enhanced-resolve": "^5.18.1", "jiti": "^2.4.2", "lightningcss": "1.30.1", "magic-string": "^0.30.17", "source-map-js": "^1.2.1", "tailwindcss": "4.1.10" } }, "sha512-2ACf1znY5fpRBwRhMgj9ZXvb2XZW8qs+oTfotJ2C5xR0/WNL7UHZ7zXl6s+rUqedL1mNi+0O+WQr5awGowS3PQ=="], + + "@tailwindcss/oxide": ["@tailwindcss/oxide@4.1.10", "https://registry.npmmirror.com/@tailwindcss/oxide/-/oxide-4.1.10.tgz", { "dependencies": { "detect-libc": "^2.0.4", "tar": "^7.4.3" }, "optionalDependencies": { "@tailwindcss/oxide-android-arm64": "4.1.10", "@tailwindcss/oxide-darwin-arm64": "4.1.10", "@tailwindcss/oxide-darwin-x64": "4.1.10", "@tailwindcss/oxide-freebsd-x64": "4.1.10", "@tailwindcss/oxide-linux-arm-gnueabihf": "4.1.10", "@tailwindcss/oxide-linux-arm64-gnu": "4.1.10", "@tailwindcss/oxide-linux-arm64-musl": "4.1.10", "@tailwindcss/oxide-linux-x64-gnu": "4.1.10", "@tailwindcss/oxide-linux-x64-musl": "4.1.10", "@tailwindcss/oxide-wasm32-wasi": "4.1.10", "@tailwindcss/oxide-win32-arm64-msvc": "4.1.10", "@tailwindcss/oxide-win32-x64-msvc": "4.1.10" } }, "sha512-v0C43s7Pjw+B9w21htrQwuFObSkio2aV/qPx/mhrRldbqxbWJK6KizM+q7BF1/1CmuLqZqX3CeYF7s7P9fbA8Q=="], + + "@tailwindcss/oxide-android-arm64": ["@tailwindcss/oxide-android-arm64@4.1.10", "https://registry.npmmirror.com/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.1.10.tgz", { "os": "android", "cpu": "arm64" }, "sha512-VGLazCoRQ7rtsCzThaI1UyDu/XRYVyH4/EWiaSX6tFglE+xZB5cvtC5Omt0OQ+FfiIVP98su16jDVHDEIuH4iQ=="], + + "@tailwindcss/oxide-darwin-arm64": ["@tailwindcss/oxide-darwin-arm64@4.1.10", "https://registry.npmmirror.com/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.1.10.tgz", { "os": "darwin", "cpu": "arm64" }, "sha512-ZIFqvR1irX2yNjWJzKCqTCcHZbgkSkSkZKbRM3BPzhDL/18idA8uWCoopYA2CSDdSGFlDAxYdU2yBHwAwx8euQ=="], + + "@tailwindcss/oxide-darwin-x64": ["@tailwindcss/oxide-darwin-x64@4.1.10", "https://registry.npmmirror.com/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.1.10.tgz", { "os": "darwin", "cpu": "x64" }, "sha512-eCA4zbIhWUFDXoamNztmS0MjXHSEJYlvATzWnRiTqJkcUteSjO94PoRHJy1Xbwp9bptjeIxxBHh+zBWFhttbrQ=="], + + "@tailwindcss/oxide-freebsd-x64": ["@tailwindcss/oxide-freebsd-x64@4.1.10", "https://registry.npmmirror.com/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.1.10.tgz", { "os": "freebsd", "cpu": "x64" }, "sha512-8/392Xu12R0cc93DpiJvNpJ4wYVSiciUlkiOHOSOQNH3adq9Gi/dtySK7dVQjXIOzlpSHjeCL89RUUI8/GTI6g=="], + + "@tailwindcss/oxide-linux-arm-gnueabihf": ["@tailwindcss/oxide-linux-arm-gnueabihf@4.1.10", "https://registry.npmmirror.com/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.1.10.tgz", { "os": "linux", "cpu": "arm" }, "sha512-t9rhmLT6EqeuPT+MXhWhlRYIMSfh5LZ6kBrC4FS6/+M1yXwfCtp24UumgCWOAJVyjQwG+lYva6wWZxrfvB+NhQ=="], + + "@tailwindcss/oxide-linux-arm64-gnu": ["@tailwindcss/oxide-linux-arm64-gnu@4.1.10", "https://registry.npmmirror.com/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.1.10.tgz", { "os": "linux", "cpu": "arm64" }, "sha512-3oWrlNlxLRxXejQ8zImzrVLuZ/9Z2SeKoLhtCu0hpo38hTO2iL86eFOu4sVR8cZc6n3z7eRXXqtHJECa6mFOvA=="], + + "@tailwindcss/oxide-linux-arm64-musl": ["@tailwindcss/oxide-linux-arm64-musl@4.1.10", "https://registry.npmmirror.com/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.1.10.tgz", { "os": "linux", "cpu": "arm64" }, "sha512-saScU0cmWvg/Ez4gUmQWr9pvY9Kssxt+Xenfx1LG7LmqjcrvBnw4r9VjkFcqmbBb7GCBwYNcZi9X3/oMda9sqQ=="], + + "@tailwindcss/oxide-linux-x64-gnu": ["@tailwindcss/oxide-linux-x64-gnu@4.1.10", "https://registry.npmmirror.com/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.1.10.tgz", { "os": "linux", "cpu": "x64" }, "sha512-/G3ao/ybV9YEEgAXeEg28dyH6gs1QG8tvdN9c2MNZdUXYBaIY/Gx0N6RlJzfLy/7Nkdok4kaxKPHKJUlAaoTdA=="], + + "@tailwindcss/oxide-linux-x64-musl": ["@tailwindcss/oxide-linux-x64-musl@4.1.10", "https://registry.npmmirror.com/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.1.10.tgz", { "os": "linux", "cpu": "x64" }, "sha512-LNr7X8fTiKGRtQGOerSayc2pWJp/9ptRYAa4G+U+cjw9kJZvkopav1AQc5HHD+U364f71tZv6XamaHKgrIoVzA=="], + + "@tailwindcss/oxide-wasm32-wasi": ["@tailwindcss/oxide-wasm32-wasi@4.1.10", "https://registry.npmmirror.com/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.1.10.tgz", { "cpu": "none" }, "sha512-d6ekQpopFQJAcIK2i7ZzWOYGZ+A6NzzvQ3ozBvWFdeyqfOZdYHU66g5yr+/HC4ipP1ZgWsqa80+ISNILk+ae/Q=="], + + "@tailwindcss/oxide-win32-arm64-msvc": ["@tailwindcss/oxide-win32-arm64-msvc@4.1.10", "https://registry.npmmirror.com/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.1.10.tgz", { "os": "win32", "cpu": "arm64" }, "sha512-i1Iwg9gRbwNVOCYmnigWCCgow8nDWSFmeTUU5nbNx3rqbe4p0kRbEqLwLJbYZKmSSp23g4N6rCDmm7OuPBXhDA=="], + + "@tailwindcss/oxide-win32-x64-msvc": ["@tailwindcss/oxide-win32-x64-msvc@4.1.10", "https://registry.npmmirror.com/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.1.10.tgz", { "os": "win32", "cpu": "x64" }, "sha512-sGiJTjcBSfGq2DVRtaSljq5ZgZS2SDHSIfhOylkBvHVjwOsodBhnb3HdmiKkVuUGKD0I7G63abMOVaskj1KpOA=="], + + "@tailwindcss/postcss": ["@tailwindcss/postcss@4.1.10", "https://registry.npmmirror.com/@tailwindcss/postcss/-/postcss-4.1.10.tgz", { "dependencies": { "@alloc/quick-lru": "^5.2.0", "@tailwindcss/node": "4.1.10", "@tailwindcss/oxide": "4.1.10", "postcss": "^8.4.41", "tailwindcss": "4.1.10" } }, "sha512-B+7r7ABZbkXJwpvt2VMnS6ujcDoR2OOcFaqrLIo1xbcdxje4Vf+VgJdBzNNbrAjBj/rLZ66/tlQ1knIGNLKOBQ=="], + + "@tybys/wasm-util": ["@tybys/wasm-util@0.9.0", "https://registry.npmmirror.com/@tybys/wasm-util/-/wasm-util-0.9.0.tgz", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-6+7nlbMVX/PVDCwaIQ8nTOPveOcFLSt8GcXdx8hD0bt39uWxYT88uXzqTd4fTvqta7oeUJqudepapKNt2DYJFw=="], + + "@types/estree": ["@types/estree@1.0.8", "https://registry.npmmirror.com/@types/estree/-/estree-1.0.8.tgz", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="], + + "@types/json-schema": ["@types/json-schema@7.0.15", "https://registry.npmmirror.com/@types/json-schema/-/json-schema-7.0.15.tgz", {}, "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA=="], + + "@types/json5": ["@types/json5@0.0.29", "https://registry.npmmirror.com/@types/json5/-/json5-0.0.29.tgz", {}, "sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ=="], + + "@types/node": ["@types/node@20.19.1", "https://registry.npmmirror.com/@types/node/-/node-20.19.1.tgz", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-jJD50LtlD2dodAEO653i3YF04NWak6jN3ky+Ri3Em3mGR39/glWiboM/IePaRbgwSfqM1TpGXfAg8ohn/4dTgA=="], + + "@types/react": ["@types/react@19.1.12", "https://registry.npmmirror.com/@types/react/-/react-19.1.12.tgz", { "dependencies": { "csstype": "^3.0.2" } }, "sha512-cMoR+FoAf/Jyq6+Df2/Z41jISvGZZ2eTlnsaJRptmZ76Caldwy1odD4xTr/gNV9VLj0AWgg/nmkevIyUfIIq5w=="], + + "@types/react-dom": ["@types/react-dom@19.1.9", "https://registry.npmmirror.com/@types/react-dom/-/react-dom-19.1.9.tgz", { "peerDependencies": { "@types/react": "^19.0.0" } }, "sha512-qXRuZaOsAdXKFyOhRBg6Lqqc0yay13vN7KrIg4L7N4aaHN68ma9OK3NE1BoDFgFOTfM7zg+3/8+2n8rLUH3OKQ=="], + + "@typescript-eslint/eslint-plugin": ["@typescript-eslint/eslint-plugin@8.58.2", "", { "dependencies": { "@eslint-community/regexpp": "^4.12.2", "@typescript-eslint/scope-manager": "8.58.2", "@typescript-eslint/type-utils": "8.58.2", "@typescript-eslint/utils": "8.58.2", "@typescript-eslint/visitor-keys": "8.58.2", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "@typescript-eslint/parser": "^8.58.2", "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-aC2qc5thQahutKjP+cl8cgN9DWe3ZUqVko30CMSZHnFEHyhOYoZSzkGtAI2mcwZ38xeImDucI4dnqsHiOYuuCw=="], + + "@typescript-eslint/parser": ["@typescript-eslint/parser@8.58.2", "", { "dependencies": { "@typescript-eslint/scope-manager": "8.58.2", "@typescript-eslint/types": "8.58.2", "@typescript-eslint/typescript-estree": "8.58.2", "@typescript-eslint/visitor-keys": "8.58.2", "debug": "^4.4.3" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-/Zb/xaIDfxeJnvishjGdcR4jmr7S+bda8PKNhRGdljDM+elXhlvN0FyPSsMnLmJUrVG9aPO6dof80wjMawsASg=="], + + "@typescript-eslint/project-service": ["@typescript-eslint/project-service@8.58.2", "", { "dependencies": { "@typescript-eslint/tsconfig-utils": "^8.58.2", "@typescript-eslint/types": "^8.58.2", "debug": "^4.4.3" }, "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-Cq6UfpZZk15+r87BkIh5rDpi38W4b+Sjnb8wQCPPDDweS/LRCFjCyViEbzHk5Ck3f2QDfgmlxqSa7S7clDtlfg=="], + + "@typescript-eslint/scope-manager": ["@typescript-eslint/scope-manager@8.58.2", "", { "dependencies": { "@typescript-eslint/types": "8.58.2", "@typescript-eslint/visitor-keys": "8.58.2" } }, "sha512-SgmyvDPexWETQek+qzZnrG6844IaO02UVyOLhI4wpo82dpZJY9+6YZCKAMFzXb7qhx37mFK1QcPQ18tud+vo6Q=="], + + "@typescript-eslint/tsconfig-utils": ["@typescript-eslint/tsconfig-utils@8.58.2", "", { "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-3SR+RukipDvkkKp/d0jP0dyzuls3DbGmwDpVEc5wqk5f38KFThakqAAO0XMirWAE+kT00oTauTbzMFGPoAzB0A=="], + + "@typescript-eslint/type-utils": ["@typescript-eslint/type-utils@8.58.2", "", { "dependencies": { "@typescript-eslint/types": "8.58.2", "@typescript-eslint/typescript-estree": "8.58.2", "@typescript-eslint/utils": "8.58.2", "debug": "^4.4.3", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-Z7EloNR/B389FvabdGeTo2XMs4W9TjtPiO9DAsmT0yom0bwlPyRjkJ1uCdW1DvrrrYP50AJZ9Xc3sByZA9+dcg=="], + + "@typescript-eslint/types": ["@typescript-eslint/types@8.58.2", "", {}, "sha512-9TukXyATBQf/Jq9AMQXfvurk+G5R2MwfqQGDR2GzGz28HvY/lXNKGhkY+6IOubwcquikWk5cjlgPvD2uAA7htQ=="], + + "@typescript-eslint/typescript-estree": ["@typescript-eslint/typescript-estree@8.58.2", "", { "dependencies": { "@typescript-eslint/project-service": "8.58.2", "@typescript-eslint/tsconfig-utils": "8.58.2", "@typescript-eslint/types": "8.58.2", "@typescript-eslint/visitor-keys": "8.58.2", "debug": "^4.4.3", "minimatch": "^10.2.2", "semver": "^7.7.3", "tinyglobby": "^0.2.15", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-ELGuoofuhhoCvNbQjFFiobFcGgcDCEm0ThWdmO4Z0UzLqPXS3KFvnEZ+SHewwOYHjM09tkzOWXNTv9u6Gqtyuw=="], + + "@typescript-eslint/utils": ["@typescript-eslint/utils@8.58.2", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", "@typescript-eslint/scope-manager": "8.58.2", "@typescript-eslint/types": "8.58.2", "@typescript-eslint/typescript-estree": "8.58.2" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-QZfjHNEzPY8+l0+fIXMvuQ2sJlplB4zgDZvA+NmvZsZv3EQwOcc1DuIU1VJUTWZ/RKouBMhDyNaBMx4sWvrzRA=="], + + "@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.58.2", "", { "dependencies": { "@typescript-eslint/types": "8.58.2", "eslint-visitor-keys": "^5.0.0" } }, "sha512-f1WO2Lx8a9t8DARmcWAUPJbu0G20bJlj8L4z72K00TMeJAoyLr/tHhI/pzYBLrR4dXWkcxO1cWYZEOX8DKHTqA=="], + + "@umijs/openapi": ["@umijs/openapi@1.13.15", "https://registry.npmmirror.com/@umijs/openapi/-/openapi-1.13.15.tgz", { "dependencies": { "chalk": "^4.1.2", "cosmiconfig": "^9.0.0", "dayjs": "^1.10.3", "glob": "^7.1.6", "lodash": "^4.17.21", "memoizee": "^0.4.15", "mock.js": "^0.2.0", "mockjs": "^1.1.0", "node-fetch": "^2.6.1", "number-to-words": "^1.2.4", "nunjucks": "^3.2.2", "openapi3-ts": "^2.0.1", "prettier": "^2.2.1", "reserved-words": "^0.1.2", "rimraf": "^3.0.2", "swagger2openapi": "^7.0.4", "tiny-pinyin": "^1.3.2" }, "bin": { "openapi2ts": "dist/cli.js" } }, "sha512-+oJBEXV9Liu7tZzkYANs72hXiwqEngVhpUQN+XLVsAr49+D6thr+Fyb0cezcrydulOSsxa+VPaPMXPmXbAVuYA=="], + + "@unrs/resolver-binding-android-arm-eabi": ["@unrs/resolver-binding-android-arm-eabi@1.9.2", "https://registry.npmmirror.com/@unrs/resolver-binding-android-arm-eabi/-/resolver-binding-android-arm-eabi-1.9.2.tgz", { "os": "android", "cpu": "arm" }, "sha512-tS+lqTU3N0kkthU+rYp0spAYq15DU8ld9kXkaKg9sbQqJNF+WPMuNHZQGCgdxrUOEO0j22RKMwRVhF1HTl+X8A=="], + + "@unrs/resolver-binding-android-arm64": ["@unrs/resolver-binding-android-arm64@1.9.2", "https://registry.npmmirror.com/@unrs/resolver-binding-android-arm64/-/resolver-binding-android-arm64-1.9.2.tgz", { "os": "android", "cpu": "arm64" }, "sha512-MffGiZULa/KmkNjHeuuflLVqfhqLv1vZLm8lWIyeADvlElJ/GLSOkoUX+5jf4/EGtfwrNFcEaB8BRas03KT0/Q=="], + + "@unrs/resolver-binding-darwin-arm64": ["@unrs/resolver-binding-darwin-arm64@1.9.2", "https://registry.npmmirror.com/@unrs/resolver-binding-darwin-arm64/-/resolver-binding-darwin-arm64-1.9.2.tgz", { "os": "darwin", "cpu": "arm64" }, "sha512-dzJYK5rohS1sYl1DHdJ3mwfwClJj5BClQnQSyAgEfggbUwA9RlROQSSbKBLqrGfsiC/VyrDPtbO8hh56fnkbsQ=="], + + "@unrs/resolver-binding-darwin-x64": ["@unrs/resolver-binding-darwin-x64@1.9.2", "https://registry.npmmirror.com/@unrs/resolver-binding-darwin-x64/-/resolver-binding-darwin-x64-1.9.2.tgz", { "os": "darwin", "cpu": "x64" }, "sha512-gaIMWK+CWtXcg9gUyznkdV54LzQ90S3X3dn8zlh+QR5Xy7Y+Efqw4Rs4im61K1juy4YNb67vmJsCDAGOnIeffQ=="], + + "@unrs/resolver-binding-freebsd-x64": ["@unrs/resolver-binding-freebsd-x64@1.9.2", "https://registry.npmmirror.com/@unrs/resolver-binding-freebsd-x64/-/resolver-binding-freebsd-x64-1.9.2.tgz", { "os": "freebsd", "cpu": "x64" }, "sha512-S7QpkMbVoVJb0xwHFwujnwCAEDe/596xqY603rpi/ioTn9VDgBHnCCxh+UFrr5yxuMH+dliHfjwCZJXOPJGPnw=="], + + "@unrs/resolver-binding-linux-arm-gnueabihf": ["@unrs/resolver-binding-linux-arm-gnueabihf@1.9.2", "https://registry.npmmirror.com/@unrs/resolver-binding-linux-arm-gnueabihf/-/resolver-binding-linux-arm-gnueabihf-1.9.2.tgz", { "os": "linux", "cpu": "arm" }, "sha512-+XPUMCuCCI80I46nCDFbGum0ZODP5NWGiwS3Pj8fOgsG5/ctz+/zzuBlq/WmGa+EjWZdue6CF0aWWNv84sE1uw=="], + + "@unrs/resolver-binding-linux-arm-musleabihf": ["@unrs/resolver-binding-linux-arm-musleabihf@1.9.2", "https://registry.npmmirror.com/@unrs/resolver-binding-linux-arm-musleabihf/-/resolver-binding-linux-arm-musleabihf-1.9.2.tgz", { "os": "linux", "cpu": "arm" }, "sha512-sqvUyAd1JUpwbz33Ce2tuTLJKM+ucSsYpPGl2vuFwZnEIg0CmdxiZ01MHQ3j6ExuRqEDUCy8yvkDKvjYFPb8Zg=="], + + "@unrs/resolver-binding-linux-arm64-gnu": ["@unrs/resolver-binding-linux-arm64-gnu@1.9.2", "https://registry.npmmirror.com/@unrs/resolver-binding-linux-arm64-gnu/-/resolver-binding-linux-arm64-gnu-1.9.2.tgz", { "os": "linux", "cpu": "arm64" }, "sha512-UYA0MA8ajkEDCFRQdng/FVx3F6szBvk3EPnkTTQuuO9lV1kPGuTB+V9TmbDxy5ikaEgyWKxa4CI3ySjklZ9lFA=="], + + "@unrs/resolver-binding-linux-arm64-musl": ["@unrs/resolver-binding-linux-arm64-musl@1.9.2", "https://registry.npmmirror.com/@unrs/resolver-binding-linux-arm64-musl/-/resolver-binding-linux-arm64-musl-1.9.2.tgz", { "os": "linux", "cpu": "arm64" }, "sha512-P/CO3ODU9YJIHFqAkHbquKtFst0COxdphc8TKGL5yCX75GOiVpGqd1d15ahpqu8xXVsqP4MGFP2C3LRZnnL5MA=="], + + "@unrs/resolver-binding-linux-ppc64-gnu": ["@unrs/resolver-binding-linux-ppc64-gnu@1.9.2", "https://registry.npmmirror.com/@unrs/resolver-binding-linux-ppc64-gnu/-/resolver-binding-linux-ppc64-gnu-1.9.2.tgz", { "os": "linux", "cpu": "ppc64" }, "sha512-uKStFlOELBxBum2s1hODPtgJhY4NxYJE9pAeyBgNEzHgTqTiVBPjfTlPFJkfxyTjQEuxZbbJlJnMCrRgD7ubzw=="], + + "@unrs/resolver-binding-linux-riscv64-gnu": ["@unrs/resolver-binding-linux-riscv64-gnu@1.9.2", "https://registry.npmmirror.com/@unrs/resolver-binding-linux-riscv64-gnu/-/resolver-binding-linux-riscv64-gnu-1.9.2.tgz", { "os": "linux", "cpu": "none" }, "sha512-LkbNnZlhINfY9gK30AHs26IIVEZ9PEl9qOScYdmY2o81imJYI4IMnJiW0vJVtXaDHvBvxeAgEy5CflwJFIl3tQ=="], + + "@unrs/resolver-binding-linux-riscv64-musl": ["@unrs/resolver-binding-linux-riscv64-musl@1.9.2", "https://registry.npmmirror.com/@unrs/resolver-binding-linux-riscv64-musl/-/resolver-binding-linux-riscv64-musl-1.9.2.tgz", { "os": "linux", "cpu": "none" }, "sha512-vI+e6FzLyZHSLFNomPi+nT+qUWN4YSj8pFtQZSFTtmgFoxqB6NyjxSjAxEC1m93qn6hUXhIsh8WMp+fGgxCoRg=="], + + "@unrs/resolver-binding-linux-s390x-gnu": ["@unrs/resolver-binding-linux-s390x-gnu@1.9.2", "https://registry.npmmirror.com/@unrs/resolver-binding-linux-s390x-gnu/-/resolver-binding-linux-s390x-gnu-1.9.2.tgz", { "os": "linux", "cpu": "s390x" }, "sha512-sSO4AlAYhSM2RAzBsRpahcJB1msc6uYLAtP6pesPbZtptF8OU/CbCPhSRW6cnYOGuVmEmWVW5xVboAqCnWTeHQ=="], + + "@unrs/resolver-binding-linux-x64-gnu": ["@unrs/resolver-binding-linux-x64-gnu@1.9.2", "https://registry.npmmirror.com/@unrs/resolver-binding-linux-x64-gnu/-/resolver-binding-linux-x64-gnu-1.9.2.tgz", { "os": "linux", "cpu": "x64" }, "sha512-jkSkwch0uPFva20Mdu8orbQjv2A3G88NExTN2oPTI1AJ+7mZfYW3cDCTyoH6OnctBKbBVeJCEqh0U02lTkqD5w=="], + + "@unrs/resolver-binding-linux-x64-musl": ["@unrs/resolver-binding-linux-x64-musl@1.9.2", "https://registry.npmmirror.com/@unrs/resolver-binding-linux-x64-musl/-/resolver-binding-linux-x64-musl-1.9.2.tgz", { "os": "linux", "cpu": "x64" }, "sha512-Uk64NoiTpQbkpl+bXsbeyOPRpUoMdcUqa+hDC1KhMW7aN1lfW8PBlBH4mJ3n3Y47dYE8qi0XTxy1mBACruYBaw=="], + + "@unrs/resolver-binding-wasm32-wasi": ["@unrs/resolver-binding-wasm32-wasi@1.9.2", "https://registry.npmmirror.com/@unrs/resolver-binding-wasm32-wasi/-/resolver-binding-wasm32-wasi-1.9.2.tgz", { "dependencies": { "@napi-rs/wasm-runtime": "^0.2.11" }, "cpu": "none" }, "sha512-EpBGwkcjDicjR/ybC0g8wO5adPNdVuMrNalVgYcWi+gYtC1XYNuxe3rufcO7dA76OHGeVabcO6cSkPJKVcbCXQ=="], + + "@unrs/resolver-binding-win32-arm64-msvc": ["@unrs/resolver-binding-win32-arm64-msvc@1.9.2", "https://registry.npmmirror.com/@unrs/resolver-binding-win32-arm64-msvc/-/resolver-binding-win32-arm64-msvc-1.9.2.tgz", { "os": "win32", "cpu": "arm64" }, "sha512-EdFbGn7o1SxGmN6aZw9wAkehZJetFPao0VGZ9OMBwKx6TkvDuj6cNeLimF/Psi6ts9lMOe+Dt6z19fZQ9Ye2fw=="], + + "@unrs/resolver-binding-win32-ia32-msvc": ["@unrs/resolver-binding-win32-ia32-msvc@1.9.2", "https://registry.npmmirror.com/@unrs/resolver-binding-win32-ia32-msvc/-/resolver-binding-win32-ia32-msvc-1.9.2.tgz", { "os": "win32", "cpu": "ia32" }, "sha512-JY9hi1p7AG+5c/dMU8o2kWemM8I6VZxfGwn1GCtf3c5i+IKcMo2NQ8OjZ4Z3/itvY/Si3K10jOBQn7qsD/whUA=="], + + "@unrs/resolver-binding-win32-x64-msvc": ["@unrs/resolver-binding-win32-x64-msvc@1.9.2", "https://registry.npmmirror.com/@unrs/resolver-binding-win32-x64-msvc/-/resolver-binding-win32-x64-msvc-1.9.2.tgz", { "os": "win32", "cpu": "x64" }, "sha512-ryoo+EB19lMxAd80ln9BVf8pdOAxLb97amrQ3SFN9OCRn/5M5wvwDgAe4i8ZjhpbiHoDeP8yavcTEnpKBo7lZg=="], + + "a-sync-waterfall": ["a-sync-waterfall@1.0.1", "https://registry.npmmirror.com/a-sync-waterfall/-/a-sync-waterfall-1.0.1.tgz", {}, "sha512-RYTOHHdWipFUliRFMCS4X2Yn2X8M87V/OpSqWzKKOGhzqyUxzyVmhHDH9sAvG+ZuQf/TAOFsLCpMw09I1ufUnA=="], + + "acorn": ["acorn@8.15.0", "https://registry.npmmirror.com/acorn/-/acorn-8.15.0.tgz", { "bin": "bin/acorn" }, "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg=="], + + "acorn-jsx": ["acorn-jsx@5.3.2", "https://registry.npmmirror.com/acorn-jsx/-/acorn-jsx-5.3.2.tgz", { "peerDependencies": { "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ=="], + + "ajv": ["ajv@6.12.6", "https://registry.npmmirror.com/ajv/-/ajv-6.12.6.tgz", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g=="], + + "ansi-regex": ["ansi-regex@5.0.1", "https://registry.npmmirror.com/ansi-regex/-/ansi-regex-5.0.1.tgz", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], + + "ansi-styles": ["ansi-styles@4.3.0", "https://registry.npmmirror.com/ansi-styles/-/ansi-styles-4.3.0.tgz", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], + + "argparse": ["argparse@2.0.1", "https://registry.npmmirror.com/argparse/-/argparse-2.0.1.tgz", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="], + + "aria-hidden": ["aria-hidden@1.2.6", "https://registry.npmmirror.com/aria-hidden/-/aria-hidden-1.2.6.tgz", { "dependencies": { "tslib": "^2.0.0" } }, "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA=="], + + "aria-query": ["aria-query@5.3.2", "https://registry.npmmirror.com/aria-query/-/aria-query-5.3.2.tgz", {}, "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw=="], + + "array-buffer-byte-length": ["array-buffer-byte-length@1.0.2", "https://registry.npmmirror.com/array-buffer-byte-length/-/array-buffer-byte-length-1.0.2.tgz", { "dependencies": { "call-bound": "^1.0.3", "is-array-buffer": "^3.0.5" } }, "sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw=="], + + "array-includes": ["array-includes@3.1.9", "https://registry.npmmirror.com/array-includes/-/array-includes-3.1.9.tgz", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.4", "define-properties": "^1.2.1", "es-abstract": "^1.24.0", "es-object-atoms": "^1.1.1", "get-intrinsic": "^1.3.0", "is-string": "^1.1.1", "math-intrinsics": "^1.1.0" } }, "sha512-FmeCCAenzH0KH381SPT5FZmiA/TmpndpcaShhfgEN9eCVjnFBqq3l1xrI42y8+PPLI6hypzou4GXw00WHmPBLQ=="], + + "array.prototype.findlast": ["array.prototype.findlast@1.2.5", "https://registry.npmmirror.com/array.prototype.findlast/-/array.prototype.findlast-1.2.5.tgz", { "dependencies": { "call-bind": "^1.0.7", "define-properties": "^1.2.1", "es-abstract": "^1.23.2", "es-errors": "^1.3.0", "es-object-atoms": "^1.0.0", "es-shim-unscopables": "^1.0.2" } }, "sha512-CVvd6FHg1Z3POpBLxO6E6zr+rSKEQ9L6rZHAaY7lLfhKsWYUBBOuMs0e9o24oopj6H+geRCX0YJ+TJLBK2eHyQ=="], + + "array.prototype.findlastindex": ["array.prototype.findlastindex@1.2.6", "https://registry.npmmirror.com/array.prototype.findlastindex/-/array.prototype.findlastindex-1.2.6.tgz", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.4", "define-properties": "^1.2.1", "es-abstract": "^1.23.9", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "es-shim-unscopables": "^1.1.0" } }, "sha512-F/TKATkzseUExPlfvmwQKGITM3DGTK+vkAsCZoDc5daVygbJBnjEUCbgkAvVFsgfXfX4YIqZ/27G3k3tdXrTxQ=="], + + "array.prototype.flat": ["array.prototype.flat@1.3.3", "https://registry.npmmirror.com/array.prototype.flat/-/array.prototype.flat-1.3.3.tgz", { "dependencies": { "call-bind": "^1.0.8", "define-properties": "^1.2.1", "es-abstract": "^1.23.5", "es-shim-unscopables": "^1.0.2" } }, "sha512-rwG/ja1neyLqCuGZ5YYrznA62D4mZXg0i1cIskIUKSiqF3Cje9/wXAls9B9s1Wa2fomMsIv8czB8jZcPmxCXFg=="], + + "array.prototype.flatmap": ["array.prototype.flatmap@1.3.3", "https://registry.npmmirror.com/array.prototype.flatmap/-/array.prototype.flatmap-1.3.3.tgz", { "dependencies": { "call-bind": "^1.0.8", "define-properties": "^1.2.1", "es-abstract": "^1.23.5", "es-shim-unscopables": "^1.0.2" } }, "sha512-Y7Wt51eKJSyi80hFrJCePGGNo5ktJCslFuboqJsbf57CCPcm5zztluPlc4/aD8sWsKvlwatezpV4U1efk8kpjg=="], + + "array.prototype.tosorted": ["array.prototype.tosorted@1.1.4", "https://registry.npmmirror.com/array.prototype.tosorted/-/array.prototype.tosorted-1.1.4.tgz", { "dependencies": { "call-bind": "^1.0.7", "define-properties": "^1.2.1", "es-abstract": "^1.23.3", "es-errors": "^1.3.0", "es-shim-unscopables": "^1.0.2" } }, "sha512-p6Fx8B7b7ZhL/gmUsAy0D15WhvDccw3mnGNbZpi3pmeJdxtWsj2jEaI4Y6oo3XiHfzuSgPwKc04MYt6KgvC/wA=="], + + "arraybuffer.prototype.slice": ["arraybuffer.prototype.slice@1.0.4", "https://registry.npmmirror.com/arraybuffer.prototype.slice/-/arraybuffer.prototype.slice-1.0.4.tgz", { "dependencies": { "array-buffer-byte-length": "^1.0.1", "call-bind": "^1.0.8", "define-properties": "^1.2.1", "es-abstract": "^1.23.5", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.6", "is-array-buffer": "^3.0.4" } }, "sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ=="], + + "asap": ["asap@2.0.6", "https://registry.npmmirror.com/asap/-/asap-2.0.6.tgz", {}, "sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA=="], + + "ast-types-flow": ["ast-types-flow@0.0.8", "https://registry.npmmirror.com/ast-types-flow/-/ast-types-flow-0.0.8.tgz", {}, "sha512-OH/2E5Fg20h2aPrbe+QL8JZQFko0YZaF+j4mnQ7BGhfavO7OpSLa8a0y9sBwomHdSbkhTS8TQNayBfnW5DwbvQ=="], + + "async-function": ["async-function@1.0.0", "https://registry.npmmirror.com/async-function/-/async-function-1.0.0.tgz", {}, "sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA=="], + + "asynckit": ["asynckit@0.4.0", "https://registry.npmmirror.com/asynckit/-/asynckit-0.4.0.tgz", {}, "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q=="], + + "available-typed-arrays": ["available-typed-arrays@1.0.7", "https://registry.npmmirror.com/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz", { "dependencies": { "possible-typed-array-names": "^1.0.0" } }, "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ=="], + + "axe-core": ["axe-core@4.10.3", "https://registry.npmmirror.com/axe-core/-/axe-core-4.10.3.tgz", {}, "sha512-Xm7bpRXnDSX2YE2YFfBk2FnF0ep6tmG7xPh8iHee8MIcrgq762Nkce856dYtJYLkuIoYZvGfTs/PbZhideTcEg=="], + + "axios": ["axios@1.15.0", "https://registry.npmmirror.com/axios/-/axios-1.15.0.tgz", { "dependencies": { "follow-redirects": "^1.15.11", "form-data": "^4.0.5", "proxy-from-env": "^2.1.0" } }, "sha512-wWyJDlAatxk30ZJer+GeCWS209sA42X+N5jU2jy6oHTp7ufw8uzUTVFBX9+wTfAlhiJXGS0Bq7X6efruWjuK9Q=="], + + "axobject-query": ["axobject-query@4.1.0", "https://registry.npmmirror.com/axobject-query/-/axobject-query-4.1.0.tgz", {}, "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ=="], + + "balanced-match": ["balanced-match@1.0.2", "https://registry.npmmirror.com/balanced-match/-/balanced-match-1.0.2.tgz", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], + + "baseline-browser-mapping": ["baseline-browser-mapping@2.10.18", "", { "bin": "dist/cli.cjs" }, "sha512-VSnGQAOLtP5mib/DPyg2/t+Tlv65NTBz83BJBJvmLVHHuKJVaDOBvJJykiT5TR++em5nfAySPccDZDa4oSrn8A=="], + + "brace-expansion": ["brace-expansion@1.1.12", "https://registry.npmmirror.com/brace-expansion/-/brace-expansion-1.1.12.tgz", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg=="], + + "braces": ["braces@3.0.3", "https://registry.npmmirror.com/braces/-/braces-3.0.3.tgz", { "dependencies": { "fill-range": "^7.1.1" } }, "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA=="], + + "browserslist": ["browserslist@4.28.2", "", { "dependencies": { "baseline-browser-mapping": "^2.10.12", "caniuse-lite": "^1.0.30001782", "electron-to-chromium": "^1.5.328", "node-releases": "^2.0.36", "update-browserslist-db": "^1.2.3" }, "bin": "cli.js" }, "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg=="], + + "call-bind": ["call-bind@1.0.8", "https://registry.npmmirror.com/call-bind/-/call-bind-1.0.8.tgz", { "dependencies": { "call-bind-apply-helpers": "^1.0.0", "es-define-property": "^1.0.0", "get-intrinsic": "^1.2.4", "set-function-length": "^1.2.2" } }, "sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww=="], + + "call-bind-apply-helpers": ["call-bind-apply-helpers@1.0.2", "https://registry.npmmirror.com/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", { "dependencies": { "es-errors": "^1.3.0", "function-bind": "^1.1.2" } }, "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ=="], + + "call-bound": ["call-bound@1.0.4", "https://registry.npmmirror.com/call-bound/-/call-bound-1.0.4.tgz", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "get-intrinsic": "^1.3.0" } }, "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg=="], + + "call-me-maybe": ["call-me-maybe@1.0.2", "https://registry.npmmirror.com/call-me-maybe/-/call-me-maybe-1.0.2.tgz", {}, "sha512-HpX65o1Hnr9HH25ojC1YGs7HCQLq0GCOibSaWER0eNpgJ/Z1MZv2mTc7+xh6WOPxbRVcmgbv4hGU+uSQ/2xFZQ=="], + + "callsites": ["callsites@3.1.0", "https://registry.npmmirror.com/callsites/-/callsites-3.1.0.tgz", {}, "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ=="], + + "caniuse-lite": ["caniuse-lite@1.0.30001788", "", {}, "sha512-6q8HFp+lOQtcf7wBK+uEenxymVWkGKkjFpCvw5W25cmMwEDU45p1xQFBQv8JDlMMry7eNxyBaR+qxgmTUZkIRQ=="], + + "chalk": ["chalk@4.1.2", "https://registry.npmmirror.com/chalk/-/chalk-4.1.2.tgz", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], + + "chownr": ["chownr@3.0.0", "https://registry.npmmirror.com/chownr/-/chownr-3.0.0.tgz", {}, "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g=="], + + "class-variance-authority": ["class-variance-authority@0.7.1", "https://registry.npmmirror.com/class-variance-authority/-/class-variance-authority-0.7.1.tgz", { "dependencies": { "clsx": "^2.1.1" } }, "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg=="], + + "client-only": ["client-only@0.0.1", "https://registry.npmmirror.com/client-only/-/client-only-0.0.1.tgz", {}, "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA=="], + + "cliui": ["cliui@8.0.1", "https://registry.npmmirror.com/cliui/-/cliui-8.0.1.tgz", { "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.1", "wrap-ansi": "^7.0.0" } }, "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ=="], + + "clsx": ["clsx@2.1.1", "https://registry.npmmirror.com/clsx/-/clsx-2.1.1.tgz", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="], + + "color-convert": ["color-convert@2.0.1", "https://registry.npmmirror.com/color-convert/-/color-convert-2.0.1.tgz", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="], + + "color-name": ["color-name@1.1.4", "https://registry.npmmirror.com/color-name/-/color-name-1.1.4.tgz", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="], + + "combined-stream": ["combined-stream@1.0.8", "https://registry.npmmirror.com/combined-stream/-/combined-stream-1.0.8.tgz", { "dependencies": { "delayed-stream": "~1.0.0" } }, "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg=="], + + "commander": ["commander@14.0.0", "https://registry.npmmirror.com/commander/-/commander-14.0.0.tgz", {}, "sha512-2uM9rYjPvyq39NwLRqaiLtWHyDC1FvryJDa2ATTVims5YAS4PupsEQsDvP14FqhFr0P49CYDugi59xaxJlTXRA=="], + + "concat-map": ["concat-map@0.0.1", "https://registry.npmmirror.com/concat-map/-/concat-map-0.0.1.tgz", {}, "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg=="], + + "convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="], + + "cosmiconfig": ["cosmiconfig@9.0.0", "https://registry.npmmirror.com/cosmiconfig/-/cosmiconfig-9.0.0.tgz", { "dependencies": { "env-paths": "^2.2.1", "import-fresh": "^3.3.0", "js-yaml": "^4.1.0", "parse-json": "^5.2.0" }, "peerDependencies": { "typescript": ">=4.9.5" } }, "sha512-itvL5h8RETACmOTFc4UfIyB2RfEHi71Ax6E/PivVxq9NseKbOWpeyHEOIbmAw1rs8Ak0VursQNww7lf7YtUwzg=="], + + "cross-spawn": ["cross-spawn@7.0.6", "https://registry.npmmirror.com/cross-spawn/-/cross-spawn-7.0.6.tgz", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="], + + "csstype": ["csstype@3.1.3", "https://registry.npmmirror.com/csstype/-/csstype-3.1.3.tgz", {}, "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw=="], + + "d": ["d@1.0.2", "https://registry.npmmirror.com/d/-/d-1.0.2.tgz", { "dependencies": { "es5-ext": "^0.10.64", "type": "^2.7.2" } }, "sha512-MOqHvMWF9/9MX6nza0KgvFH4HpMU0EF5uUDXqX/BtxtU8NfB0QzRtJ8Oe/6SuS4kbhyzVJwjd97EA4PKrzJ8bw=="], + + "damerau-levenshtein": ["damerau-levenshtein@1.0.8", "https://registry.npmmirror.com/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz", {}, "sha512-sdQSFB7+llfUcQHUQO3+B8ERRj0Oa4w9POWMI/puGtuf7gFywGmkaLCElnudfTiKZV+NvHqL0ifzdrI8Ro7ESA=="], + + "data-view-buffer": ["data-view-buffer@1.0.2", "https://registry.npmmirror.com/data-view-buffer/-/data-view-buffer-1.0.2.tgz", { "dependencies": { "call-bound": "^1.0.3", "es-errors": "^1.3.0", "is-data-view": "^1.0.2" } }, "sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ=="], + + "data-view-byte-length": ["data-view-byte-length@1.0.2", "https://registry.npmmirror.com/data-view-byte-length/-/data-view-byte-length-1.0.2.tgz", { "dependencies": { "call-bound": "^1.0.3", "es-errors": "^1.3.0", "is-data-view": "^1.0.2" } }, "sha512-tuhGbE6CfTM9+5ANGf+oQb72Ky/0+s3xKUpHvShfiz2RxMFgFPjsXuRLBVMtvMs15awe45SRb83D6wH4ew6wlQ=="], + + "data-view-byte-offset": ["data-view-byte-offset@1.0.1", "https://registry.npmmirror.com/data-view-byte-offset/-/data-view-byte-offset-1.0.1.tgz", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "is-data-view": "^1.0.1" } }, "sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ=="], + + "date-fns": ["date-fns@4.1.0", "https://registry.npmmirror.com/date-fns/-/date-fns-4.1.0.tgz", {}, "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg=="], + + "date-fns-jalali": ["date-fns-jalali@4.1.0-0", "https://registry.npmmirror.com/date-fns-jalali/-/date-fns-jalali-4.1.0-0.tgz", {}, "sha512-hTIP/z+t+qKwBDcmmsnmjWTduxCg+5KfdqWQvb2X/8C9+knYY6epN/pfxdDuyVlSVeFz0sM5eEfwIUQ70U4ckg=="], + + "dayjs": ["dayjs@1.11.13", "https://registry.npmmirror.com/dayjs/-/dayjs-1.11.13.tgz", {}, "sha512-oaMBel6gjolK862uaPQOVTA7q3TZhuSvuMQAAglQDOWYO9A91IrAOUJEyKVlqJlHE0vq5p5UXxzdPfMH/x6xNg=="], + + "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], + + "deep-is": ["deep-is@0.1.4", "https://registry.npmmirror.com/deep-is/-/deep-is-0.1.4.tgz", {}, "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ=="], + + "define-data-property": ["define-data-property@1.1.4", "https://registry.npmmirror.com/define-data-property/-/define-data-property-1.1.4.tgz", { "dependencies": { "es-define-property": "^1.0.0", "es-errors": "^1.3.0", "gopd": "^1.0.1" } }, "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A=="], + + "define-properties": ["define-properties@1.2.1", "https://registry.npmmirror.com/define-properties/-/define-properties-1.2.1.tgz", { "dependencies": { "define-data-property": "^1.0.1", "has-property-descriptors": "^1.0.0", "object-keys": "^1.1.1" } }, "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg=="], + + "delayed-stream": ["delayed-stream@1.0.0", "https://registry.npmmirror.com/delayed-stream/-/delayed-stream-1.0.0.tgz", {}, "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ=="], + + "detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], + + "detect-node-es": ["detect-node-es@1.1.0", "https://registry.npmmirror.com/detect-node-es/-/detect-node-es-1.1.0.tgz", {}, "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ=="], + + "doctrine": ["doctrine@2.1.0", "https://registry.npmmirror.com/doctrine/-/doctrine-2.1.0.tgz", { "dependencies": { "esutils": "^2.0.2" } }, "sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw=="], + + "dunder-proto": ["dunder-proto@1.0.1", "https://registry.npmmirror.com/dunder-proto/-/dunder-proto-1.0.1.tgz", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="], + + "electron-to-chromium": ["electron-to-chromium@1.5.336", "", {}, "sha512-AbH9q9J455r/nLmdNZes0G0ZKcRX73FicwowalLs6ijwOmCJSRRrLX63lcAlzy9ux3dWK1w1+1nsBJEWN11hcQ=="], + + "emoji-regex": ["emoji-regex@9.2.2", "https://registry.npmmirror.com/emoji-regex/-/emoji-regex-9.2.2.tgz", {}, "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg=="], + + "enhanced-resolve": ["enhanced-resolve@5.18.2", "https://registry.npmmirror.com/enhanced-resolve/-/enhanced-resolve-5.18.2.tgz", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.2.0" } }, "sha512-6Jw4sE1maoRJo3q8MsSIn2onJFbLTOjY9hlx4DZXmOKvLRd1Ok2kXmAGXaafL2+ijsJZ1ClYbl/pmqr9+k4iUQ=="], + + "env-paths": ["env-paths@2.2.1", "https://registry.npmmirror.com/env-paths/-/env-paths-2.2.1.tgz", {}, "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A=="], + + "error-ex": ["error-ex@1.3.2", "https://registry.npmmirror.com/error-ex/-/error-ex-1.3.2.tgz", { "dependencies": { "is-arrayish": "^0.2.1" } }, "sha512-7dFHNmqeFSEt2ZBsCriorKnn3Z2pj+fd9kmI6QoWw4//DL+icEBfc0U7qJCisqrTsKTjw4fNFy2pW9OqStD84g=="], + + "es-abstract": ["es-abstract@1.24.0", "https://registry.npmmirror.com/es-abstract/-/es-abstract-1.24.0.tgz", { "dependencies": { "array-buffer-byte-length": "^1.0.2", "arraybuffer.prototype.slice": "^1.0.4", "available-typed-arrays": "^1.0.7", "call-bind": "^1.0.8", "call-bound": "^1.0.4", "data-view-buffer": "^1.0.2", "data-view-byte-length": "^1.0.2", "data-view-byte-offset": "^1.0.1", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "es-set-tostringtag": "^2.1.0", "es-to-primitive": "^1.3.0", "function.prototype.name": "^1.1.8", "get-intrinsic": "^1.3.0", "get-proto": "^1.0.1", "get-symbol-description": "^1.1.0", "globalthis": "^1.0.4", "gopd": "^1.2.0", "has-property-descriptors": "^1.0.2", "has-proto": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "internal-slot": "^1.1.0", "is-array-buffer": "^3.0.5", "is-callable": "^1.2.7", "is-data-view": "^1.0.2", "is-negative-zero": "^2.0.3", "is-regex": "^1.2.1", "is-set": "^2.0.3", "is-shared-array-buffer": "^1.0.4", "is-string": "^1.1.1", "is-typed-array": "^1.1.15", "is-weakref": "^1.1.1", "math-intrinsics": "^1.1.0", "object-inspect": "^1.13.4", "object-keys": "^1.1.1", "object.assign": "^4.1.7", "own-keys": "^1.0.1", "regexp.prototype.flags": "^1.5.4", "safe-array-concat": "^1.1.3", "safe-push-apply": "^1.0.0", "safe-regex-test": "^1.1.0", "set-proto": "^1.0.0", "stop-iteration-iterator": "^1.1.0", "string.prototype.trim": "^1.2.10", "string.prototype.trimend": "^1.0.9", "string.prototype.trimstart": "^1.0.8", "typed-array-buffer": "^1.0.3", "typed-array-byte-length": "^1.0.3", "typed-array-byte-offset": "^1.0.4", "typed-array-length": "^1.0.7", "unbox-primitive": "^1.1.0", "which-typed-array": "^1.1.19" } }, "sha512-WSzPgsdLtTcQwm4CROfS5ju2Wa1QQcVeT37jFjYzdFz1r9ahadC8B8/a4qxJxM+09F18iumCdRmlr96ZYkQvEg=="], + + "es-define-property": ["es-define-property@1.0.1", "https://registry.npmmirror.com/es-define-property/-/es-define-property-1.0.1.tgz", {}, "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g=="], + + "es-errors": ["es-errors@1.3.0", "https://registry.npmmirror.com/es-errors/-/es-errors-1.3.0.tgz", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="], + + "es-iterator-helpers": ["es-iterator-helpers@1.2.1", "https://registry.npmmirror.com/es-iterator-helpers/-/es-iterator-helpers-1.2.1.tgz", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.3", "define-properties": "^1.2.1", "es-abstract": "^1.23.6", "es-errors": "^1.3.0", "es-set-tostringtag": "^2.0.3", "function-bind": "^1.1.2", "get-intrinsic": "^1.2.6", "globalthis": "^1.0.4", "gopd": "^1.2.0", "has-property-descriptors": "^1.0.2", "has-proto": "^1.2.0", "has-symbols": "^1.1.0", "internal-slot": "^1.1.0", "iterator.prototype": "^1.1.4", "safe-array-concat": "^1.1.3" } }, "sha512-uDn+FE1yrDzyC0pCo961B2IHbdM8y/ACZsKD4dG6WqrjV53BADjwa7D+1aom2rsNVfLyDgU/eigvlJGJ08OQ4w=="], + + "es-object-atoms": ["es-object-atoms@1.1.1", "https://registry.npmmirror.com/es-object-atoms/-/es-object-atoms-1.1.1.tgz", { "dependencies": { "es-errors": "^1.3.0" } }, "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA=="], + + "es-set-tostringtag": ["es-set-tostringtag@2.1.0", "https://registry.npmmirror.com/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", { "dependencies": { "es-errors": "^1.3.0", "get-intrinsic": "^1.2.6", "has-tostringtag": "^1.0.2", "hasown": "^2.0.2" } }, "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA=="], + + "es-shim-unscopables": ["es-shim-unscopables@1.1.0", "https://registry.npmmirror.com/es-shim-unscopables/-/es-shim-unscopables-1.1.0.tgz", { "dependencies": { "hasown": "^2.0.2" } }, "sha512-d9T8ucsEhh8Bi1woXCf+TIKDIROLG5WCkxg8geBCbvk22kzwC5G2OnXVMO6FUsvQlgUUXQ2itephWDLqDzbeCw=="], + + "es-to-primitive": ["es-to-primitive@1.3.0", "https://registry.npmmirror.com/es-to-primitive/-/es-to-primitive-1.3.0.tgz", { "dependencies": { "is-callable": "^1.2.7", "is-date-object": "^1.0.5", "is-symbol": "^1.0.4" } }, "sha512-w+5mJ3GuFL+NjVtJlvydShqE1eN3h3PbI7/5LAsYJP/2qtuMXjfL2LpHSRqo4b4eSF5K/DH1JXKUAHSB2UW50g=="], + + "es5-ext": ["es5-ext@0.10.64", "https://registry.npmmirror.com/es5-ext/-/es5-ext-0.10.64.tgz", { "dependencies": { "es6-iterator": "^2.0.3", "es6-symbol": "^3.1.3", "esniff": "^2.0.1", "next-tick": "^1.1.0" } }, "sha512-p2snDhiLaXe6dahss1LddxqEm+SkuDvV8dnIQG0MWjyHpcMNfXKPE+/Cc0y+PhxJX3A4xGNeFCj5oc0BUh6deg=="], + + "es6-iterator": ["es6-iterator@2.0.3", "https://registry.npmmirror.com/es6-iterator/-/es6-iterator-2.0.3.tgz", { "dependencies": { "d": "1", "es5-ext": "^0.10.35", "es6-symbol": "^3.1.1" } }, "sha512-zw4SRzoUkd+cl+ZoE15A9o1oQd920Bb0iOJMQkQhl3jNc03YqVjAhG7scf9C5KWRU/R13Orf588uCC6525o02g=="], + + "es6-promise": ["es6-promise@3.3.1", "https://registry.npmmirror.com/es6-promise/-/es6-promise-3.3.1.tgz", {}, "sha512-SOp9Phqvqn7jtEUxPWdWfWoLmyt2VaJ6MpvP9Comy1MceMXqE6bxvaTu4iaxpYYPzhny28Lc+M87/c2cPK6lDg=="], + + "es6-symbol": ["es6-symbol@3.1.4", "https://registry.npmmirror.com/es6-symbol/-/es6-symbol-3.1.4.tgz", { "dependencies": { "d": "^1.0.2", "ext": "^1.7.0" } }, "sha512-U9bFFjX8tFiATgtkJ1zg25+KviIXpgRvRHS8sau3GfhVzThRQrOeksPeT0BWW2MNZs1OEWJ1DPXOQMn0KKRkvg=="], + + "es6-weak-map": ["es6-weak-map@2.0.3", "https://registry.npmmirror.com/es6-weak-map/-/es6-weak-map-2.0.3.tgz", { "dependencies": { "d": "1", "es5-ext": "^0.10.46", "es6-iterator": "^2.0.3", "es6-symbol": "^3.1.1" } }, "sha512-p5um32HOTO1kP+w7PRnB+5lQ43Z6muuMuIMffvDN8ZB4GcnjLBV6zGStpbASIMk4DCAvEaamhe2zhyCb/QXXsA=="], + + "escalade": ["escalade@3.2.0", "https://registry.npmmirror.com/escalade/-/escalade-3.2.0.tgz", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="], + + "escape-string-regexp": ["escape-string-regexp@4.0.0", "https://registry.npmmirror.com/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", {}, "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA=="], + + "eslint": ["eslint@9.29.0", "https://registry.npmmirror.com/eslint/-/eslint-9.29.0.tgz", { "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.12.1", "@eslint/config-array": "^0.20.1", "@eslint/config-helpers": "^0.2.1", "@eslint/core": "^0.14.0", "@eslint/eslintrc": "^3.3.1", "@eslint/js": "9.29.0", "@eslint/plugin-kit": "^0.3.1", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.2", "@types/estree": "^1.0.6", "@types/json-schema": "^7.0.15", "ajv": "^6.12.4", "chalk": "^4.0.0", "cross-spawn": "^7.0.6", "debug": "^4.3.2", "escape-string-regexp": "^4.0.0", "eslint-scope": "^8.4.0", "eslint-visitor-keys": "^4.2.1", "espree": "^10.4.0", "esquery": "^1.5.0", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", "file-entry-cache": "^8.0.0", "find-up": "^5.0.0", "glob-parent": "^6.0.2", "ignore": "^5.2.0", "imurmurhash": "^0.1.4", "is-glob": "^4.0.0", "json-stable-stringify-without-jsonify": "^1.0.1", "lodash.merge": "^4.6.2", "minimatch": "^3.1.2", "natural-compare": "^1.4.0", "optionator": "^0.9.3" }, "peerDependencies": { "jiti": "*" }, "bin": "bin/eslint.js" }, "sha512-GsGizj2Y1rCWDu6XoEekL3RLilp0voSePurjZIkxL3wlm5o5EC9VpgaP7lrCvjnkuLvzFBQWB3vWB3K5KQTveQ=="], + + "eslint-config-next": ["eslint-config-next@16.2.3", "", { "dependencies": { "@next/eslint-plugin-next": "16.2.3", "eslint-import-resolver-node": "^0.3.6", "eslint-import-resolver-typescript": "^3.5.2", "eslint-plugin-import": "^2.32.0", "eslint-plugin-jsx-a11y": "^6.10.0", "eslint-plugin-react": "^7.37.0", "eslint-plugin-react-hooks": "^7.0.0", "globals": "16.4.0", "typescript-eslint": "^8.46.0" }, "peerDependencies": { "eslint": ">=9.0.0", "typescript": ">=3.3.1" } }, "sha512-Dnkrylzjof/Az7iNoIQJqD18zTxQZcngir19KJaiRsMnnjpQSVoa6aEg/1Q4hQC+cW90uTlgQYadwL1CYNwFWA=="], + + "eslint-config-prettier": ["eslint-config-prettier@10.1.8", "https://registry.npmmirror.com/eslint-config-prettier/-/eslint-config-prettier-10.1.8.tgz", { "peerDependencies": { "eslint": ">=7.0.0" }, "bin": "bin/cli.js" }, "sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w=="], + + "eslint-import-resolver-node": ["eslint-import-resolver-node@0.3.9", "https://registry.npmmirror.com/eslint-import-resolver-node/-/eslint-import-resolver-node-0.3.9.tgz", { "dependencies": { "debug": "^3.2.7", "is-core-module": "^2.13.0", "resolve": "^1.22.4" } }, "sha512-WFj2isz22JahUv+B788TlO3N6zL3nNJGU8CcZbPZvVEkBPaJdCV4vy5wyghty5ROFbCRnm132v8BScu5/1BQ8g=="], + + "eslint-import-resolver-typescript": ["eslint-import-resolver-typescript@3.10.1", "https://registry.npmmirror.com/eslint-import-resolver-typescript/-/eslint-import-resolver-typescript-3.10.1.tgz", { "dependencies": { "@nolyfill/is-core-module": "1.0.39", "debug": "^4.4.0", "get-tsconfig": "^4.10.0", "is-bun-module": "^2.0.0", "stable-hash": "^0.0.5", "tinyglobby": "^0.2.13", "unrs-resolver": "^1.6.2" }, "peerDependencies": { "eslint": "*", "eslint-plugin-import": "*", "eslint-plugin-import-x": "*" }, "optionalPeers": ["eslint-plugin-import-x"] }, "sha512-A1rHYb06zjMGAxdLSkN2fXPBwuSaQ0iO5M/hdyS0Ajj1VBaRp0sPD3dn1FhME3c/JluGFbwSxyCfqdSbtQLAHQ=="], + + "eslint-module-utils": ["eslint-module-utils@2.12.1", "https://registry.npmmirror.com/eslint-module-utils/-/eslint-module-utils-2.12.1.tgz", { "dependencies": { "debug": "^3.2.7" } }, "sha512-L8jSWTze7K2mTg0vos/RuLRS5soomksDPoJLXIslC7c8Wmut3bx7CPpJijDcBZtxQ5lrbUdM+s0OlNbz0DCDNw=="], + + "eslint-plugin-import": ["eslint-plugin-import@2.32.0", "https://registry.npmmirror.com/eslint-plugin-import/-/eslint-plugin-import-2.32.0.tgz", { "dependencies": { "@rtsao/scc": "^1.1.0", "array-includes": "^3.1.9", "array.prototype.findlastindex": "^1.2.6", "array.prototype.flat": "^1.3.3", "array.prototype.flatmap": "^1.3.3", "debug": "^3.2.7", "doctrine": "^2.1.0", "eslint-import-resolver-node": "^0.3.9", "eslint-module-utils": "^2.12.1", "hasown": "^2.0.2", "is-core-module": "^2.16.1", "is-glob": "^4.0.3", "minimatch": "^3.1.2", "object.fromentries": "^2.0.8", "object.groupby": "^1.0.3", "object.values": "^1.2.1", "semver": "^6.3.1", "string.prototype.trimend": "^1.0.9", "tsconfig-paths": "^3.15.0" }, "peerDependencies": { "eslint": "^2 || ^3 || ^4 || ^5 || ^6 || ^7.2.0 || ^8 || ^9" } }, "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA=="], + + "eslint-plugin-jsx-a11y": ["eslint-plugin-jsx-a11y@6.10.2", "https://registry.npmmirror.com/eslint-plugin-jsx-a11y/-/eslint-plugin-jsx-a11y-6.10.2.tgz", { "dependencies": { "aria-query": "^5.3.2", "array-includes": "^3.1.8", "array.prototype.flatmap": "^1.3.2", "ast-types-flow": "^0.0.8", "axe-core": "^4.10.0", "axobject-query": "^4.1.0", "damerau-levenshtein": "^1.0.8", "emoji-regex": "^9.2.2", "hasown": "^2.0.2", "jsx-ast-utils": "^3.3.5", "language-tags": "^1.0.9", "minimatch": "^3.1.2", "object.fromentries": "^2.0.8", "safe-regex-test": "^1.0.3", "string.prototype.includes": "^2.0.1" }, "peerDependencies": { "eslint": "^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9" } }, "sha512-scB3nz4WmG75pV8+3eRUQOHZlNSUhFNq37xnpgRkCCELU3XMvXAxLk1eqWWyE22Ki4Q01Fnsw9BA3cJHDPgn2Q=="], + + "eslint-plugin-react": ["eslint-plugin-react@7.37.5", "https://registry.npmmirror.com/eslint-plugin-react/-/eslint-plugin-react-7.37.5.tgz", { "dependencies": { "array-includes": "^3.1.8", "array.prototype.findlast": "^1.2.5", "array.prototype.flatmap": "^1.3.3", "array.prototype.tosorted": "^1.1.4", "doctrine": "^2.1.0", "es-iterator-helpers": "^1.2.1", "estraverse": "^5.3.0", "hasown": "^2.0.2", "jsx-ast-utils": "^2.4.1 || ^3.0.0", "minimatch": "^3.1.2", "object.entries": "^1.1.9", "object.fromentries": "^2.0.8", "object.values": "^1.2.1", "prop-types": "^15.8.1", "resolve": "^2.0.0-next.5", "semver": "^6.3.1", "string.prototype.matchall": "^4.0.12", "string.prototype.repeat": "^1.0.0" }, "peerDependencies": { "eslint": "^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9.7" } }, "sha512-Qteup0SqU15kdocexFNAJMvCJEfa2xUKNV4CC1xsVMrIIqEy3SQ/rqyxCWNzfrd3/ldy6HMlD2e0JDVpDg2qIA=="], + + "eslint-plugin-react-hooks": ["eslint-plugin-react-hooks@7.0.1", "", { "dependencies": { "@babel/core": "^7.24.4", "@babel/parser": "^7.24.4", "hermes-parser": "^0.25.1", "zod": "^3.25.0 || ^4.0.0", "zod-validation-error": "^3.5.0 || ^4.0.0" }, "peerDependencies": { "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0" } }, "sha512-O0d0m04evaNzEPoSW+59Mezf8Qt0InfgGIBJnpC0h3NH/WjUAR7BIKUfysC6todmtiZ/A0oUVS8Gce0WhBrHsA=="], + + "eslint-scope": ["eslint-scope@8.4.0", "https://registry.npmmirror.com/eslint-scope/-/eslint-scope-8.4.0.tgz", { "dependencies": { "esrecurse": "^4.3.0", "estraverse": "^5.2.0" } }, "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg=="], + + "eslint-visitor-keys": ["eslint-visitor-keys@4.2.1", "https://registry.npmmirror.com/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", {}, "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ=="], + + "esniff": ["esniff@2.0.1", "https://registry.npmmirror.com/esniff/-/esniff-2.0.1.tgz", { "dependencies": { "d": "^1.0.1", "es5-ext": "^0.10.62", "event-emitter": "^0.3.5", "type": "^2.7.2" } }, "sha512-kTUIGKQ/mDPFoJ0oVfcmyJn4iBDRptjNVIzwIFR7tqWXdVI9xfA2RMwY/gbSpJG3lkdWNEjLap/NqVHZiJsdfg=="], + + "espree": ["espree@10.4.0", "https://registry.npmmirror.com/espree/-/espree-10.4.0.tgz", { "dependencies": { "acorn": "^8.15.0", "acorn-jsx": "^5.3.2", "eslint-visitor-keys": "^4.2.1" } }, "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ=="], + + "esquery": ["esquery@1.6.0", "https://registry.npmmirror.com/esquery/-/esquery-1.6.0.tgz", { "dependencies": { "estraverse": "^5.1.0" } }, "sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg=="], + + "esrecurse": ["esrecurse@4.3.0", "https://registry.npmmirror.com/esrecurse/-/esrecurse-4.3.0.tgz", { "dependencies": { "estraverse": "^5.2.0" } }, "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag=="], + + "estraverse": ["estraverse@5.3.0", "https://registry.npmmirror.com/estraverse/-/estraverse-5.3.0.tgz", {}, "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA=="], + + "esutils": ["esutils@2.0.3", "https://registry.npmmirror.com/esutils/-/esutils-2.0.3.tgz", {}, "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g=="], + + "event-emitter": ["event-emitter@0.3.5", "https://registry.npmmirror.com/event-emitter/-/event-emitter-0.3.5.tgz", { "dependencies": { "d": "1", "es5-ext": "~0.10.14" } }, "sha512-D9rRn9y7kLPnJ+hMq7S/nhvoKwwvVJahBi2BPmx3bvbsEdK3W9ii8cBSGjP+72/LnM4n6fo3+dkCX5FeTQruXA=="], + + "ext": ["ext@1.7.0", "https://registry.npmmirror.com/ext/-/ext-1.7.0.tgz", { "dependencies": { "type": "^2.7.2" } }, "sha512-6hxeJYaL110a9b5TEJSj0gojyHQAmA2ch5Os+ySCiA1QGdS697XWY1pzsrSjqA9LDEEgdB/KypIlR59RcLuHYw=="], + + "fast-deep-equal": ["fast-deep-equal@3.1.3", "https://registry.npmmirror.com/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="], + + "fast-glob": ["fast-glob@3.3.1", "https://registry.npmmirror.com/fast-glob/-/fast-glob-3.3.1.tgz", { "dependencies": { "@nodelib/fs.stat": "^2.0.2", "@nodelib/fs.walk": "^1.2.3", "glob-parent": "^5.1.2", "merge2": "^1.3.0", "micromatch": "^4.0.4" } }, "sha512-kNFPyjhh5cKjrUltxs+wFx+ZkbRaxxmZ+X0ZU31SOsxCEtP9VPgtq2teZw1DebupL5GmDaNQ6yKMMVcM41iqDg=="], + + "fast-json-stable-stringify": ["fast-json-stable-stringify@2.1.0", "https://registry.npmmirror.com/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", {}, "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw=="], + + "fast-levenshtein": ["fast-levenshtein@2.0.6", "https://registry.npmmirror.com/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", {}, "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw=="], + + "fast-safe-stringify": ["fast-safe-stringify@2.1.1", "https://registry.npmmirror.com/fast-safe-stringify/-/fast-safe-stringify-2.1.1.tgz", {}, "sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA=="], + + "fastq": ["fastq@1.19.1", "https://registry.npmmirror.com/fastq/-/fastq-1.19.1.tgz", { "dependencies": { "reusify": "^1.0.4" } }, "sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ=="], + + "fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" } }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="], + + "file-entry-cache": ["file-entry-cache@8.0.0", "https://registry.npmmirror.com/file-entry-cache/-/file-entry-cache-8.0.0.tgz", { "dependencies": { "flat-cache": "^4.0.0" } }, "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ=="], + + "fill-range": ["fill-range@7.1.1", "https://registry.npmmirror.com/fill-range/-/fill-range-7.1.1.tgz", { "dependencies": { "to-regex-range": "^5.0.1" } }, "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg=="], + + "find-up": ["find-up@5.0.0", "https://registry.npmmirror.com/find-up/-/find-up-5.0.0.tgz", { "dependencies": { "locate-path": "^6.0.0", "path-exists": "^4.0.0" } }, "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng=="], + + "flat-cache": ["flat-cache@4.0.1", "https://registry.npmmirror.com/flat-cache/-/flat-cache-4.0.1.tgz", { "dependencies": { "flatted": "^3.2.9", "keyv": "^4.5.4" } }, "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw=="], + + "flatted": ["flatted@3.3.3", "https://registry.npmmirror.com/flatted/-/flatted-3.3.3.tgz", {}, "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg=="], + + "follow-redirects": ["follow-redirects@1.16.0", "https://registry.npmmirror.com/follow-redirects/-/follow-redirects-1.16.0.tgz", {}, "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw=="], + + "for-each": ["for-each@0.3.5", "https://registry.npmmirror.com/for-each/-/for-each-0.3.5.tgz", { "dependencies": { "is-callable": "^1.2.7" } }, "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg=="], + + "form-data": ["form-data@4.0.5", "https://registry.npmmirror.com/form-data/-/form-data-4.0.5.tgz", { "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", "es-set-tostringtag": "^2.1.0", "hasown": "^2.0.2", "mime-types": "^2.1.12" } }, "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w=="], + + "framer-motion": ["framer-motion@12.38.0", "https://registry.npmmirror.com/framer-motion/-/framer-motion-12.38.0.tgz", { "dependencies": { "motion-dom": "^12.38.0", "motion-utils": "^12.36.0", "tslib": "^2.4.0" }, "peerDependencies": { "@emotion/is-prop-valid": "*", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@emotion/is-prop-valid", "react", "react-dom"] }, "sha512-rFYkY/pigbcswl1XQSb7q424kSTQ8q6eAC+YUsSKooHQYuLdzdHjrt6uxUC+PRAO++q5IS7+TamgIw1AphxR+g=="], + + "fs.realpath": ["fs.realpath@1.0.0", "https://registry.npmmirror.com/fs.realpath/-/fs.realpath-1.0.0.tgz", {}, "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw=="], + + "function-bind": ["function-bind@1.1.2", "https://registry.npmmirror.com/function-bind/-/function-bind-1.1.2.tgz", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="], + + "function.prototype.name": ["function.prototype.name@1.1.8", "https://registry.npmmirror.com/function.prototype.name/-/function.prototype.name-1.1.8.tgz", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.3", "define-properties": "^1.2.1", "functions-have-names": "^1.2.3", "hasown": "^2.0.2", "is-callable": "^1.2.7" } }, "sha512-e5iwyodOHhbMr/yNrc7fDYG4qlbIvI5gajyzPnb5TCwyhjApznQh1BMFou9b30SevY43gCJKXycoCBjMbsuW0Q=="], + + "functions-have-names": ["functions-have-names@1.2.3", "https://registry.npmmirror.com/functions-have-names/-/functions-have-names-1.2.3.tgz", {}, "sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ=="], + + "gensync": ["gensync@1.0.0-beta.2", "", {}, "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg=="], + + "get-caller-file": ["get-caller-file@2.0.5", "https://registry.npmmirror.com/get-caller-file/-/get-caller-file-2.0.5.tgz", {}, "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg=="], + + "get-intrinsic": ["get-intrinsic@1.3.0", "https://registry.npmmirror.com/get-intrinsic/-/get-intrinsic-1.3.0.tgz", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "function-bind": "^1.1.2", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "math-intrinsics": "^1.1.0" } }, "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ=="], + + "get-nonce": ["get-nonce@1.0.1", "https://registry.npmmirror.com/get-nonce/-/get-nonce-1.0.1.tgz", {}, "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q=="], + + "get-proto": ["get-proto@1.0.1", "https://registry.npmmirror.com/get-proto/-/get-proto-1.0.1.tgz", { "dependencies": { "dunder-proto": "^1.0.1", "es-object-atoms": "^1.0.0" } }, "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g=="], + + "get-symbol-description": ["get-symbol-description@1.1.0", "https://registry.npmmirror.com/get-symbol-description/-/get-symbol-description-1.1.0.tgz", { "dependencies": { "call-bound": "^1.0.3", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.6" } }, "sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg=="], + + "get-tsconfig": ["get-tsconfig@4.10.1", "https://registry.npmmirror.com/get-tsconfig/-/get-tsconfig-4.10.1.tgz", { "dependencies": { "resolve-pkg-maps": "^1.0.0" } }, "sha512-auHyJ4AgMz7vgS8Hp3N6HXSmlMdUyhSUrfBF16w153rxtLIEOE+HGqaBppczZvnHLqQJfiHotCYpNhl0lUROFQ=="], + + "glob": ["glob@7.2.3", "https://registry.npmmirror.com/glob/-/glob-7.2.3.tgz", { "dependencies": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", "inherits": "2", "minimatch": "^3.1.1", "once": "^1.3.0", "path-is-absolute": "^1.0.0" } }, "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q=="], + + "glob-parent": ["glob-parent@6.0.2", "https://registry.npmmirror.com/glob-parent/-/glob-parent-6.0.2.tgz", { "dependencies": { "is-glob": "^4.0.3" } }, "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A=="], + + "globals": ["globals@14.0.0", "https://registry.npmmirror.com/globals/-/globals-14.0.0.tgz", {}, "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ=="], + + "globalthis": ["globalthis@1.0.4", "https://registry.npmmirror.com/globalthis/-/globalthis-1.0.4.tgz", { "dependencies": { "define-properties": "^1.2.1", "gopd": "^1.0.1" } }, "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ=="], + + "gopd": ["gopd@1.2.0", "https://registry.npmmirror.com/gopd/-/gopd-1.2.0.tgz", {}, "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg=="], + + "graceful-fs": ["graceful-fs@4.2.11", "https://registry.npmmirror.com/graceful-fs/-/graceful-fs-4.2.11.tgz", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="], + + "has-bigints": ["has-bigints@1.1.0", "https://registry.npmmirror.com/has-bigints/-/has-bigints-1.1.0.tgz", {}, "sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg=="], + + "has-flag": ["has-flag@4.0.0", "https://registry.npmmirror.com/has-flag/-/has-flag-4.0.0.tgz", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="], + + "has-property-descriptors": ["has-property-descriptors@1.0.2", "https://registry.npmmirror.com/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", { "dependencies": { "es-define-property": "^1.0.0" } }, "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg=="], + + "has-proto": ["has-proto@1.2.0", "https://registry.npmmirror.com/has-proto/-/has-proto-1.2.0.tgz", { "dependencies": { "dunder-proto": "^1.0.0" } }, "sha512-KIL7eQPfHQRC8+XluaIw7BHUwwqL19bQn4hzNgdr+1wXoU0KKj6rufu47lhY7KbJR2C6T6+PfyN0Ea7wkSS+qQ=="], + + "has-symbols": ["has-symbols@1.1.0", "https://registry.npmmirror.com/has-symbols/-/has-symbols-1.1.0.tgz", {}, "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ=="], + + "has-tostringtag": ["has-tostringtag@1.0.2", "https://registry.npmmirror.com/has-tostringtag/-/has-tostringtag-1.0.2.tgz", { "dependencies": { "has-symbols": "^1.0.3" } }, "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw=="], + + "hasown": ["hasown@2.0.2", "https://registry.npmmirror.com/hasown/-/hasown-2.0.2.tgz", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="], + + "hermes-estree": ["hermes-estree@0.25.1", "", {}, "sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw=="], + + "hermes-parser": ["hermes-parser@0.25.1", "", { "dependencies": { "hermes-estree": "0.25.1" } }, "sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA=="], + + "http2-client": ["http2-client@1.3.5", "https://registry.npmmirror.com/http2-client/-/http2-client-1.3.5.tgz", {}, "sha512-EC2utToWl4RKfs5zd36Mxq7nzHHBuomZboI0yYL6Y0RmBgT7Sgkq4rQ0ezFTYoIsSs7Tm9SJe+o2FcAg6GBhGA=="], + + "ignore": ["ignore@5.3.2", "https://registry.npmmirror.com/ignore/-/ignore-5.3.2.tgz", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="], + + "immediate": ["immediate@3.0.6", "https://registry.npmmirror.com/immediate/-/immediate-3.0.6.tgz", {}, "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ=="], + + "immer": ["immer@10.1.3", "https://registry.npmmirror.com/immer/-/immer-10.1.3.tgz", {}, "sha512-tmjF/k8QDKydUlm3mZU+tjM6zeq9/fFpPqH9SzWmBnVVKsPBg/V66qsMwb3/Bo90cgUN+ghdVBess+hPsxUyRw=="], + + "import-fresh": ["import-fresh@3.3.1", "https://registry.npmmirror.com/import-fresh/-/import-fresh-3.3.1.tgz", { "dependencies": { "parent-module": "^1.0.0", "resolve-from": "^4.0.0" } }, "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ=="], + + "imurmurhash": ["imurmurhash@0.1.4", "https://registry.npmmirror.com/imurmurhash/-/imurmurhash-0.1.4.tgz", {}, "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA=="], + + "inflight": ["inflight@1.0.6", "https://registry.npmmirror.com/inflight/-/inflight-1.0.6.tgz", { "dependencies": { "once": "^1.3.0", "wrappy": "1" } }, "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA=="], + + "inherits": ["inherits@2.0.4", "https://registry.npmmirror.com/inherits/-/inherits-2.0.4.tgz", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="], + + "internal-slot": ["internal-slot@1.1.0", "https://registry.npmmirror.com/internal-slot/-/internal-slot-1.1.0.tgz", { "dependencies": { "es-errors": "^1.3.0", "hasown": "^2.0.2", "side-channel": "^1.1.0" } }, "sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw=="], + + "is-array-buffer": ["is-array-buffer@3.0.5", "https://registry.npmmirror.com/is-array-buffer/-/is-array-buffer-3.0.5.tgz", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.3", "get-intrinsic": "^1.2.6" } }, "sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A=="], + + "is-arrayish": ["is-arrayish@0.2.1", "https://registry.npmmirror.com/is-arrayish/-/is-arrayish-0.2.1.tgz", {}, "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg=="], + + "is-async-function": ["is-async-function@2.1.1", "https://registry.npmmirror.com/is-async-function/-/is-async-function-2.1.1.tgz", { "dependencies": { "async-function": "^1.0.0", "call-bound": "^1.0.3", "get-proto": "^1.0.1", "has-tostringtag": "^1.0.2", "safe-regex-test": "^1.1.0" } }, "sha512-9dgM/cZBnNvjzaMYHVoxxfPj2QXt22Ev7SuuPrs+xav0ukGB0S6d4ydZdEiM48kLx5kDV+QBPrpVnFyefL8kkQ=="], + + "is-bigint": ["is-bigint@1.1.0", "https://registry.npmmirror.com/is-bigint/-/is-bigint-1.1.0.tgz", { "dependencies": { "has-bigints": "^1.0.2" } }, "sha512-n4ZT37wG78iz03xPRKJrHTdZbe3IicyucEtdRsV5yglwc3GyUfbAfpSeD0FJ41NbUNSt5wbhqfp1fS+BgnvDFQ=="], + + "is-boolean-object": ["is-boolean-object@1.2.2", "https://registry.npmmirror.com/is-boolean-object/-/is-boolean-object-1.2.2.tgz", { "dependencies": { "call-bound": "^1.0.3", "has-tostringtag": "^1.0.2" } }, "sha512-wa56o2/ElJMYqjCjGkXri7it5FbebW5usLw/nPmCMs5DeZ7eziSYZhSmPRn0txqeW4LnAmQQU7FgqLpsEFKM4A=="], + + "is-bun-module": ["is-bun-module@2.0.0", "https://registry.npmmirror.com/is-bun-module/-/is-bun-module-2.0.0.tgz", { "dependencies": { "semver": "^7.7.1" } }, "sha512-gNCGbnnnnFAUGKeZ9PdbyeGYJqewpmc2aKHUEMO5nQPWU9lOmv7jcmQIv+qHD8fXW6W7qfuCwX4rY9LNRjXrkQ=="], + + "is-callable": ["is-callable@1.2.7", "https://registry.npmmirror.com/is-callable/-/is-callable-1.2.7.tgz", {}, "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA=="], + + "is-core-module": ["is-core-module@2.16.1", "https://registry.npmmirror.com/is-core-module/-/is-core-module-2.16.1.tgz", { "dependencies": { "hasown": "^2.0.2" } }, "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w=="], + + "is-data-view": ["is-data-view@1.0.2", "https://registry.npmmirror.com/is-data-view/-/is-data-view-1.0.2.tgz", { "dependencies": { "call-bound": "^1.0.2", "get-intrinsic": "^1.2.6", "is-typed-array": "^1.1.13" } }, "sha512-RKtWF8pGmS87i2D6gqQu/l7EYRlVdfzemCJN/P3UOs//x1QE7mfhvzHIApBTRf7axvT6DMGwSwBXYCT0nfB9xw=="], + + "is-date-object": ["is-date-object@1.1.0", "https://registry.npmmirror.com/is-date-object/-/is-date-object-1.1.0.tgz", { "dependencies": { "call-bound": "^1.0.2", "has-tostringtag": "^1.0.2" } }, "sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg=="], + + "is-extglob": ["is-extglob@2.1.1", "https://registry.npmmirror.com/is-extglob/-/is-extglob-2.1.1.tgz", {}, "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="], + + "is-finalizationregistry": ["is-finalizationregistry@1.1.1", "https://registry.npmmirror.com/is-finalizationregistry/-/is-finalizationregistry-1.1.1.tgz", { "dependencies": { "call-bound": "^1.0.3" } }, "sha512-1pC6N8qWJbWoPtEjgcL2xyhQOP491EQjeUo3qTKcmV8YSDDJrOepfG8pcC7h/QgnQHYSv0mJ3Z/ZWxmatVrysg=="], + + "is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "https://registry.npmmirror.com/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="], + + "is-generator-function": ["is-generator-function@1.1.0", "https://registry.npmmirror.com/is-generator-function/-/is-generator-function-1.1.0.tgz", { "dependencies": { "call-bound": "^1.0.3", "get-proto": "^1.0.0", "has-tostringtag": "^1.0.2", "safe-regex-test": "^1.1.0" } }, "sha512-nPUB5km40q9e8UfN/Zc24eLlzdSf9OfKByBw9CIdw4H1giPMeA0OIJvbchsCu4npfI2QcMVBsGEBHKZ7wLTWmQ=="], + + "is-glob": ["is-glob@4.0.3", "https://registry.npmmirror.com/is-glob/-/is-glob-4.0.3.tgz", { "dependencies": { "is-extglob": "^2.1.1" } }, "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg=="], + + "is-map": ["is-map@2.0.3", "https://registry.npmmirror.com/is-map/-/is-map-2.0.3.tgz", {}, "sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw=="], + + "is-negative-zero": ["is-negative-zero@2.0.3", "https://registry.npmmirror.com/is-negative-zero/-/is-negative-zero-2.0.3.tgz", {}, "sha512-5KoIu2Ngpyek75jXodFvnafB6DJgr3u8uuK0LEZJjrU19DrMD3EVERaR8sjz8CCGgpZvxPl9SuE1GMVPFHx1mw=="], + + "is-number": ["is-number@7.0.0", "https://registry.npmmirror.com/is-number/-/is-number-7.0.0.tgz", {}, "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng=="], + + "is-number-object": ["is-number-object@1.1.1", "https://registry.npmmirror.com/is-number-object/-/is-number-object-1.1.1.tgz", { "dependencies": { "call-bound": "^1.0.3", "has-tostringtag": "^1.0.2" } }, "sha512-lZhclumE1G6VYD8VHe35wFaIif+CTy5SJIi5+3y4psDgWu4wPDoBhF8NxUOinEc7pHgiTsT6MaBb92rKhhD+Xw=="], + + "is-promise": ["is-promise@2.2.2", "https://registry.npmmirror.com/is-promise/-/is-promise-2.2.2.tgz", {}, "sha512-+lP4/6lKUBfQjZ2pdxThZvLUAafmZb8OAxFb8XXtiQmS35INgr85hdOGoEs124ez1FCnZJt6jau/T+alh58QFQ=="], + + "is-regex": ["is-regex@1.2.1", "https://registry.npmmirror.com/is-regex/-/is-regex-1.2.1.tgz", { "dependencies": { "call-bound": "^1.0.2", "gopd": "^1.2.0", "has-tostringtag": "^1.0.2", "hasown": "^2.0.2" } }, "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g=="], + + "is-set": ["is-set@2.0.3", "https://registry.npmmirror.com/is-set/-/is-set-2.0.3.tgz", {}, "sha512-iPAjerrse27/ygGLxw+EBR9agv9Y6uLeYVJMu+QNCoouJ1/1ri0mGrcWpfCqFZuzzx3WjtwxG098X+n4OuRkPg=="], + + "is-shared-array-buffer": ["is-shared-array-buffer@1.0.4", "https://registry.npmmirror.com/is-shared-array-buffer/-/is-shared-array-buffer-1.0.4.tgz", { "dependencies": { "call-bound": "^1.0.3" } }, "sha512-ISWac8drv4ZGfwKl5slpHG9OwPNty4jOWPRIhBpxOoD+hqITiwuipOQ2bNthAzwA3B4fIjO4Nln74N0S9byq8A=="], + + "is-string": ["is-string@1.1.1", "https://registry.npmmirror.com/is-string/-/is-string-1.1.1.tgz", { "dependencies": { "call-bound": "^1.0.3", "has-tostringtag": "^1.0.2" } }, "sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA=="], + + "is-symbol": ["is-symbol@1.1.1", "https://registry.npmmirror.com/is-symbol/-/is-symbol-1.1.1.tgz", { "dependencies": { "call-bound": "^1.0.2", "has-symbols": "^1.1.0", "safe-regex-test": "^1.1.0" } }, "sha512-9gGx6GTtCQM73BgmHQXfDmLtfjjTUDSyoxTCbp5WtoixAhfgsDirWIcVQ/IHpvI5Vgd5i/J5F7B9cN/WlVbC/w=="], + + "is-typed-array": ["is-typed-array@1.1.15", "https://registry.npmmirror.com/is-typed-array/-/is-typed-array-1.1.15.tgz", { "dependencies": { "which-typed-array": "^1.1.16" } }, "sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ=="], + + "is-weakmap": ["is-weakmap@2.0.2", "https://registry.npmmirror.com/is-weakmap/-/is-weakmap-2.0.2.tgz", {}, "sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w=="], + + "is-weakref": ["is-weakref@1.1.1", "https://registry.npmmirror.com/is-weakref/-/is-weakref-1.1.1.tgz", { "dependencies": { "call-bound": "^1.0.3" } }, "sha512-6i9mGWSlqzNMEqpCp93KwRS1uUOodk2OJ6b+sq7ZPDSy2WuI5NFIxp/254TytR8ftefexkWn5xNiHUNpPOfSew=="], + + "is-weakset": ["is-weakset@2.0.4", "https://registry.npmmirror.com/is-weakset/-/is-weakset-2.0.4.tgz", { "dependencies": { "call-bound": "^1.0.3", "get-intrinsic": "^1.2.6" } }, "sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ=="], + + "isarray": ["isarray@2.0.5", "https://registry.npmmirror.com/isarray/-/isarray-2.0.5.tgz", {}, "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw=="], + + "isexe": ["isexe@2.0.0", "https://registry.npmmirror.com/isexe/-/isexe-2.0.0.tgz", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], + + "iterator.prototype": ["iterator.prototype@1.1.5", "https://registry.npmmirror.com/iterator.prototype/-/iterator.prototype-1.1.5.tgz", { "dependencies": { "define-data-property": "^1.1.4", "es-object-atoms": "^1.0.0", "get-intrinsic": "^1.2.6", "get-proto": "^1.0.0", "has-symbols": "^1.1.0", "set-function-name": "^2.0.2" } }, "sha512-H0dkQoCa3b2VEeKQBOxFph+JAbcrQdE7KC0UkqwpLmv2EC4P41QXP+rqo9wYodACiG5/WM5s9oDApTU8utwj9g=="], + + "jiti": ["jiti@2.4.2", "https://registry.npmmirror.com/jiti/-/jiti-2.4.2.tgz", { "bin": "lib/jiti-cli.mjs" }, "sha512-rg9zJN+G4n2nfJl5MW3BMygZX56zKPNVEYYqq7adpmMh4Jn2QNEwhvQlFy6jPVdcod7txZtKHWnyZiA3a0zP7A=="], + + "js-tokens": ["js-tokens@4.0.0", "https://registry.npmmirror.com/js-tokens/-/js-tokens-4.0.0.tgz", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="], + + "js-yaml": ["js-yaml@4.1.0", "https://registry.npmmirror.com/js-yaml/-/js-yaml-4.1.0.tgz", { "dependencies": { "argparse": "^2.0.1" }, "bin": "bin/js-yaml.js" }, "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA=="], + + "jsesc": ["jsesc@3.1.0", "", { "bin": "bin/jsesc" }, "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA=="], + + "json-buffer": ["json-buffer@3.0.1", "https://registry.npmmirror.com/json-buffer/-/json-buffer-3.0.1.tgz", {}, "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ=="], + + "json-parse-even-better-errors": ["json-parse-even-better-errors@2.3.1", "https://registry.npmmirror.com/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", {}, "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w=="], + + "json-schema-traverse": ["json-schema-traverse@0.4.1", "https://registry.npmmirror.com/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="], + + "json-stable-stringify-without-jsonify": ["json-stable-stringify-without-jsonify@1.0.1", "https://registry.npmmirror.com/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", {}, "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw=="], + + "json5": ["json5@1.0.2", "https://registry.npmmirror.com/json5/-/json5-1.0.2.tgz", { "dependencies": { "minimist": "^1.2.0" }, "bin": "lib/cli.js" }, "sha512-g1MWMLBiz8FKi1e4w0UyVL3w+iJceWAFBAaBnnGKOpNa5f8TLktkbre1+s6oICydWAm+HRUGTmI+//xv2hvXYA=="], + + "jsx-ast-utils": ["jsx-ast-utils@3.3.5", "https://registry.npmmirror.com/jsx-ast-utils/-/jsx-ast-utils-3.3.5.tgz", { "dependencies": { "array-includes": "^3.1.6", "array.prototype.flat": "^1.3.1", "object.assign": "^4.1.4", "object.values": "^1.1.6" } }, "sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ=="], + + "keyv": ["keyv@4.5.4", "https://registry.npmmirror.com/keyv/-/keyv-4.5.4.tgz", { "dependencies": { "json-buffer": "3.0.1" } }, "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw=="], + + "language-subtag-registry": ["language-subtag-registry@0.3.23", "https://registry.npmmirror.com/language-subtag-registry/-/language-subtag-registry-0.3.23.tgz", {}, "sha512-0K65Lea881pHotoGEa5gDlMxt3pctLi2RplBb7Ezh4rRdLEOtgi7n4EwK9lamnUCkKBqaeKRVebTq6BAxSkpXQ=="], + + "language-tags": ["language-tags@1.0.9", "https://registry.npmmirror.com/language-tags/-/language-tags-1.0.9.tgz", { "dependencies": { "language-subtag-registry": "^0.3.20" } }, "sha512-MbjN408fEndfiQXbFQ1vnd+1NoLDsnQW41410oQBXiyXDMYH5z505juWa4KUE1LqxRC7DgOgZDbKLxHIwm27hA=="], + + "levn": ["levn@0.4.1", "https://registry.npmmirror.com/levn/-/levn-0.4.1.tgz", { "dependencies": { "prelude-ls": "^1.2.1", "type-check": "~0.4.0" } }, "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ=="], + + "lie": ["lie@3.1.1", "https://registry.npmmirror.com/lie/-/lie-3.1.1.tgz", { "dependencies": { "immediate": "~3.0.5" } }, "sha512-RiNhHysUjhrDQntfYSfY4MU24coXXdEOgw9WGcKHNeEwffDYbF//u87M1EWaMGzuFoSbqW0C9C6lEEhDOAswfw=="], + + "lightningcss": ["lightningcss@1.30.1", "https://registry.npmmirror.com/lightningcss/-/lightningcss-1.30.1.tgz", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-darwin-arm64": "1.30.1", "lightningcss-darwin-x64": "1.30.1", "lightningcss-freebsd-x64": "1.30.1", "lightningcss-linux-arm-gnueabihf": "1.30.1", "lightningcss-linux-arm64-gnu": "1.30.1", "lightningcss-linux-arm64-musl": "1.30.1", "lightningcss-linux-x64-gnu": "1.30.1", "lightningcss-linux-x64-musl": "1.30.1", "lightningcss-win32-arm64-msvc": "1.30.1", "lightningcss-win32-x64-msvc": "1.30.1" } }, "sha512-xi6IyHML+c9+Q3W0S4fCQJOym42pyurFiJUHEcEyHS0CeKzia4yZDEsLlqOFykxOdHpNy0NmvVO31vcSqAxJCg=="], + + "lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.30.1", "https://registry.npmmirror.com/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.30.1.tgz", { "os": "darwin", "cpu": "arm64" }, "sha512-c8JK7hyE65X1MHMN+Viq9n11RRC7hgin3HhYKhrMyaXflk5GVplZ60IxyoVtzILeKr+xAJwg6zK6sjTBJ0FKYQ=="], + + "lightningcss-darwin-x64": ["lightningcss-darwin-x64@1.30.1", "https://registry.npmmirror.com/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.30.1.tgz", { "os": "darwin", "cpu": "x64" }, "sha512-k1EvjakfumAQoTfcXUcHQZhSpLlkAuEkdMBsI/ivWw9hL+7FtilQc0Cy3hrx0AAQrVtQAbMI7YjCgYgvn37PzA=="], + + "lightningcss-freebsd-x64": ["lightningcss-freebsd-x64@1.30.1", "https://registry.npmmirror.com/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.30.1.tgz", { "os": "freebsd", "cpu": "x64" }, "sha512-kmW6UGCGg2PcyUE59K5r0kWfKPAVy4SltVeut+umLCFoJ53RdCUWxcRDzO1eTaxf/7Q2H7LTquFHPL5R+Gjyig=="], + + "lightningcss-linux-arm-gnueabihf": ["lightningcss-linux-arm-gnueabihf@1.30.1", "https://registry.npmmirror.com/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.30.1.tgz", { "os": "linux", "cpu": "arm" }, "sha512-MjxUShl1v8pit+6D/zSPq9S9dQ2NPFSQwGvxBCYaBYLPlCWuPh9/t1MRS8iUaR8i+a6w7aps+B4N0S1TYP/R+Q=="], + + "lightningcss-linux-arm64-gnu": ["lightningcss-linux-arm64-gnu@1.30.1", "https://registry.npmmirror.com/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.30.1.tgz", { "os": "linux", "cpu": "arm64" }, "sha512-gB72maP8rmrKsnKYy8XUuXi/4OctJiuQjcuqWNlJQ6jZiWqtPvqFziskH3hnajfvKB27ynbVCucKSm2rkQp4Bw=="], + + "lightningcss-linux-arm64-musl": ["lightningcss-linux-arm64-musl@1.30.1", "https://registry.npmmirror.com/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.30.1.tgz", { "os": "linux", "cpu": "arm64" }, "sha512-jmUQVx4331m6LIX+0wUhBbmMX7TCfjF5FoOH6SD1CttzuYlGNVpA7QnrmLxrsub43ClTINfGSYyHe2HWeLl5CQ=="], + + "lightningcss-linux-x64-gnu": ["lightningcss-linux-x64-gnu@1.30.1", "https://registry.npmmirror.com/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.30.1.tgz", { "os": "linux", "cpu": "x64" }, "sha512-piWx3z4wN8J8z3+O5kO74+yr6ze/dKmPnI7vLqfSqI8bccaTGY5xiSGVIJBDd5K5BHlvVLpUB3S2YCfelyJ1bw=="], + + "lightningcss-linux-x64-musl": ["lightningcss-linux-x64-musl@1.30.1", "https://registry.npmmirror.com/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.30.1.tgz", { "os": "linux", "cpu": "x64" }, "sha512-rRomAK7eIkL+tHY0YPxbc5Dra2gXlI63HL+v1Pdi1a3sC+tJTcFrHX+E86sulgAXeI7rSzDYhPSeHHjqFhqfeQ=="], + + "lightningcss-win32-arm64-msvc": ["lightningcss-win32-arm64-msvc@1.30.1", "https://registry.npmmirror.com/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.30.1.tgz", { "os": "win32", "cpu": "arm64" }, "sha512-mSL4rqPi4iXq5YVqzSsJgMVFENoa4nGTT/GjO2c0Yl9OuQfPsIfncvLrEW6RbbB24WtZ3xP/2CCmI3tNkNV4oA=="], + + "lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.30.1", "https://registry.npmmirror.com/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.30.1.tgz", { "os": "win32", "cpu": "x64" }, "sha512-PVqXh48wh4T53F/1CCu8PIPCxLzWyCnn/9T5W1Jpmdy5h9Cwd+0YQS6/LwhHXSafuc61/xg9Lv5OrCby6a++jg=="], + + "lines-and-columns": ["lines-and-columns@1.2.4", "https://registry.npmmirror.com/lines-and-columns/-/lines-and-columns-1.2.4.tgz", {}, "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg=="], + + "localforage": ["localforage@1.10.0", "https://registry.npmmirror.com/localforage/-/localforage-1.10.0.tgz", { "dependencies": { "lie": "3.1.1" } }, "sha512-14/H1aX7hzBBmmh7sGPd+AOMkkIrHM3Z1PAyGgZigA1H1p5O5ANnMyWzvpAETtG68/dC4pC0ncy3+PPGzXZHPg=="], + + "locate-path": ["locate-path@6.0.0", "https://registry.npmmirror.com/locate-path/-/locate-path-6.0.0.tgz", { "dependencies": { "p-locate": "^5.0.0" } }, "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw=="], + + "lodash": ["lodash@4.17.21", "https://registry.npmmirror.com/lodash/-/lodash-4.17.21.tgz", {}, "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg=="], + + "lodash.merge": ["lodash.merge@4.6.2", "https://registry.npmmirror.com/lodash.merge/-/lodash.merge-4.6.2.tgz", {}, "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ=="], + + "loose-envify": ["loose-envify@1.4.0", "https://registry.npmmirror.com/loose-envify/-/loose-envify-1.4.0.tgz", { "dependencies": { "js-tokens": "^3.0.0 || ^4.0.0" }, "bin": "cli.js" }, "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q=="], + + "lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="], + + "lru-queue": ["lru-queue@0.1.0", "https://registry.npmmirror.com/lru-queue/-/lru-queue-0.1.0.tgz", { "dependencies": { "es5-ext": "~0.10.2" } }, "sha512-BpdYkt9EvGl8OfWHDQPISVpcl5xZthb+XPsbELj5AQXxIC8IriDZIQYjBJPEm5rS420sjZ0TLEzRcq5KdBhYrQ=="], + + "lucide-react": ["lucide-react@0.523.0", "https://registry.npmmirror.com/lucide-react/-/lucide-react-0.523.0.tgz", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-rUjQoy7egZT9XYVXBK1je9ckBnNp7qzRZOhLQx5RcEp2dCGlXo+mv6vf7Am4LimEcFBJIIZzSGfgTqc9QCrPSw=="], + + "magic-string": ["magic-string@0.30.17", "https://registry.npmmirror.com/magic-string/-/magic-string-0.30.17.tgz", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0" } }, "sha512-sNPKHvyjVf7gyjwS4xGTaW/mCnF8wnjtifKBEhxfZ7E/S8tQ0rssrwGNn6q8JH/ohItJfSQp9mBtQYuTlH5QnA=="], + + "math-intrinsics": ["math-intrinsics@1.1.0", "https://registry.npmmirror.com/math-intrinsics/-/math-intrinsics-1.1.0.tgz", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="], + + "memoizee": ["memoizee@0.4.17", "https://registry.npmmirror.com/memoizee/-/memoizee-0.4.17.tgz", { "dependencies": { "d": "^1.0.2", "es5-ext": "^0.10.64", "es6-weak-map": "^2.0.3", "event-emitter": "^0.3.5", "is-promise": "^2.2.2", "lru-queue": "^0.1.0", "next-tick": "^1.1.0", "timers-ext": "^0.1.7" } }, "sha512-DGqD7Hjpi/1or4F/aYAspXKNm5Yili0QDAFAY4QYvpqpgiY6+1jOfqpmByzjxbWd/T9mChbCArXAbDAsTm5oXA=="], + + "merge2": ["merge2@1.4.1", "https://registry.npmmirror.com/merge2/-/merge2-1.4.1.tgz", {}, "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg=="], + + "micromatch": ["micromatch@4.0.8", "https://registry.npmmirror.com/micromatch/-/micromatch-4.0.8.tgz", { "dependencies": { "braces": "^3.0.3", "picomatch": "^2.3.1" } }, "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA=="], + + "mime-db": ["mime-db@1.52.0", "https://registry.npmmirror.com/mime-db/-/mime-db-1.52.0.tgz", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="], + + "mime-types": ["mime-types@2.1.35", "https://registry.npmmirror.com/mime-types/-/mime-types-2.1.35.tgz", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="], + + "minimatch": ["minimatch@3.1.2", "https://registry.npmmirror.com/minimatch/-/minimatch-3.1.2.tgz", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw=="], + + "minimist": ["minimist@1.2.8", "https://registry.npmmirror.com/minimist/-/minimist-1.2.8.tgz", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="], + + "minipass": ["minipass@7.1.2", "https://registry.npmmirror.com/minipass/-/minipass-7.1.2.tgz", {}, "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw=="], + + "minizlib": ["minizlib@3.0.2", "https://registry.npmmirror.com/minizlib/-/minizlib-3.0.2.tgz", { "dependencies": { "minipass": "^7.1.2" } }, "sha512-oG62iEk+CYt5Xj2YqI5Xi9xWUeZhDI8jjQmC5oThVH5JGCTgIjr7ciJDzC7MBzYd//WvR1OTmP5Q38Q8ShQtVA=="], + + "mkdirp": ["mkdirp@3.0.1", "https://registry.npmmirror.com/mkdirp/-/mkdirp-3.0.1.tgz", { "bin": "dist/cjs/src/bin.js" }, "sha512-+NsyUUAZDmo6YVHzL/stxSu3t9YS1iljliy3BSDrXJ/dkn1KYdmtZODGGjLcc9XLgVVpH4KshHB8XmZgMhaBXg=="], + + "mock.js": ["mock.js@0.2.0", "https://registry.npmmirror.com/mock.js/-/mock.js-0.2.0.tgz", {}, "sha512-DKI8Rh/h7Mma+fg+6aD0uUvwn0QXAjKG6q3s+lTaCboCQ/kvQMBN9IXRBzgEaz4aPiYoRnKU9jVsfZp0mHpWrQ=="], + + "mockjs": ["mockjs@1.1.0", "https://registry.npmmirror.com/mockjs/-/mockjs-1.1.0.tgz", { "dependencies": { "commander": "*" }, "bin": { "random": "bin/random" } }, "sha512-eQsKcWzIaZzEZ07NuEyO4Nw65g0hdWAyurVol1IPl1gahRwY+svqzfgfey8U8dahLwG44d6/RwEzuK52rSa/JQ=="], + + "motion": ["motion@12.38.0", "https://registry.npmmirror.com/motion/-/motion-12.38.0.tgz", { "dependencies": { "framer-motion": "^12.38.0", "tslib": "^2.4.0" }, "peerDependencies": { "@emotion/is-prop-valid": "*", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@emotion/is-prop-valid", "react", "react-dom"] }, "sha512-uYfXzeHlgThchzwz5Te47dlv5JOUC7OB4rjJ/7XTUgtBZD8CchMN8qEJ4ZVsUmTyYA44zjV0fBwsiktRuFnn+w=="], + + "motion-dom": ["motion-dom@12.38.0", "https://registry.npmmirror.com/motion-dom/-/motion-dom-12.38.0.tgz", { "dependencies": { "motion-utils": "^12.36.0" } }, "sha512-pdkHLD8QYRp8VfiNLb8xIBJis1byQ9gPT3Jnh2jqfFtAsWUA3dEepDlsWe/xMpO8McV+VdpKVcp+E+TGJEtOoA=="], + + "motion-utils": ["motion-utils@12.36.0", "https://registry.npmmirror.com/motion-utils/-/motion-utils-12.36.0.tgz", {}, "sha512-eHWisygbiwVvf6PZ1vhaHCLamvkSbPIeAYxWUuL3a2PD/TROgE7FvfHWTIH4vMl798QLfMw15nRqIaRDXTlYRg=="], + + "ms": ["ms@2.1.3", "https://registry.npmmirror.com/ms/-/ms-2.1.3.tgz", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], + + "nanoid": ["nanoid@3.3.11", "https://registry.npmmirror.com/nanoid/-/nanoid-3.3.11.tgz", { "bin": "bin/nanoid.cjs" }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="], + + "napi-postinstall": ["napi-postinstall@0.2.4", "https://registry.npmmirror.com/napi-postinstall/-/napi-postinstall-0.2.4.tgz", { "bin": "lib/cli.js" }, "sha512-ZEzHJwBhZ8qQSbknHqYcdtQVr8zUgGyM/q6h6qAyhtyVMNrSgDhrC4disf03dYW0e+czXyLnZINnCTEkWy0eJg=="], + + "natural-compare": ["natural-compare@1.4.0", "https://registry.npmmirror.com/natural-compare/-/natural-compare-1.4.0.tgz", {}, "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw=="], + + "next": ["next@16.2.3", "", { "dependencies": { "@next/env": "16.2.3", "@swc/helpers": "0.5.15", "baseline-browser-mapping": "^2.9.19", "caniuse-lite": "^1.0.30001579", "postcss": "8.4.31", "styled-jsx": "5.1.6" }, "optionalDependencies": { "@next/swc-darwin-arm64": "16.2.3", "@next/swc-darwin-x64": "16.2.3", "@next/swc-linux-arm64-gnu": "16.2.3", "@next/swc-linux-arm64-musl": "16.2.3", "@next/swc-linux-x64-gnu": "16.2.3", "@next/swc-linux-x64-musl": "16.2.3", "@next/swc-win32-arm64-msvc": "16.2.3", "@next/swc-win32-x64-msvc": "16.2.3", "sharp": "^0.34.5" }, "peerDependencies": { "@opentelemetry/api": "^1.1.0", "@playwright/test": "^1.51.1", "babel-plugin-react-compiler": "*", "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "sass": "^1.3.0" }, "optionalPeers": ["@opentelemetry/api", "@playwright/test", "babel-plugin-react-compiler", "sass"], "bin": "dist/bin/next" }, "sha512-9V3zV4oZFza3PVev5/poB9g0dEafVcgNyQ8eTRop8GvxZjV2G15FC5ARuG1eFD42QgeYkzJBJzHghNP8Ad9xtA=="], + + "next-tick": ["next-tick@1.1.0", "https://registry.npmmirror.com/next-tick/-/next-tick-1.1.0.tgz", {}, "sha512-CXdUiJembsNjuToQvxayPZF9Vqht7hewsvy2sOWafLvi2awflj9mOC6bHIg50orX8IJvWKY9wYQ/zB2kogPslQ=="], + + "node-fetch": ["node-fetch@2.7.0", "https://registry.npmmirror.com/node-fetch/-/node-fetch-2.7.0.tgz", { "dependencies": { "whatwg-url": "^5.0.0" }, "peerDependencies": { "encoding": "^0.1.0" }, "optionalPeers": ["encoding"] }, "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A=="], + + "node-fetch-h2": ["node-fetch-h2@2.3.0", "https://registry.npmmirror.com/node-fetch-h2/-/node-fetch-h2-2.3.0.tgz", { "dependencies": { "http2-client": "^1.2.5" } }, "sha512-ofRW94Ab0T4AOh5Fk8t0h8OBWrmjb0SSB20xh1H8YnPV9EJ+f5AMoYSUQ2zgJ4Iq2HAK0I2l5/Nequ8YzFS3Hg=="], + + "node-readfiles": ["node-readfiles@0.2.0", "https://registry.npmmirror.com/node-readfiles/-/node-readfiles-0.2.0.tgz", { "dependencies": { "es6-promise": "^3.2.1" } }, "sha512-SU00ZarexNlE4Rjdm83vglt5Y9yiQ+XI1XpflWlb7q7UTN1JUItm69xMeiQCTxtTfnzt+83T8Cx+vI2ED++VDA=="], + + "node-releases": ["node-releases@2.0.37", "", {}, "sha512-1h5gKZCF+pO/o3Iqt5Jp7wc9rH3eJJ0+nh/CIoiRwjRxde/hAHyLPXYN4V3CqKAbiZPSeJFSWHmJsbkicta0Eg=="], + + "number-to-words": ["number-to-words@1.2.4", "https://registry.npmmirror.com/number-to-words/-/number-to-words-1.2.4.tgz", {}, "sha512-/fYevVkXRcyBiZDg6yzZbm0RuaD6i0qRfn8yr+6D0KgBMOndFPxuW10qCHpzs50nN8qKuv78k8MuotZhcVX6Pw=="], + + "nunjucks": ["nunjucks@3.2.4", "https://registry.npmmirror.com/nunjucks/-/nunjucks-3.2.4.tgz", { "dependencies": { "a-sync-waterfall": "^1.0.0", "asap": "^2.0.3", "commander": "^5.1.0" }, "peerDependencies": { "chokidar": "^3.3.0" }, "optionalPeers": ["chokidar"], "bin": { "nunjucks-precompile": "bin/precompile" } }, "sha512-26XRV6BhkgK0VOxfbU5cQI+ICFUtMLixv1noZn1tGU38kQH5A5nmmbk/O45xdyBhD1esk47nKrY0mvQpZIhRjQ=="], + + "oas-kit-common": ["oas-kit-common@1.0.8", "https://registry.npmmirror.com/oas-kit-common/-/oas-kit-common-1.0.8.tgz", { "dependencies": { "fast-safe-stringify": "^2.0.7" } }, "sha512-pJTS2+T0oGIwgjGpw7sIRU8RQMcUoKCDWFLdBqKB2BNmGpbBMH2sdqAaOXUg8OzonZHU0L7vfJu1mJFEiYDWOQ=="], + + "oas-linter": ["oas-linter@3.2.2", "https://registry.npmmirror.com/oas-linter/-/oas-linter-3.2.2.tgz", { "dependencies": { "@exodus/schemasafe": "^1.0.0-rc.2", "should": "^13.2.1", "yaml": "^1.10.0" } }, "sha512-KEGjPDVoU5K6swgo9hJVA/qYGlwfbFx+Kg2QB/kd7rzV5N8N5Mg6PlsoCMohVnQmo+pzJap/F610qTodKzecGQ=="], + + "oas-resolver": ["oas-resolver@2.5.6", "https://registry.npmmirror.com/oas-resolver/-/oas-resolver-2.5.6.tgz", { "dependencies": { "node-fetch-h2": "^2.3.0", "oas-kit-common": "^1.0.8", "reftools": "^1.1.9", "yaml": "^1.10.0", "yargs": "^17.0.1" }, "bin": { "resolve": "resolve.js" } }, "sha512-Yx5PWQNZomfEhPPOphFbZKi9W93CocQj18NlD2Pa4GWZzdZpSJvYwoiuurRI7m3SpcChrnO08hkuQDL3FGsVFQ=="], + + "oas-schema-walker": ["oas-schema-walker@1.1.5", "https://registry.npmmirror.com/oas-schema-walker/-/oas-schema-walker-1.1.5.tgz", {}, "sha512-2yucenq1a9YPmeNExoUa9Qwrt9RFkjqaMAA1X+U7sbb0AqBeTIdMHky9SQQ6iN94bO5NW0W4TRYXerG+BdAvAQ=="], + + "oas-validator": ["oas-validator@5.0.8", "https://registry.npmmirror.com/oas-validator/-/oas-validator-5.0.8.tgz", { "dependencies": { "call-me-maybe": "^1.0.1", "oas-kit-common": "^1.0.8", "oas-linter": "^3.2.2", "oas-resolver": "^2.5.6", "oas-schema-walker": "^1.1.5", "reftools": "^1.1.9", "should": "^13.2.1", "yaml": "^1.10.0" } }, "sha512-cu20/HE5N5HKqVygs3dt94eYJfBi0TsZvPVXDhbXQHiEityDN+RROTleefoKRKKJ9dFAF2JBkDHgvWj0sjKGmw=="], + + "object-assign": ["object-assign@4.1.1", "https://registry.npmmirror.com/object-assign/-/object-assign-4.1.1.tgz", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="], + + "object-inspect": ["object-inspect@1.13.4", "https://registry.npmmirror.com/object-inspect/-/object-inspect-1.13.4.tgz", {}, "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew=="], + + "object-keys": ["object-keys@1.1.1", "https://registry.npmmirror.com/object-keys/-/object-keys-1.1.1.tgz", {}, "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA=="], + + "object.assign": ["object.assign@4.1.7", "https://registry.npmmirror.com/object.assign/-/object.assign-4.1.7.tgz", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.3", "define-properties": "^1.2.1", "es-object-atoms": "^1.0.0", "has-symbols": "^1.1.0", "object-keys": "^1.1.1" } }, "sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw=="], + + "object.entries": ["object.entries@1.1.9", "https://registry.npmmirror.com/object.entries/-/object.entries-1.1.9.tgz", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.4", "define-properties": "^1.2.1", "es-object-atoms": "^1.1.1" } }, "sha512-8u/hfXFRBD1O0hPUjioLhoWFHRmt6tKA4/vZPyckBr18l1KE9uHrFaFaUi8MDRTpi4uak2goyPTSNJLXX2k2Hw=="], + + "object.fromentries": ["object.fromentries@2.0.8", "https://registry.npmmirror.com/object.fromentries/-/object.fromentries-2.0.8.tgz", { "dependencies": { "call-bind": "^1.0.7", "define-properties": "^1.2.1", "es-abstract": "^1.23.2", "es-object-atoms": "^1.0.0" } }, "sha512-k6E21FzySsSK5a21KRADBd/NGneRegFO5pLHfdQLpRDETUNJueLXs3WCzyQ3tFRDYgbq3KHGXfTbi2bs8WQ6rQ=="], + + "object.groupby": ["object.groupby@1.0.3", "https://registry.npmmirror.com/object.groupby/-/object.groupby-1.0.3.tgz", { "dependencies": { "call-bind": "^1.0.7", "define-properties": "^1.2.1", "es-abstract": "^1.23.2" } }, "sha512-+Lhy3TQTuzXI5hevh8sBGqbmurHbbIjAi0Z4S63nthVLmLxfbj4T54a4CfZrXIrt9iP4mVAPYMo/v99taj3wjQ=="], + + "object.values": ["object.values@1.2.1", "https://registry.npmmirror.com/object.values/-/object.values-1.2.1.tgz", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.3", "define-properties": "^1.2.1", "es-object-atoms": "^1.0.0" } }, "sha512-gXah6aZrcUxjWg2zR2MwouP2eHlCBzdV4pygudehaKXSGW4v2AsRQUK+lwwXhii6KFZcunEnmSUoYp5CXibxtA=="], + + "once": ["once@1.4.0", "https://registry.npmmirror.com/once/-/once-1.4.0.tgz", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="], + + "openapi3-ts": ["openapi3-ts@2.0.2", "https://registry.npmmirror.com/openapi3-ts/-/openapi3-ts-2.0.2.tgz", { "dependencies": { "yaml": "^1.10.2" } }, "sha512-TxhYBMoqx9frXyOgnRHufjQfPXomTIHYKhSKJ6jHfj13kS8OEIhvmE8CTuQyKtjjWttAjX5DPxM1vmalEpo8Qw=="], + + "optionator": ["optionator@0.9.4", "https://registry.npmmirror.com/optionator/-/optionator-0.9.4.tgz", { "dependencies": { "deep-is": "^0.1.3", "fast-levenshtein": "^2.0.6", "levn": "^0.4.1", "prelude-ls": "^1.2.1", "type-check": "^0.4.0", "word-wrap": "^1.2.5" } }, "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g=="], + + "own-keys": ["own-keys@1.0.1", "https://registry.npmmirror.com/own-keys/-/own-keys-1.0.1.tgz", { "dependencies": { "get-intrinsic": "^1.2.6", "object-keys": "^1.1.1", "safe-push-apply": "^1.0.0" } }, "sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg=="], + + "p-limit": ["p-limit@3.1.0", "https://registry.npmmirror.com/p-limit/-/p-limit-3.1.0.tgz", { "dependencies": { "yocto-queue": "^0.1.0" } }, "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ=="], + + "p-locate": ["p-locate@5.0.0", "https://registry.npmmirror.com/p-locate/-/p-locate-5.0.0.tgz", { "dependencies": { "p-limit": "^3.0.2" } }, "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw=="], + + "parent-module": ["parent-module@1.0.1", "https://registry.npmmirror.com/parent-module/-/parent-module-1.0.1.tgz", { "dependencies": { "callsites": "^3.0.0" } }, "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g=="], + + "parse-json": ["parse-json@5.2.0", "https://registry.npmmirror.com/parse-json/-/parse-json-5.2.0.tgz", { "dependencies": { "@babel/code-frame": "^7.0.0", "error-ex": "^1.3.1", "json-parse-even-better-errors": "^2.3.0", "lines-and-columns": "^1.1.6" } }, "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg=="], + + "path-exists": ["path-exists@4.0.0", "https://registry.npmmirror.com/path-exists/-/path-exists-4.0.0.tgz", {}, "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w=="], + + "path-is-absolute": ["path-is-absolute@1.0.1", "https://registry.npmmirror.com/path-is-absolute/-/path-is-absolute-1.0.1.tgz", {}, "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg=="], + + "path-key": ["path-key@3.1.1", "https://registry.npmmirror.com/path-key/-/path-key-3.1.1.tgz", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="], + + "path-parse": ["path-parse@1.0.7", "https://registry.npmmirror.com/path-parse/-/path-parse-1.0.7.tgz", {}, "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw=="], + + "picocolors": ["picocolors@1.1.1", "https://registry.npmmirror.com/picocolors/-/picocolors-1.1.1.tgz", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], + + "picomatch": ["picomatch@4.0.4", "", {}, "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A=="], + + "possible-typed-array-names": ["possible-typed-array-names@1.1.0", "https://registry.npmmirror.com/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", {}, "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg=="], + + "postcss": ["postcss@8.5.6", "https://registry.npmmirror.com/postcss/-/postcss-8.5.6.tgz", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg=="], + + "prelude-ls": ["prelude-ls@1.2.1", "https://registry.npmmirror.com/prelude-ls/-/prelude-ls-1.2.1.tgz", {}, "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g=="], + + "prettier": ["prettier@2.8.8", "https://registry.npmmirror.com/prettier/-/prettier-2.8.8.tgz", { "bin": "bin-prettier.js" }, "sha512-tdN8qQGvNjw4CHbY+XXk0JgCXn9QiF21a55rBe5LJAU+kDyC4WQn4+awm2Xfk2lQMk5fKup9XgzTZtGkjBdP9Q=="], + + "prettier-plugin-organize-imports": ["prettier-plugin-organize-imports@4.2.0", "https://registry.npmmirror.com/prettier-plugin-organize-imports/-/prettier-plugin-organize-imports-4.2.0.tgz", { "peerDependencies": { "prettier": ">=2.0", "typescript": ">=2.9", "vue-tsc": "^2.1.0 || 3" }, "optionalPeers": ["vue-tsc"] }, "sha512-Zdy27UhlmyvATZi67BTnLcKTo8fm6Oik59Sz6H64PgZJVs6NJpPD1mT240mmJn62c98/QaL+r3kx9Q3gRpDajg=="], + + "prettier-plugin-tailwindcss": ["prettier-plugin-tailwindcss@0.6.14", "https://registry.npmmirror.com/prettier-plugin-tailwindcss/-/prettier-plugin-tailwindcss-0.6.14.tgz", { "peerDependencies": { "@ianvs/prettier-plugin-sort-imports": "*", "@prettier/plugin-hermes": "*", "@prettier/plugin-oxc": "*", "@prettier/plugin-pug": "*", "@shopify/prettier-plugin-liquid": "*", "@trivago/prettier-plugin-sort-imports": "*", "@zackad/prettier-plugin-twig": "*", "prettier": "^3.0", "prettier-plugin-astro": "*", "prettier-plugin-css-order": "*", "prettier-plugin-import-sort": "*", "prettier-plugin-jsdoc": "*", "prettier-plugin-marko": "*", "prettier-plugin-multiline-arrays": "*", "prettier-plugin-organize-attributes": "*", "prettier-plugin-organize-imports": "*", "prettier-plugin-sort-imports": "*", "prettier-plugin-style-order": "*", "prettier-plugin-svelte": "*" }, "optionalPeers": ["@ianvs/prettier-plugin-sort-imports", "@prettier/plugin-hermes", "@prettier/plugin-oxc", "@prettier/plugin-pug", "@shopify/prettier-plugin-liquid", "@trivago/prettier-plugin-sort-imports", "@zackad/prettier-plugin-twig", "prettier-plugin-astro", "prettier-plugin-css-order", "prettier-plugin-import-sort", "prettier-plugin-jsdoc", "prettier-plugin-marko", "prettier-plugin-multiline-arrays", "prettier-plugin-organize-attributes", "prettier-plugin-sort-imports", "prettier-plugin-style-order", "prettier-plugin-svelte"] }, "sha512-pi2e/+ZygeIqntN+vC573BcW5Cve8zUB0SSAGxqpB4f96boZF4M3phPVoOFCeypwkpRYdi7+jQ5YJJUwrkGUAg=="], + + "prop-types": ["prop-types@15.8.1", "https://registry.npmmirror.com/prop-types/-/prop-types-15.8.1.tgz", { "dependencies": { "loose-envify": "^1.4.0", "object-assign": "^4.1.1", "react-is": "^16.13.1" } }, "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg=="], + + "proxy-from-env": ["proxy-from-env@2.1.0", "https://registry.npmmirror.com/proxy-from-env/-/proxy-from-env-2.1.0.tgz", {}, "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA=="], + + "punycode": ["punycode@2.3.1", "https://registry.npmmirror.com/punycode/-/punycode-2.3.1.tgz", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="], + + "queue-microtask": ["queue-microtask@1.2.3", "https://registry.npmmirror.com/queue-microtask/-/queue-microtask-1.2.3.tgz", {}, "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A=="], + + "react": ["react@19.2.5", "", {}, "sha512-llUJLzz1zTUBrskt2pwZgLq59AemifIftw4aB7JxOqf1HY2FDaGDxgwpAPVzHU1kdWabH7FauP4i1oEeer2WCA=="], + + "react-day-picker": ["react-day-picker@9.14.0", "https://registry.npmmirror.com/react-day-picker/-/react-day-picker-9.14.0.tgz", { "dependencies": { "@date-fns/tz": "^1.4.1", "@tabby_ai/hijri-converter": "1.0.5", "date-fns": "^4.1.0", "date-fns-jalali": "4.1.0-0" }, "peerDependencies": { "react": ">=16.8.0" } }, "sha512-tBaoDWjPwe0M5pGrum4H0SR6Lyk+BO9oHnp9JbKpGKW2mlraNPgP9BMfsg5pWpwrssARmeqk7YBl2oXutZTaHA=="], + + "react-dom": ["react-dom@19.2.5", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.5" } }, "sha512-J5bAZz+DXMMwW/wV3xzKke59Af6CHY7G4uYLN1OvBcKEsWOs4pQExj86BBKamxl/Ik5bx9whOrvBlSDfWzgSag=="], + + "react-hook-form": ["react-hook-form@7.62.0", "https://registry.npmmirror.com/react-hook-form/-/react-hook-form-7.62.0.tgz", { "peerDependencies": { "react": "^16.8.0 || ^17 || ^18 || ^19" } }, "sha512-7KWFejc98xqG/F4bAxpL41NB3o1nnvQO1RWZT3TqRZYL8RryQETGfEdVnJN2fy1crCiBLLjkRBVK05j24FxJGA=="], + + "react-is": ["react-is@16.13.1", "https://registry.npmmirror.com/react-is/-/react-is-16.13.1.tgz", {}, "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="], + + "react-medium-image-zoom": ["react-medium-image-zoom@5.3.0", "https://registry.npmmirror.com/react-medium-image-zoom/-/react-medium-image-zoom-5.3.0.tgz", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-RCIzVlsKqy3BYgGgYbolUfuvx0aSKC7YhX/IJGEp+WJxsqdIVYJHkBdj++FAj6VD7RiWj6VVmdCfa/9vJE9hZg=="], + + "react-remove-scroll": ["react-remove-scroll@2.7.2", "https://registry.npmmirror.com/react-remove-scroll/-/react-remove-scroll-2.7.2.tgz", { "dependencies": { "react-remove-scroll-bar": "^2.3.7", "react-style-singleton": "^2.2.3", "tslib": "^2.1.0", "use-callback-ref": "^1.3.3", "use-sidecar": "^1.1.3" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Iqb9NjCCTt6Hf+vOdNIZGdTiH1QSqr27H/Ek9sv/a97gfueI/5h1s3yRi1nngzMUaOOToin5dI1dXKdXiF+u0Q=="], + + "react-remove-scroll-bar": ["react-remove-scroll-bar@2.3.8", "https://registry.npmmirror.com/react-remove-scroll-bar/-/react-remove-scroll-bar-2.3.8.tgz", { "dependencies": { "react-style-singleton": "^2.2.2", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" }, "optionalPeers": ["@types/react"] }, "sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q=="], + + "react-style-singleton": ["react-style-singleton@2.2.3", "https://registry.npmmirror.com/react-style-singleton/-/react-style-singleton-2.2.3.tgz", { "dependencies": { "get-nonce": "^1.0.0", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ=="], + + "reflect.getprototypeof": ["reflect.getprototypeof@1.0.10", "https://registry.npmmirror.com/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz", { "dependencies": { "call-bind": "^1.0.8", "define-properties": "^1.2.1", "es-abstract": "^1.23.9", "es-errors": "^1.3.0", "es-object-atoms": "^1.0.0", "get-intrinsic": "^1.2.7", "get-proto": "^1.0.1", "which-builtin-type": "^1.2.1" } }, "sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw=="], + + "reftools": ["reftools@1.1.9", "https://registry.npmmirror.com/reftools/-/reftools-1.1.9.tgz", {}, "sha512-OVede/NQE13xBQ+ob5CKd5KyeJYU2YInb1bmV4nRoOfquZPkAkxuOXicSe1PvqIuZZ4kD13sPKBbR7UFDmli6w=="], + + "regexp.prototype.flags": ["regexp.prototype.flags@1.5.4", "https://registry.npmmirror.com/regexp.prototype.flags/-/regexp.prototype.flags-1.5.4.tgz", { "dependencies": { "call-bind": "^1.0.8", "define-properties": "^1.2.1", "es-errors": "^1.3.0", "get-proto": "^1.0.1", "gopd": "^1.2.0", "set-function-name": "^2.0.2" } }, "sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA=="], + + "require-directory": ["require-directory@2.1.1", "https://registry.npmmirror.com/require-directory/-/require-directory-2.1.1.tgz", {}, "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q=="], + + "reserved-words": ["reserved-words@0.1.2", "https://registry.npmmirror.com/reserved-words/-/reserved-words-0.1.2.tgz", {}, "sha512-0S5SrIUJ9LfpbVl4Yzij6VipUdafHrOTzvmfazSw/jeZrZtQK303OPZW+obtkaw7jQlTQppy0UvZWm9872PbRw=="], + + "resolve": ["resolve@1.22.10", "https://registry.npmmirror.com/resolve/-/resolve-1.22.10.tgz", { "dependencies": { "is-core-module": "^2.16.0", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, "bin": "bin/resolve" }, "sha512-NPRy+/ncIMeDlTAsuqwKIiferiawhefFJtkNSW0qZJEqMEb+qBt/77B/jGeeek+F0uOeN05CDa6HXbbIgtVX4w=="], + + "resolve-from": ["resolve-from@4.0.0", "https://registry.npmmirror.com/resolve-from/-/resolve-from-4.0.0.tgz", {}, "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g=="], + + "resolve-pkg-maps": ["resolve-pkg-maps@1.0.0", "https://registry.npmmirror.com/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", {}, "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw=="], + + "reusify": ["reusify@1.1.0", "https://registry.npmmirror.com/reusify/-/reusify-1.1.0.tgz", {}, "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw=="], + + "rimraf": ["rimraf@3.0.2", "https://registry.npmmirror.com/rimraf/-/rimraf-3.0.2.tgz", { "dependencies": { "glob": "^7.1.3" }, "bin": "bin.js" }, "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA=="], + + "run-parallel": ["run-parallel@1.2.0", "https://registry.npmmirror.com/run-parallel/-/run-parallel-1.2.0.tgz", { "dependencies": { "queue-microtask": "^1.2.2" } }, "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA=="], + + "safe-array-concat": ["safe-array-concat@1.1.3", "https://registry.npmmirror.com/safe-array-concat/-/safe-array-concat-1.1.3.tgz", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.2", "get-intrinsic": "^1.2.6", "has-symbols": "^1.1.0", "isarray": "^2.0.5" } }, "sha512-AURm5f0jYEOydBj7VQlVvDrjeFgthDdEF5H1dP+6mNpoXOMo1quQqJ4wvJDyRZ9+pO3kGWoOdmV08cSv2aJV6Q=="], + + "safe-push-apply": ["safe-push-apply@1.0.0", "https://registry.npmmirror.com/safe-push-apply/-/safe-push-apply-1.0.0.tgz", { "dependencies": { "es-errors": "^1.3.0", "isarray": "^2.0.5" } }, "sha512-iKE9w/Z7xCzUMIZqdBsp6pEQvwuEebH4vdpjcDWnyzaI6yl6O9FHvVpmGelvEHNsoY6wGblkxR6Zty/h00WiSA=="], + + "safe-regex-test": ["safe-regex-test@1.1.0", "https://registry.npmmirror.com/safe-regex-test/-/safe-regex-test-1.1.0.tgz", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "is-regex": "^1.2.1" } }, "sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw=="], + + "scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="], + + "semver": ["semver@6.3.1", "https://registry.npmmirror.com/semver/-/semver-6.3.1.tgz", { "bin": "bin/semver.js" }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], + + "set-function-length": ["set-function-length@1.2.2", "https://registry.npmmirror.com/set-function-length/-/set-function-length-1.2.2.tgz", { "dependencies": { "define-data-property": "^1.1.4", "es-errors": "^1.3.0", "function-bind": "^1.1.2", "get-intrinsic": "^1.2.4", "gopd": "^1.0.1", "has-property-descriptors": "^1.0.2" } }, "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg=="], + + "set-function-name": ["set-function-name@2.0.2", "https://registry.npmmirror.com/set-function-name/-/set-function-name-2.0.2.tgz", { "dependencies": { "define-data-property": "^1.1.4", "es-errors": "^1.3.0", "functions-have-names": "^1.2.3", "has-property-descriptors": "^1.0.2" } }, "sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ=="], + + "set-proto": ["set-proto@1.0.0", "https://registry.npmmirror.com/set-proto/-/set-proto-1.0.0.tgz", { "dependencies": { "dunder-proto": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.0.0" } }, "sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw=="], + + "sharp": ["sharp@0.34.5", "", { "dependencies": { "@img/colour": "^1.0.0", "detect-libc": "^2.1.2", "semver": "^7.7.3" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "0.34.5", "@img/sharp-darwin-x64": "0.34.5", "@img/sharp-libvips-darwin-arm64": "1.2.4", "@img/sharp-libvips-darwin-x64": "1.2.4", "@img/sharp-libvips-linux-arm": "1.2.4", "@img/sharp-libvips-linux-arm64": "1.2.4", "@img/sharp-libvips-linux-ppc64": "1.2.4", "@img/sharp-libvips-linux-riscv64": "1.2.4", "@img/sharp-libvips-linux-s390x": "1.2.4", "@img/sharp-libvips-linux-x64": "1.2.4", "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", "@img/sharp-libvips-linuxmusl-x64": "1.2.4", "@img/sharp-linux-arm": "0.34.5", "@img/sharp-linux-arm64": "0.34.5", "@img/sharp-linux-ppc64": "0.34.5", "@img/sharp-linux-riscv64": "0.34.5", "@img/sharp-linux-s390x": "0.34.5", "@img/sharp-linux-x64": "0.34.5", "@img/sharp-linuxmusl-arm64": "0.34.5", "@img/sharp-linuxmusl-x64": "0.34.5", "@img/sharp-wasm32": "0.34.5", "@img/sharp-win32-arm64": "0.34.5", "@img/sharp-win32-ia32": "0.34.5", "@img/sharp-win32-x64": "0.34.5" } }, "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg=="], + + "shebang-command": ["shebang-command@2.0.0", "https://registry.npmmirror.com/shebang-command/-/shebang-command-2.0.0.tgz", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="], + + "shebang-regex": ["shebang-regex@3.0.0", "https://registry.npmmirror.com/shebang-regex/-/shebang-regex-3.0.0.tgz", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="], + + "should": ["should@13.2.3", "https://registry.npmmirror.com/should/-/should-13.2.3.tgz", { "dependencies": { "should-equal": "^2.0.0", "should-format": "^3.0.3", "should-type": "^1.4.0", "should-type-adaptors": "^1.0.1", "should-util": "^1.0.0" } }, "sha512-ggLesLtu2xp+ZxI+ysJTmNjh2U0TsC+rQ/pfED9bUZZ4DKefP27D+7YJVVTvKsmjLpIi9jAa7itwDGkDDmt1GQ=="], + + "should-equal": ["should-equal@2.0.0", "https://registry.npmmirror.com/should-equal/-/should-equal-2.0.0.tgz", { "dependencies": { "should-type": "^1.4.0" } }, "sha512-ZP36TMrK9euEuWQYBig9W55WPC7uo37qzAEmbjHz4gfyuXrEUgF8cUvQVO+w+d3OMfPvSRQJ22lSm8MQJ43LTA=="], + + "should-format": ["should-format@3.0.3", "https://registry.npmmirror.com/should-format/-/should-format-3.0.3.tgz", { "dependencies": { "should-type": "^1.3.0", "should-type-adaptors": "^1.0.1" } }, "sha512-hZ58adtulAk0gKtua7QxevgUaXTTXxIi8t41L3zo9AHvjXO1/7sdLECuHeIN2SRtYXpNkmhoUP2pdeWgricQ+Q=="], + + "should-type": ["should-type@1.4.0", "https://registry.npmmirror.com/should-type/-/should-type-1.4.0.tgz", {}, "sha512-MdAsTu3n25yDbIe1NeN69G4n6mUnJGtSJHygX3+oN0ZbO3DTiATnf7XnYJdGT42JCXurTb1JI0qOBR65shvhPQ=="], + + "should-type-adaptors": ["should-type-adaptors@1.1.0", "https://registry.npmmirror.com/should-type-adaptors/-/should-type-adaptors-1.1.0.tgz", { "dependencies": { "should-type": "^1.3.0", "should-util": "^1.0.0" } }, "sha512-JA4hdoLnN+kebEp2Vs8eBe9g7uy0zbRo+RMcU0EsNy+R+k049Ki+N5tT5Jagst2g7EAja+euFuoXFCa8vIklfA=="], + + "should-util": ["should-util@1.0.1", "https://registry.npmmirror.com/should-util/-/should-util-1.0.1.tgz", {}, "sha512-oXF8tfxx5cDk8r2kYqlkUJzZpDBqVY/II2WhvU0n9Y3XYvAYRmeaf1PvvIvTgPnv4KJ+ES5M0PyDq5Jp+Ygy2g=="], + + "side-channel": ["side-channel@1.1.0", "https://registry.npmmirror.com/side-channel/-/side-channel-1.1.0.tgz", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.3", "side-channel-list": "^1.0.0", "side-channel-map": "^1.0.1", "side-channel-weakmap": "^1.0.2" } }, "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw=="], + + "side-channel-list": ["side-channel-list@1.0.0", "https://registry.npmmirror.com/side-channel-list/-/side-channel-list-1.0.0.tgz", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.3" } }, "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA=="], + + "side-channel-map": ["side-channel-map@1.0.1", "https://registry.npmmirror.com/side-channel-map/-/side-channel-map-1.0.1.tgz", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3" } }, "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA=="], + + "side-channel-weakmap": ["side-channel-weakmap@1.0.2", "https://registry.npmmirror.com/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3", "side-channel-map": "^1.0.1" } }, "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A=="], + + "sonner": ["sonner@2.0.6", "https://registry.npmmirror.com/sonner/-/sonner-2.0.6.tgz", { "peerDependencies": { "react": "^18.0.0 || ^19.0.0 || ^19.0.0-rc", "react-dom": "^18.0.0 || ^19.0.0 || ^19.0.0-rc" } }, "sha512-yHFhk8T/DK3YxjFQXIrcHT1rGEeTLliVzWbO0xN8GberVun2RiBnxAjXAYpZrqwEVHBG9asI/Li8TAAhN9m59Q=="], + + "source-map-js": ["source-map-js@1.2.1", "https://registry.npmmirror.com/source-map-js/-/source-map-js-1.2.1.tgz", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="], + + "stable-hash": ["stable-hash@0.0.5", "https://registry.npmmirror.com/stable-hash/-/stable-hash-0.0.5.tgz", {}, "sha512-+L3ccpzibovGXFK+Ap/f8LOS0ahMrHTf3xu7mMLSpEGU0EO9ucaysSylKo9eRDFNhWve/y275iPmIZ4z39a9iA=="], + + "stop-iteration-iterator": ["stop-iteration-iterator@1.1.0", "https://registry.npmmirror.com/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz", { "dependencies": { "es-errors": "^1.3.0", "internal-slot": "^1.1.0" } }, "sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ=="], + + "string-width": ["string-width@4.2.3", "https://registry.npmmirror.com/string-width/-/string-width-4.2.3.tgz", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], + + "string.prototype.includes": ["string.prototype.includes@2.0.1", "https://registry.npmmirror.com/string.prototype.includes/-/string.prototype.includes-2.0.1.tgz", { "dependencies": { "call-bind": "^1.0.7", "define-properties": "^1.2.1", "es-abstract": "^1.23.3" } }, "sha512-o7+c9bW6zpAdJHTtujeePODAhkuicdAryFsfVKwA+wGw89wJ4GTY484WTucM9hLtDEOpOvI+aHnzqnC5lHp4Rg=="], + + "string.prototype.matchall": ["string.prototype.matchall@4.0.12", "https://registry.npmmirror.com/string.prototype.matchall/-/string.prototype.matchall-4.0.12.tgz", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.3", "define-properties": "^1.2.1", "es-abstract": "^1.23.6", "es-errors": "^1.3.0", "es-object-atoms": "^1.0.0", "get-intrinsic": "^1.2.6", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "internal-slot": "^1.1.0", "regexp.prototype.flags": "^1.5.3", "set-function-name": "^2.0.2", "side-channel": "^1.1.0" } }, "sha512-6CC9uyBL+/48dYizRf7H7VAYCMCNTBeM78x/VTUe9bFEaxBepPJDa1Ow99LqI/1yF7kuy7Q3cQsYMrcjGUcskA=="], + + "string.prototype.repeat": ["string.prototype.repeat@1.0.0", "https://registry.npmmirror.com/string.prototype.repeat/-/string.prototype.repeat-1.0.0.tgz", { "dependencies": { "define-properties": "^1.1.3", "es-abstract": "^1.17.5" } }, "sha512-0u/TldDbKD8bFCQ/4f5+mNRrXwZ8hg2w7ZR8wa16e8z9XpePWl3eGEcUD0OXpEH/VJH/2G3gjUtR3ZOiBe2S/w=="], + + "string.prototype.trim": ["string.prototype.trim@1.2.10", "https://registry.npmmirror.com/string.prototype.trim/-/string.prototype.trim-1.2.10.tgz", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.2", "define-data-property": "^1.1.4", "define-properties": "^1.2.1", "es-abstract": "^1.23.5", "es-object-atoms": "^1.0.0", "has-property-descriptors": "^1.0.2" } }, "sha512-Rs66F0P/1kedk5lyYyH9uBzuiI/kNRmwJAR9quK6VOtIpZ2G+hMZd+HQbbv25MgCA6gEffoMZYxlTod4WcdrKA=="], + + "string.prototype.trimend": ["string.prototype.trimend@1.0.9", "https://registry.npmmirror.com/string.prototype.trimend/-/string.prototype.trimend-1.0.9.tgz", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.2", "define-properties": "^1.2.1", "es-object-atoms": "^1.0.0" } }, "sha512-G7Ok5C6E/j4SGfyLCloXTrngQIQU3PWtXGst3yM7Bea9FRURf1S42ZHlZZtsNque2FN2PoUhfZXYLNWwEr4dLQ=="], + + "string.prototype.trimstart": ["string.prototype.trimstart@1.0.8", "https://registry.npmmirror.com/string.prototype.trimstart/-/string.prototype.trimstart-1.0.8.tgz", { "dependencies": { "call-bind": "^1.0.7", "define-properties": "^1.2.1", "es-object-atoms": "^1.0.0" } }, "sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg=="], + + "strip-ansi": ["strip-ansi@6.0.1", "https://registry.npmmirror.com/strip-ansi/-/strip-ansi-6.0.1.tgz", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], + + "strip-bom": ["strip-bom@3.0.0", "https://registry.npmmirror.com/strip-bom/-/strip-bom-3.0.0.tgz", {}, "sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA=="], + + "strip-json-comments": ["strip-json-comments@3.1.1", "https://registry.npmmirror.com/strip-json-comments/-/strip-json-comments-3.1.1.tgz", {}, "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig=="], + + "styled-jsx": ["styled-jsx@5.1.6", "https://registry.npmmirror.com/styled-jsx/-/styled-jsx-5.1.6.tgz", { "dependencies": { "client-only": "0.0.1" }, "peerDependencies": { "react": ">= 16.8.0 || 17.x.x || ^18.0.0-0 || ^19.0.0-0" } }, "sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA=="], + + "supports-color": ["supports-color@7.2.0", "https://registry.npmmirror.com/supports-color/-/supports-color-7.2.0.tgz", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="], + + "supports-preserve-symlinks-flag": ["supports-preserve-symlinks-flag@1.0.0", "https://registry.npmmirror.com/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", {}, "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w=="], + + "swagger2openapi": ["swagger2openapi@7.0.8", "https://registry.npmmirror.com/swagger2openapi/-/swagger2openapi-7.0.8.tgz", { "dependencies": { "call-me-maybe": "^1.0.1", "node-fetch": "^2.6.1", "node-fetch-h2": "^2.3.0", "node-readfiles": "^0.2.0", "oas-kit-common": "^1.0.8", "oas-resolver": "^2.5.6", "oas-schema-walker": "^1.1.5", "oas-validator": "^5.0.8", "reftools": "^1.1.9", "yaml": "^1.10.0", "yargs": "^17.0.1" }, "bin": { "boast": "boast.js", "oas-validate": "oas-validate.js", "swagger2openapi": "swagger2openapi.js" } }, "sha512-upi/0ZGkYgEcLeGieoz8gT74oWHA0E7JivX7aN9mAf+Tc7BQoRBvnIGHoPDw+f9TXTW4s6kGYCZJtauP6OYp7g=="], + + "tailwind-merge": ["tailwind-merge@3.3.1", "https://registry.npmmirror.com/tailwind-merge/-/tailwind-merge-3.3.1.tgz", {}, "sha512-gBXpgUm/3rp1lMZZrM/w7D8GKqshif0zAymAhbCyIt8KMe+0v9DQ7cdYLR4FHH/cKpdTXb+A/tKKU3eolfsI+g=="], + + "tailwindcss": ["tailwindcss@4.1.10", "https://registry.npmmirror.com/tailwindcss/-/tailwindcss-4.1.10.tgz", {}, "sha512-P3nr6WkvKV/ONsTzj6Gb57sWPMX29EPNPopo7+FcpkQaNsrNpZ1pv8QmrYI2RqEKD7mlGqLnGovlcYnBK0IqUA=="], + + "tapable": ["tapable@2.2.2", "https://registry.npmmirror.com/tapable/-/tapable-2.2.2.tgz", {}, "sha512-Re10+NauLTMCudc7T5WLFLAwDhQ0JWdrMK+9B2M8zR5hRExKmsRDCBA7/aV/pNJFltmBFO5BAMlQFi/vq3nKOg=="], + + "tar": ["tar@7.4.3", "https://registry.npmmirror.com/tar/-/tar-7.4.3.tgz", { "dependencies": { "@isaacs/fs-minipass": "^4.0.0", "chownr": "^3.0.0", "minipass": "^7.1.2", "minizlib": "^3.0.1", "mkdirp": "^3.0.1", "yallist": "^5.0.0" } }, "sha512-5S7Va8hKfV7W5U6g3aYxXmlPoZVAwUMy9AOKyF2fVuZa2UD3qZjg578OrLRt8PcNN1PleVaL/5/yYATNL0ICUw=="], + + "timers-ext": ["timers-ext@0.1.8", "https://registry.npmmirror.com/timers-ext/-/timers-ext-0.1.8.tgz", { "dependencies": { "es5-ext": "^0.10.64", "next-tick": "^1.1.0" } }, "sha512-wFH7+SEAcKfJpfLPkrgMPvvwnEtj8W4IurvEyrKsDleXnKLCDw71w8jltvfLa8Rm4qQxxT4jmDBYbJG/z7qoww=="], + + "tiny-pinyin": ["tiny-pinyin@1.3.2", "https://registry.npmmirror.com/tiny-pinyin/-/tiny-pinyin-1.3.2.tgz", {}, "sha512-uHNGu4evFt/8eNLldazeAM1M8JrMc1jshhJJfVRARTN3yT8HEEibofeQ7QETWQ5ISBjd6fKtTVBCC/+mGS6FpA=="], + + "tinyglobby": ["tinyglobby@0.2.16", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg=="], + + "to-regex-range": ["to-regex-range@5.0.1", "https://registry.npmmirror.com/to-regex-range/-/to-regex-range-5.0.1.tgz", { "dependencies": { "is-number": "^7.0.0" } }, "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ=="], + + "tr46": ["tr46@0.0.3", "https://registry.npmmirror.com/tr46/-/tr46-0.0.3.tgz", {}, "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw=="], + + "ts-api-utils": ["ts-api-utils@2.5.0", "", { "peerDependencies": { "typescript": ">=4.8.4" } }, "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA=="], + + "tsconfig-paths": ["tsconfig-paths@3.15.0", "https://registry.npmmirror.com/tsconfig-paths/-/tsconfig-paths-3.15.0.tgz", { "dependencies": { "@types/json5": "^0.0.29", "json5": "^1.0.2", "minimist": "^1.2.6", "strip-bom": "^3.0.0" } }, "sha512-2Ac2RgzDe/cn48GvOe3M+o82pEFewD3UPbyoUHHdKasHwJKjds4fLXWf/Ux5kATBKN20oaFGu+jbElp1pos0mg=="], + + "tslib": ["tslib@2.8.1", "https://registry.npmmirror.com/tslib/-/tslib-2.8.1.tgz", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + + "tw-animate-css": ["tw-animate-css@1.3.4", "https://registry.npmmirror.com/tw-animate-css/-/tw-animate-css-1.3.4.tgz", {}, "sha512-dd1Ht6/YQHcNbq0znIT6dG8uhO7Ce+VIIhZUhjsryXsMPJQz3bZg7Q2eNzLwipb25bRZslGb2myio5mScd1TFg=="], + + "type": ["type@2.7.3", "https://registry.npmmirror.com/type/-/type-2.7.3.tgz", {}, "sha512-8j+1QmAbPvLZow5Qpi6NCaN8FB60p/6x8/vfNqOk/hC+HuvFZhL4+WfekuhQLiqFZXOgQdrs3B+XxEmCc6b3FQ=="], + + "type-check": ["type-check@0.4.0", "https://registry.npmmirror.com/type-check/-/type-check-0.4.0.tgz", { "dependencies": { "prelude-ls": "^1.2.1" } }, "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew=="], + + "typed-array-buffer": ["typed-array-buffer@1.0.3", "https://registry.npmmirror.com/typed-array-buffer/-/typed-array-buffer-1.0.3.tgz", { "dependencies": { "call-bound": "^1.0.3", "es-errors": "^1.3.0", "is-typed-array": "^1.1.14" } }, "sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw=="], + + "typed-array-byte-length": ["typed-array-byte-length@1.0.3", "https://registry.npmmirror.com/typed-array-byte-length/-/typed-array-byte-length-1.0.3.tgz", { "dependencies": { "call-bind": "^1.0.8", "for-each": "^0.3.3", "gopd": "^1.2.0", "has-proto": "^1.2.0", "is-typed-array": "^1.1.14" } }, "sha512-BaXgOuIxz8n8pIq3e7Atg/7s+DpiYrxn4vdot3w9KbnBhcRQq6o3xemQdIfynqSeXeDrF32x+WvfzmOjPiY9lg=="], + + "typed-array-byte-offset": ["typed-array-byte-offset@1.0.4", "https://registry.npmmirror.com/typed-array-byte-offset/-/typed-array-byte-offset-1.0.4.tgz", { "dependencies": { "available-typed-arrays": "^1.0.7", "call-bind": "^1.0.8", "for-each": "^0.3.3", "gopd": "^1.2.0", "has-proto": "^1.2.0", "is-typed-array": "^1.1.15", "reflect.getprototypeof": "^1.0.9" } }, "sha512-bTlAFB/FBYMcuX81gbL4OcpH5PmlFHqlCCpAl8AlEzMz5k53oNDvN8p1PNOWLEmI2x4orp3raOFB51tv9X+MFQ=="], + + "typed-array-length": ["typed-array-length@1.0.7", "https://registry.npmmirror.com/typed-array-length/-/typed-array-length-1.0.7.tgz", { "dependencies": { "call-bind": "^1.0.7", "for-each": "^0.3.3", "gopd": "^1.0.1", "is-typed-array": "^1.1.13", "possible-typed-array-names": "^1.0.0", "reflect.getprototypeof": "^1.0.6" } }, "sha512-3KS2b+kL7fsuk/eJZ7EQdnEmQoaho/r6KUef7hxvltNA5DR8NAUM+8wJMbJyZ4G9/7i3v5zPBIMN5aybAh2/Jg=="], + + "typescript": ["typescript@5.8.3", "https://registry.npmmirror.com/typescript/-/typescript-5.8.3.tgz", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ=="], + + "typescript-eslint": ["typescript-eslint@8.58.2", "", { "dependencies": { "@typescript-eslint/eslint-plugin": "8.58.2", "@typescript-eslint/parser": "8.58.2", "@typescript-eslint/typescript-estree": "8.58.2", "@typescript-eslint/utils": "8.58.2" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-V8iSng9mRbdZjl54VJ9NKr6ZB+dW0J3TzRXRGcSbLIej9jV86ZRtlYeTKDR/QLxXykocJ5icNzbsl2+5TzIvcQ=="], + + "unbox-primitive": ["unbox-primitive@1.1.0", "https://registry.npmmirror.com/unbox-primitive/-/unbox-primitive-1.1.0.tgz", { "dependencies": { "call-bound": "^1.0.3", "has-bigints": "^1.0.2", "has-symbols": "^1.1.0", "which-boxed-primitive": "^1.1.1" } }, "sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw=="], + + "undici-types": ["undici-types@6.21.0", "https://registry.npmmirror.com/undici-types/-/undici-types-6.21.0.tgz", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], + + "unrs-resolver": ["unrs-resolver@1.9.2", "https://registry.npmmirror.com/unrs-resolver/-/unrs-resolver-1.9.2.tgz", { "dependencies": { "napi-postinstall": "^0.2.4" }, "optionalDependencies": { "@unrs/resolver-binding-android-arm-eabi": "1.9.2", "@unrs/resolver-binding-android-arm64": "1.9.2", "@unrs/resolver-binding-darwin-arm64": "1.9.2", "@unrs/resolver-binding-darwin-x64": "1.9.2", "@unrs/resolver-binding-freebsd-x64": "1.9.2", "@unrs/resolver-binding-linux-arm-gnueabihf": "1.9.2", "@unrs/resolver-binding-linux-arm-musleabihf": "1.9.2", "@unrs/resolver-binding-linux-arm64-gnu": "1.9.2", "@unrs/resolver-binding-linux-arm64-musl": "1.9.2", "@unrs/resolver-binding-linux-ppc64-gnu": "1.9.2", "@unrs/resolver-binding-linux-riscv64-gnu": "1.9.2", "@unrs/resolver-binding-linux-riscv64-musl": "1.9.2", "@unrs/resolver-binding-linux-s390x-gnu": "1.9.2", "@unrs/resolver-binding-linux-x64-gnu": "1.9.2", "@unrs/resolver-binding-linux-x64-musl": "1.9.2", "@unrs/resolver-binding-wasm32-wasi": "1.9.2", "@unrs/resolver-binding-win32-arm64-msvc": "1.9.2", "@unrs/resolver-binding-win32-ia32-msvc": "1.9.2", "@unrs/resolver-binding-win32-x64-msvc": "1.9.2" } }, "sha512-VUyWiTNQD7itdiMuJy+EuLEErLj3uwX/EpHQF8EOf33Dq3Ju6VW1GXm+swk6+1h7a49uv9fKZ+dft9jU7esdLA=="], + + "update-browserslist-db": ["update-browserslist-db@1.2.3", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": "cli.js" }, "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w=="], + + "uri-js": ["uri-js@4.4.1", "https://registry.npmmirror.com/uri-js/-/uri-js-4.4.1.tgz", { "dependencies": { "punycode": "^2.1.0" } }, "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg=="], + + "use-callback-ref": ["use-callback-ref@1.3.3", "https://registry.npmmirror.com/use-callback-ref/-/use-callback-ref-1.3.3.tgz", { "dependencies": { "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg=="], + + "use-sidecar": ["use-sidecar@1.1.3", "https://registry.npmmirror.com/use-sidecar/-/use-sidecar-1.1.3.tgz", { "dependencies": { "detect-node-es": "^1.1.0", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ=="], + + "webidl-conversions": ["webidl-conversions@3.0.1", "https://registry.npmmirror.com/webidl-conversions/-/webidl-conversions-3.0.1.tgz", {}, "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ=="], + + "whatwg-url": ["whatwg-url@5.0.0", "https://registry.npmmirror.com/whatwg-url/-/whatwg-url-5.0.0.tgz", { "dependencies": { "tr46": "~0.0.3", "webidl-conversions": "^3.0.0" } }, "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw=="], + + "which": ["which@2.0.2", "https://registry.npmmirror.com/which/-/which-2.0.2.tgz", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], + + "which-boxed-primitive": ["which-boxed-primitive@1.1.1", "https://registry.npmmirror.com/which-boxed-primitive/-/which-boxed-primitive-1.1.1.tgz", { "dependencies": { "is-bigint": "^1.1.0", "is-boolean-object": "^1.2.1", "is-number-object": "^1.1.1", "is-string": "^1.1.1", "is-symbol": "^1.1.1" } }, "sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA=="], + + "which-builtin-type": ["which-builtin-type@1.2.1", "https://registry.npmmirror.com/which-builtin-type/-/which-builtin-type-1.2.1.tgz", { "dependencies": { "call-bound": "^1.0.2", "function.prototype.name": "^1.1.6", "has-tostringtag": "^1.0.2", "is-async-function": "^2.0.0", "is-date-object": "^1.1.0", "is-finalizationregistry": "^1.1.0", "is-generator-function": "^1.0.10", "is-regex": "^1.2.1", "is-weakref": "^1.0.2", "isarray": "^2.0.5", "which-boxed-primitive": "^1.1.0", "which-collection": "^1.0.2", "which-typed-array": "^1.1.16" } }, "sha512-6iBczoX+kDQ7a3+YJBnh3T+KZRxM/iYNPXicqk66/Qfm1b93iu+yOImkg0zHbj5LNOcNv1TEADiZ0xa34B4q6Q=="], + + "which-collection": ["which-collection@1.0.2", "https://registry.npmmirror.com/which-collection/-/which-collection-1.0.2.tgz", { "dependencies": { "is-map": "^2.0.3", "is-set": "^2.0.3", "is-weakmap": "^2.0.2", "is-weakset": "^2.0.3" } }, "sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw=="], + + "which-typed-array": ["which-typed-array@1.1.19", "https://registry.npmmirror.com/which-typed-array/-/which-typed-array-1.1.19.tgz", { "dependencies": { "available-typed-arrays": "^1.0.7", "call-bind": "^1.0.8", "call-bound": "^1.0.4", "for-each": "^0.3.5", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-tostringtag": "^1.0.2" } }, "sha512-rEvr90Bck4WZt9HHFC4DJMsjvu7x+r6bImz0/BrbWb7A2djJ8hnZMrWnHo9F8ssv0OMErasDhftrfROTyqSDrw=="], + + "word-wrap": ["word-wrap@1.2.5", "https://registry.npmmirror.com/word-wrap/-/word-wrap-1.2.5.tgz", {}, "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA=="], + + "wrap-ansi": ["wrap-ansi@7.0.0", "https://registry.npmmirror.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="], + + "wrappy": ["wrappy@1.0.2", "https://registry.npmmirror.com/wrappy/-/wrappy-1.0.2.tgz", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="], + + "y18n": ["y18n@5.0.8", "https://registry.npmmirror.com/y18n/-/y18n-5.0.8.tgz", {}, "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA=="], + + "yallist": ["yallist@5.0.0", "https://registry.npmmirror.com/yallist/-/yallist-5.0.0.tgz", {}, "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw=="], + + "yaml": ["yaml@1.10.2", "https://registry.npmmirror.com/yaml/-/yaml-1.10.2.tgz", {}, "sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg=="], + + "yargs": ["yargs@17.7.2", "https://registry.npmmirror.com/yargs/-/yargs-17.7.2.tgz", { "dependencies": { "cliui": "^8.0.1", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", "require-directory": "^2.1.1", "string-width": "^4.2.3", "y18n": "^5.0.5", "yargs-parser": "^21.1.1" } }, "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w=="], + + "yargs-parser": ["yargs-parser@21.1.1", "https://registry.npmmirror.com/yargs-parser/-/yargs-parser-21.1.1.tgz", {}, "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw=="], + + "yocto-queue": ["yocto-queue@0.1.0", "https://registry.npmmirror.com/yocto-queue/-/yocto-queue-0.1.0.tgz", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="], + + "zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="], + + "zod-validation-error": ["zod-validation-error@4.0.2", "", { "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" } }, "sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ=="], + + "zustand": ["zustand@5.0.8", "https://registry.npmmirror.com/zustand/-/zustand-5.0.8.tgz", { "peerDependencies": { "@types/react": ">=18.0.0", "immer": ">=9.0.6", "react": ">=18.0.0", "use-sync-external-store": ">=1.2.0" }, "optionalPeers": ["use-sync-external-store"] }, "sha512-gyPKpIaxY9XcO2vSMrLbiER7QMAMGOQZVRdJ6Zi782jkbzZygq5GI9nG8g+sMgitRtndwaBSl7uiqC49o1SSiw=="], + + "@babel/core/json5": ["json5@2.2.3", "", { "bin": "lib/cli.js" }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="], + + "@eslint-community/eslint-utils/eslint-visitor-keys": ["eslint-visitor-keys@3.4.3", "https://registry.npmmirror.com/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", {}, "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="], + + "@eslint/plugin-kit/@eslint/core": ["@eslint/core@0.15.1", "https://registry.npmmirror.com/@eslint/core/-/core-0.15.1.tgz", { "dependencies": { "@types/json-schema": "^7.0.15" } }, "sha512-bkOp+iumZCCbt1K1CmWf0R9pM5yKpDv+ZXtvSyQpudrI9kuFLp+bM2WOPXImuD/ceQuaa8f5pj93Y7zyECIGNA=="], + + "@humanfs/node/@humanwhocodes/retry": ["@humanwhocodes/retry@0.3.1", "https://registry.npmmirror.com/@humanwhocodes/retry/-/retry-0.3.1.tgz", {}, "sha512-JBxkERygn7Bv/GbN5Rv8Ul6LVknS+5Bp6RgDC/O8gEBU/yeH5Ui5C/OlWrTb6qct7LjjfT6Re2NxB0ln0yYybA=="], + + "@typescript-eslint/eslint-plugin/ignore": ["ignore@7.0.5", "", {}, "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg=="], + + "@typescript-eslint/typescript-estree/minimatch": ["minimatch@10.2.5", "", { "dependencies": { "brace-expansion": "^5.0.5" } }, "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg=="], + + "@typescript-eslint/typescript-estree/semver": ["semver@7.7.4", "", { "bin": "bin/semver.js" }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="], + + "@typescript-eslint/visitor-keys/eslint-visitor-keys": ["eslint-visitor-keys@5.0.1", "", {}, "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA=="], + + "eslint-config-next/globals": ["globals@16.4.0", "", {}, "sha512-ob/2LcVVaVGCYN+r14cnwnoDPUufjiYgSqRhiFD0Q1iI4Odora5RE8Iv1D24hAz5oMophRGkGz+yuvQmmUMnMw=="], + + "eslint-import-resolver-node/debug": ["debug@3.2.7", "https://registry.npmmirror.com/debug/-/debug-3.2.7.tgz", { "dependencies": { "ms": "^2.1.1" } }, "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ=="], + + "eslint-module-utils/debug": ["debug@3.2.7", "https://registry.npmmirror.com/debug/-/debug-3.2.7.tgz", { "dependencies": { "ms": "^2.1.1" } }, "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ=="], + + "eslint-plugin-import/debug": ["debug@3.2.7", "https://registry.npmmirror.com/debug/-/debug-3.2.7.tgz", { "dependencies": { "ms": "^2.1.1" } }, "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ=="], + + "eslint-plugin-react/resolve": ["resolve@2.0.0-next.5", "https://registry.npmmirror.com/resolve/-/resolve-2.0.0-next.5.tgz", { "dependencies": { "is-core-module": "^2.13.0", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, "bin": "bin/resolve" }, "sha512-U7WjGVG9sH8tvjW5SmGbQuui75FiyjAX72HX15DwBBwF9dNiQZRQAg9nnPhYy+TUnE0+VcrttuvNI8oSxZcocA=="], + + "fast-glob/glob-parent": ["glob-parent@5.1.2", "https://registry.npmmirror.com/glob-parent/-/glob-parent-5.1.2.tgz", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="], + + "is-bun-module/semver": ["semver@7.7.4", "", { "bin": "bin/semver.js" }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="], + + "lru-cache/yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="], + + "micromatch/picomatch": ["picomatch@2.3.1", "https://registry.npmmirror.com/picomatch/-/picomatch-2.3.1.tgz", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="], + + "next/postcss": ["postcss@8.4.31", "https://registry.npmmirror.com/postcss/-/postcss-8.4.31.tgz", { "dependencies": { "nanoid": "^3.3.6", "picocolors": "^1.0.0", "source-map-js": "^1.0.2" } }, "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ=="], + + "nunjucks/commander": ["commander@5.1.0", "https://registry.npmmirror.com/commander/-/commander-5.1.0.tgz", {}, "sha512-P0CysNDQ7rtVw4QIQtm+MRxV66vKFSvlsQvGYXZWR3qFU0jlMKHZZZgw8e+8DSah4UDKMqnknRDQz+xuQXQ/Zg=="], + + "prettier-plugin-tailwindcss/prettier": ["prettier@3.6.2", "https://registry.npmmirror.com/prettier/-/prettier-3.6.2.tgz", { "bin": "bin/prettier.cjs" }, "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ=="], + + "sharp/semver": ["semver@7.7.4", "", { "bin": "bin/semver.js" }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="], + + "string-width/emoji-regex": ["emoji-regex@8.0.0", "https://registry.npmmirror.com/emoji-regex/-/emoji-regex-8.0.0.tgz", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], + + "@typescript-eslint/typescript-estree/minimatch/brace-expansion": ["brace-expansion@5.0.5", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ=="], + + "@typescript-eslint/typescript-estree/minimatch/brace-expansion/balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="], + } +} diff --git a/web/components.json b/web/components.json new file mode 100644 index 0000000000000000000000000000000000000000..2ad646365ee2d7b6edf69dadea868da75cfc51d8 --- /dev/null +++ b/web/components.json @@ -0,0 +1,24 @@ +{ + "$schema": "https://ui.shadcn.com/schema.json", + "style": "new-york", + "rsc": true, + "tsx": true, + "tailwind": { + "config": "", + "css": "src/app/globals.css", + "baseColor": "neutral", + "cssVariables": true, + "prefix": "" + }, + "iconLibrary": "lucide", + "aliases": { + "components": "@/components", + "utils": "@/lib/utils", + "ui": "@/components/ui", + "lib": "@/lib", + "hooks": "@/hooks" + }, + "registries": { + "@magicui": "https://magicui.design/r/{name}" + } +} diff --git a/web/eslint.config.mjs b/web/eslint.config.mjs new file mode 100644 index 0000000000000000000000000000000000000000..f9cc07d758e19e887a1b6ee3e3c559bd156864ef --- /dev/null +++ b/web/eslint.config.mjs @@ -0,0 +1,17 @@ +import nextCoreWebVitals from 'eslint-config-next/core-web-vitals'; +import nextTypescript from 'eslint-config-next/typescript'; +import prettier from 'eslint-config-prettier/flat'; + +const eslintConfig = [ + ...nextCoreWebVitals, + ...nextTypescript, + prettier, + { + rules: { + '@typescript-eslint/no-unused-vars': 'off', // 不检查未使用的变量 + '@typescript-eslint/no-explicit-any': 'off', // 关闭 any 报错 + }, + }, +]; + +export default eslintConfig; diff --git a/web/next.config.ts b/web/next.config.ts new file mode 100644 index 0000000000000000000000000000000000000000..99e63ef118a75bc558b4023919766c36b32611f7 --- /dev/null +++ b/web/next.config.ts @@ -0,0 +1,40 @@ +import { readFileSync } from 'node:fs' +import { dirname, join } from 'node:path' +import { fileURLToPath } from 'node:url' +import type { NextConfig } from 'next' +import { parseChangelog } from './src/lib/release' + +const projectRoot = join(dirname(fileURLToPath(import.meta.url)), '..') + +function readAppVersion() { + try { + const version = readFileSync(join(projectRoot, 'VERSION'), 'utf-8').trim() + return version || '0.0.0' + } catch { + return '0.0.0' + } +} + +const appVersion = process.env.NEXT_PUBLIC_APP_VERSION || readAppVersion() +let appReleases = '[]' +try { + appReleases = JSON.stringify(parseChangelog(readFileSync(join(projectRoot, 'CHANGELOG.md'), 'utf-8'))) +} catch {} + +const nextConfig: NextConfig = { + allowedDevOrigins: ['127.0.0.1'], + env: { + NEXT_PUBLIC_APP_VERSION: appVersion, + NEXT_PUBLIC_APP_RELEASES: appReleases, + }, + output: 'export', + trailingSlash: true, + images: { + unoptimized: true, + }, + typescript: { + ignoreBuildErrors: true, + }, +} + +export default nextConfig diff --git a/web/package.json b/web/package.json new file mode 100644 index 0000000000000000000000000000000000000000..9e521c9a46bfba2f45f39f0257994fa48e83ccc1 --- /dev/null +++ b/web/package.json @@ -0,0 +1,55 @@ +{ + "name": "next-starter", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "dev": "next dev --webpack -H 0.0.0.0", + "build": "next build", + "start": "next start" + }, + "dependencies": { + "@radix-ui/react-checkbox": "^1.3.3", + "@radix-ui/react-dialog": "^1.1.15", + "@radix-ui/react-popover": "^1.1.15", + "@radix-ui/react-select": "^2.2.6", + "@radix-ui/react-slot": "^1.2.3", + "axios": "^1.9.0", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "date-fns": "^4.1.0", + "immer": "^10.1.3", + "localforage": "^1.10.0", + "lucide-react": "^0.523.0", + "motion": "^12.38.0", + "next": "16.2.3", + "react": "19.2.5", + "react-day-picker": "^9.14.0", + "react-dom": "19.2.5", + "react-hook-form": "^7.62.0", + "react-medium-image-zoom": "^5.3.0", + "sonner": "^2.0.6", + "tailwind-merge": "^3.3.1", + "zustand": "^5.0.8" + }, + "devDependencies": { + "@eslint/eslintrc": "^3", + "@tailwindcss/postcss": "^4", + "@types/node": "^20", + "@types/react": "19.1.12", + "@types/react-dom": "19.1.9", + "@umijs/openapi": "^1.13.15", + "eslint": "^9", + "eslint-config-next": "16.2.3", + "eslint-config-prettier": "^10.1.8", + "prettier-plugin-organize-imports": "^4.2.0", + "prettier-plugin-tailwindcss": "^0.6.14", + "tailwindcss": "^4", + "tw-animate-css": "^1.3.4", + "typescript": "^5" + }, + "overrides": { + "@types/react": "19.1.12", + "@types/react-dom": "19.1.9" + } +} diff --git a/web/postcss.config.mjs b/web/postcss.config.mjs new file mode 100644 index 0000000000000000000000000000000000000000..e2b63c7953ce053b43e5f00d3a9b15157413915d --- /dev/null +++ b/web/postcss.config.mjs @@ -0,0 +1,5 @@ +const config = { + plugins: ['@tailwindcss/postcss'], +}; + +export default config; diff --git a/web/public/next.svg b/web/public/next.svg new file mode 100644 index 0000000000000000000000000000000000000000..5174b28c565c285e3e312ec5178be64fbeca8398 --- /dev/null +++ b/web/public/next.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg> \ No newline at end of file diff --git a/web/public/openai.svg b/web/public/openai.svg new file mode 100644 index 0000000000000000000000000000000000000000..78caf4fa20ffb0ae8ebefd74408871c11c1c7fd2 --- /dev/null +++ b/web/public/openai.svg @@ -0,0 +1 @@ +<svg fill="currentColor" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>OpenAI \ No newline at end of file diff --git a/web/public/vercel.svg b/web/public/vercel.svg new file mode 100644 index 0000000000000000000000000000000000000000..77053960334e2e34dc584dea8019925c3b4ccca9 --- /dev/null +++ b/web/public/vercel.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/web/src/app/accounts/components/account-import-dialog.tsx b/web/src/app/accounts/components/account-import-dialog.tsx new file mode 100644 index 0000000000000000000000000000000000000000..f0fdc5863e111e476b28b6beedd3ef83154ae98f --- /dev/null +++ b/web/src/app/accounts/components/account-import-dialog.tsx @@ -0,0 +1,899 @@ +"use client"; + +import { useRouter } from "next/navigation"; +import { useRef, useState, type ChangeEvent } from "react"; +import { + ArrowLeft, + Copy, + ExternalLink, + FileJson, + FileText, + Files, + KeyRound, + LoaderCircle, + LogIn, + ServerCog, + Upload, +} from "lucide-react"; +import { toast } from "sonner"; + +import { Button } from "@/components/ui/button"; +import { Card, CardContent } from "@/components/ui/card"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Textarea } from "@/components/ui/textarea"; +import { + createAccounts, + finishOAuthLogin, + startOAuthLogin, + type Account, + type AccountImportPayload, + type OAuthLoginStartResponse, +} from "@/lib/api"; +import { cn } from "@/lib/utils"; + +type ImportMethod = "menu" | "token" | "session" | "codex-auth" | "cpa" | "oauth"; + +type AccountImportDialogProps = { + disabled?: boolean; + onImported: (items: Account[]) => void; +}; + +type PendingCpaImport = { + tokens: string[]; + accounts: AccountImportPayload[]; + parsedFileCount: number; + errorCount: number; +}; + +const sessionUrl = "https://chatgpt.com/api/auth/session"; + +function splitTokens(value: string) { + return value + .split(/\r?\n/) + .map((item) => item.trim()) + .filter(Boolean); +} + +function getSessionAccessToken(value: unknown) { + const token = (value as { accessToken?: unknown })?.accessToken; + return typeof token === "string" ? token.trim() : ""; +} + +function getCpaAccount(value: unknown): AccountImportPayload | null { + if (!value || typeof value !== "object" || Array.isArray(value)) { + return null; + } + const raw = value as Record; + const tokenValue = raw.access_token ?? raw.accessToken; + const token = typeof tokenValue === "string" ? tokenValue.trim() : ""; + if (!token) { + return null; + } + + const payload: AccountImportPayload = { + ...raw, + access_token: token, + source_type: "codex", + }; + delete payload.accessToken; + if (payload.type === "codex") { + payload.export_type = "codex"; + delete payload.type; + } + return payload; +} + +function getCodexAuthAccount(value: unknown): AccountImportPayload | null { + if (!value || typeof value !== "object" || Array.isArray(value)) { + return null; + } + const raw = value as Record; + const tokenValue = raw.access_token ?? raw.accessToken; + const token = typeof tokenValue === "string" ? tokenValue.trim() : ""; + if (!token) { + return null; + } + + const payload: AccountImportPayload = { + ...raw, + access_token: token, + export_type: "codex", + source_type: "codex", + }; + delete payload.accessToken; + if (payload.type === "codex") { + delete payload.type; + } + return payload; +} + +function readFileAsText(file: File) { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => resolve(typeof reader.result === "string" ? reader.result : ""); + reader.onerror = () => reject(reader.error ?? new Error(`读取文件失败: ${file.name}`)); + reader.readAsText(file); + }); +} + +function MethodCard({ + title, + description, + icon: Icon, + onClick, +}: { + title: string; + description: string; + icon: typeof KeyRound; + onClick: () => void; +}) { + return ( + + ); +} + +export function AccountImportDialog({ disabled, onImported }: AccountImportDialogProps) { + const router = useRouter(); + const [open, setOpen] = useState(false); + const [method, setMethod] = useState("menu"); + const [tokenInput, setTokenInput] = useState(""); + const [sessionInput, setSessionInput] = useState(""); + const [codexAuthInput, setCodexAuthInput] = useState(""); + const [isSubmitting, setIsSubmitting] = useState(false); + const [pendingCpaImport, setPendingCpaImport] = useState(null); + const [confirmOpen, setConfirmOpen] = useState(false); + const [oauthEmailHint, setOauthEmailHint] = useState(""); + const [oauthSession, setOauthSession] = useState(null); + const [oauthCallbackInput, setOauthCallbackInput] = useState(""); + const [oauthStarting, setOauthStarting] = useState(false); + + const txtInputRef = useRef(null); + const cpaInputRef = useRef(null); + + const resetState = () => { + setMethod("menu"); + setTokenInput(""); + setSessionInput(""); + setCodexAuthInput(""); + setPendingCpaImport(null); + setConfirmOpen(false); + setOauthEmailHint(""); + setOauthSession(null); + setOauthCallbackInput(""); + setOauthStarting(false); + }; + + const handleOpenChange = (nextOpen: boolean) => { + setOpen(nextOpen); + if (!nextOpen) { + resetState(); + } + }; + + const submitTokens = async (tokens: string[], successText?: string, accountPayloads: AccountImportPayload[] = []) => { + const normalizedTokens = tokens.map((item) => item.trim()).filter(Boolean); + + if (normalizedTokens.length === 0) { + toast.error("请先提供至少一个可用 Token"); + return; + } + + setIsSubmitting(true); + try { + const data = await createAccounts(normalizedTokens, accountPayloads); + onImported(data.items); + setOpen(false); + resetState(); + + if ((data.errors?.length ?? 0) > 0) { + const firstError = data.errors?.[0]?.error; + toast.error( + `${successText ?? "导入完成"},新增 ${data.added ?? 0} 个,已刷新 ${data.refreshed ?? 0} 个,失败 ${data.errors?.length ?? 0} 个${firstError ? `,首个错误:${firstError}` : ""}`, + ); + } else { + toast.success( + `${successText ?? "导入完成"},新增 ${data.added ?? 0} 个,跳过 ${data.skipped ?? 0} 个重复项,已自动刷新账号信息`, + ); + } + } catch (error) { + const message = error instanceof Error ? error.message : "导入账户失败"; + toast.error(message); + } finally { + setIsSubmitting(false); + } + }; + + const handleImportTokenText = async () => { + await submitTokens(splitTokens(tokenInput), "Access Token 导入完成"); + }; + + // 起授权:拿 authorize URL,立刻在新窗口打开,方便用户登录 + const handleStartOAuth = async () => { + setOauthStarting(true); + try { + const data = await startOAuthLogin(oauthEmailHint.trim()); + setOauthSession(data); + setOauthCallbackInput(""); + if (typeof window !== "undefined") { + window.open(data.authorize_url, "_blank", "noopener,noreferrer"); + } + toast.success("已打开 OpenAI 授权页面,请在登录后复制 callback URL 回来"); + } catch (error) { + const message = error instanceof Error ? error.message : "OAuth 起始失败"; + toast.error(message); + } finally { + setOauthStarting(false); + } + }; + + // 用粘贴回来的 callback URL 完成换 token + 落盘 + const handleFinishOAuth = async () => { + if (!oauthSession) { + toast.error("请先点击\"打开授权页面\"获取 session"); + return; + } + const trimmed = oauthCallbackInput.trim(); + if (!trimmed) { + toast.error("请粘贴 callback URL 或 code"); + return; + } + + setIsSubmitting(true); + try { + const data = await finishOAuthLogin(oauthSession.session_id, trimmed); + onImported(data.items); + setOpen(false); + resetState(); + + if ((data.errors?.length ?? 0) > 0) { + const firstError = data.errors?.[0]?.error; + toast.error( + `OAuth 登录完成,新增 ${data.added ?? 0} 个,已刷新 ${data.refreshed ?? 0} 个,失败 ${data.errors?.length ?? 0} 个${firstError ? `,首个错误:${firstError}` : ""}`, + ); + } else { + toast.success( + `OAuth 登录完成,新增 ${data.added ?? 0} 个,跳过 ${data.skipped ?? 0} 个重复项,已自动刷新账号信息`, + ); + } + } catch (error) { + const message = error instanceof Error ? error.message : "OAuth 换 token 失败"; + toast.error(message); + } finally { + setIsSubmitting(false); + } + }; + + // 复制 authorize URL 到剪贴板(适配浏览器和 fallback) + const handleCopyAuthorizeUrl = async () => { + if (!oauthSession) { + return; + } + try { + if (navigator.clipboard?.writeText) { + await navigator.clipboard.writeText(oauthSession.authorize_url); + toast.success("授权 URL 已复制到剪贴板"); + } else { + toast.error("当前环境不支持自动复制,请手动选择并复制"); + } + } catch { + toast.error("复制失败,请手动选择并复制"); + } + }; + + const handleTxtSelected = async (event: ChangeEvent) => { + const file = event.target.files?.[0]; + event.target.value = ""; + + if (!file) { + return; + } + + try { + const content = await readFileAsText(file); + const tokens = splitTokens(content); + + if (tokens.length === 0) { + toast.error("TXT 文件里没有读取到有效 Token"); + return; + } + + setTokenInput((prev) => { + const next = [...splitTokens(prev), ...tokens]; + return next.join("\n"); + }); + toast.success(`已从 ${file.name} 读取 ${tokens.length} 个 Token`); + } catch (error) { + const message = error instanceof Error ? error.message : "读取 TXT 文件失败"; + toast.error(message); + } + }; + + const handleImportSessionJson = async () => { + if (!sessionInput.trim()) { + toast.error("请先粘贴完整 Session JSON"); + return; + } + + try { + const payload = JSON.parse(sessionInput) as unknown; + const token = getSessionAccessToken(payload); + + if (!token) { + toast.error("未从 Session JSON 中提取到 accessToken"); + return; + } + + await submitTokens([token], "Session JSON 导入完成"); + } catch (error) { + const message = error instanceof Error ? error.message : "Session JSON 解析失败"; + toast.error(message); + } + }; + + const handleImportCodexAuthJson = async () => { + if (!codexAuthInput.trim()) { + toast.error("请先粘贴 Codex 认证 JSON"); + return; + } + + try { + const payload = JSON.parse(codexAuthInput) as unknown; + const account = getCodexAuthAccount(payload); + + if (!account) { + toast.error("未从 Codex 认证 JSON 中提取到 access_token"); + return; + } + + await submitTokens([account.access_token], "Codex 认证 JSON 导入完成", [account]); + } catch (error) { + const message = error instanceof Error ? error.message : "Codex 认证 JSON 解析失败"; + toast.error(message); + } + }; + + const handleCpaSelected = async (event: ChangeEvent) => { + const files = Array.from(event.target.files ?? []); + event.target.value = ""; + + if (files.length === 0) { + return; + } + + try { + const results = await Promise.all( + files.map(async (file) => { + const raw = await readFileAsText(file); + const parsed = JSON.parse(raw) as unknown; + const account = getCpaAccount(parsed); + return { + account, + }; + }), + ); + + const accounts = results.map((item) => item.account).filter((item): item is AccountImportPayload => Boolean(item)); + const tokens = accounts.map((item) => item.access_token); + const parsedFileCount = accounts.length; + const errorCount = results.length - parsedFileCount; + + if (parsedFileCount === 0) { + toast.error("这些 CPA JSON 文件里没有读取到可用 access_token"); + return; + } + + setPendingCpaImport({ + tokens, + accounts, + parsedFileCount, + errorCount, + }); + setConfirmOpen(true); + } catch (error) { + const message = error instanceof Error ? error.message : "读取 CPA JSON 文件失败"; + toast.error(message); + } + }; + + const renderMethodBody = () => { + if (method === "token") { + const tokenCount = splitTokens(tokenInput).length; + + return ( +
+
+ + 当前识别 {tokenCount} 个 Token +
+
+ +